'use strict' const cfg = require('./config') const { log, json, parseBody, erpFetch } = require('./helpers') // ── ERPNext GL query helpers ────────────────────────────────────────────── /** * Query GL Entry grouped by account & month. * Used for revenue report and tax report. * ERPNext API: frappe.client.get_list with group_by isn't great for aggregation, * so we use the report_builder run endpoint instead. */ async function glQuery (filters, fields, groupBy, orderBy, limit = 500) { const params = new URLSearchParams({ doctype: 'GL Entry', filters: JSON.stringify(filters), fields: JSON.stringify(fields), group_by: groupBy || '', order_by: orderBy || 'posting_date asc', limit_page_length: limit, }) const res = await erpFetch('/api/resource/GL Entry?' + params) if (res.status !== 200) { log('GL query error:', res.status, res.statusText) return [] } return res.data?.data || [] } /** * Fetch raw GL entries for a date range + accounts. * Returns [{posting_date, account, debit, credit, voucher_type, voucher_no, party}] */ async function fetchGLEntries (startDate, endDate, accounts = [], limit = 5000) { const filters = [ ['posting_date', '>=', startDate], ['posting_date', '<=', endDate], ['is_cancelled', '=', 0], ] if (accounts.length) { filters.push(['account', 'in', accounts]) } const fields = ['posting_date', 'account', 'debit', 'credit', 'voucher_type', 'voucher_no', 'party', 'against_voucher'] const params = new URLSearchParams({ fields: JSON.stringify(fields), filters: JSON.stringify(filters), limit_page_length: limit, order_by: 'posting_date asc', }) const res = await erpFetch('/api/resource/GL Entry?' + params) if (res.status !== 200) return [] return res.data?.data || [] } /** * Fetch accounts from Chart of Accounts matching a range or pattern. */ async function fetchAccounts (rootType, parentAccount) { const filters = [] if (rootType) filters.push(['root_type', '=', rootType]) if (parentAccount) filters.push(['parent_account', 'like', '%' + parentAccount + '%']) const params = new URLSearchParams({ fields: JSON.stringify(['name', 'account_name', 'account_number', 'root_type', 'parent_account', 'is_group']), filters: JSON.stringify(filters), limit_page_length: 200, order_by: 'account_number asc, name asc', }) const res = await erpFetch('/api/resource/Account?' + params) if (res.status !== 200) return [] return res.data?.data || [] } /** * Fetch Sales Invoices with items and taxes for the sales report. */ async function fetchSalesInvoices (startDate, endDate, limit = 2000) { const filters = [ ['posting_date', '>=', startDate], ['posting_date', '<=', endDate], ['docstatus', '=', 1], // submitted only ] const fields = [ 'name', 'posting_date', 'customer', 'customer_name', 'net_total', 'total_taxes_and_charges', 'grand_total', 'outstanding_amount', 'status', 'is_return', ] const params = new URLSearchParams({ fields: JSON.stringify(fields), filters: JSON.stringify(filters), limit_page_length: limit, order_by: 'posting_date asc', }) const res = await erpFetch('/api/resource/Sales Invoice?' + params) if (res.status !== 200) return [] return res.data?.data || [] } /** * Fetch tax breakdown for a set of invoices. */ async function fetchInvoiceTaxes (invoiceNames) { if (!invoiceNames.length) return [] // Fetch in chunks of 100 const results = [] for (let i = 0; i < invoiceNames.length; i += 100) { const chunk = invoiceNames.slice(i, i + 100) const params = new URLSearchParams({ fields: JSON.stringify(['parent', 'account_head', 'tax_amount', 'description']), filters: JSON.stringify([['parent', 'in', chunk], ['parenttype', '=', 'Sales Invoice']]), limit_page_length: chunk.length * 5, }) const res = await erpFetch('/api/resource/Sales Taxes and Charges?' + params) if (res.status === 200 && res.data?.data) results.push(...res.data.data) } return results } /** * Fetch ALL GL entries for a date range + accounts, paginating in chunks. * ERPNext REST API limits to ~100k per request; we loop until exhausted. */ async function fetchAllGLEntries (startDate, endDate, accounts = []) { const PAGE = 50000 let all = [] let offset = 0 while (true) { const filters = [ ['posting_date', '>=', startDate], ['posting_date', '<=', endDate], ['is_cancelled', '=', 0], ] if (accounts.length) filters.push(['account', 'in', accounts]) const fields = ['posting_date', 'account', 'debit', 'credit'] const params = new URLSearchParams({ fields: JSON.stringify(fields), filters: JSON.stringify(filters), limit_page_length: PAGE, limit_start: offset, order_by: 'posting_date asc', }) const res = await erpFetch('/api/resource/GL Entry?' + params) const rows = res.status === 200 ? (res.data?.data || []) : [] all.push(...rows) if (rows.length < PAGE) break offset += PAGE log(` GL fetch: ${all.length} entries so far...`) } return all } // ── Report endpoints ────────────────────────────────────────────────────── /** * GET /reports/revenue?start=2026-01-01&end=2026-12-31&mode=gl|items * Revenue by GL account per month (mirrors report_revenu.php) * mode=gl (default): aggregate GL entries by Income account * mode=items: aggregate Sales Invoice Items by item_group (useful when GL accounts not detailed) * filter=account1,account2: only show selected accounts (optional) */ async function handleRevenue (req, res, url) { const start = url.searchParams.get('start') const end = url.searchParams.get('end') const mode = url.searchParams.get('mode') || 'gl' const filterParam = url.searchParams.get('filter') || '' if (!start || !end) return json(res, 400, { error: 'start and end dates required (YYYY-MM-DD)' }) try { if (mode === 'items') { return await handleRevenueByItems(res, start, end, filterParam) } return await handleRevenueByGL(res, start, end, filterParam) } catch (e) { log('Revenue report error:', e.message) return json(res, 500, { error: e.message }) } } async function handleRevenueByGL (res, start, end, filterParam) { // 1. Get all Income accounts const accounts = await fetchAccounts('Income') const filterList = filterParam ? filterParam.split(',').map(s => s.trim()) : [] const filteredAccounts = filterList.length ? accounts.filter(a => filterList.includes(a.name) || filterList.includes(a.account_name) || filterList.includes(a.account_number)) : accounts const accountNames = filteredAccounts.map(a => a.name) if (!accountNames.length) return json(res, 200, { accounts: [], months: [], all_accounts: accounts.filter(a => !a.is_group).map(a => ({ name: a.name, label: a.account_name, number: a.account_number })) }) // 2. Fetch GL entries (paginate to handle large datasets) const entries = await fetchAllGLEntries(start, end, accountNames) // 3. Aggregate by account + month const data = {} const monthSet = new Set() for (const e of entries) { const month = e.posting_date.slice(0, 7) monthSet.add(month) if (!data[e.account]) data[e.account] = {} if (!data[e.account][month]) data[e.account][month] = 0 data[e.account][month] += (e.credit || 0) - (e.debit || 0) } const months = [...monthSet].sort() const accountData = filteredAccounts .filter(a => data[a.name]) .map(a => ({ name: a.name, label: a.account_name || a.name, number: a.account_number || '', monthly: months.map(m => Math.round((data[a.name][m] || 0) * 100) / 100), total: Math.round(Object.values(data[a.name]).reduce((s, v) => s + v, 0) * 100) / 100, })) .sort((a, b) => (b.total - a.total)) return json(res, 200, { accounts: accountData, months, mode: 'gl', all_accounts: accounts.filter(a => !a.is_group).map(a => ({ name: a.name, label: a.account_name, number: a.account_number })), }) } async function handleRevenueByItems (res, start, end, filterParam) { // Fetch Sales Invoice Items grouped by item_group const filters = [ ['posting_date', '>=', start], ['posting_date', '<=', end], ['docstatus', '=', 1], ] const params = new URLSearchParams({ fields: JSON.stringify(['posting_date', 'item_group', 'base_net_amount']), filters: JSON.stringify(filters), limit_page_length: 50000, order_by: 'posting_date asc', }) const r = await erpFetch('/api/resource/Sales Invoice Item?' + params) const items = r.status === 200 ? (r.data?.data || []) : [] const filterList = filterParam ? filterParam.split(',').map(s => s.trim()) : [] // Aggregate by item_group + month const data = {} const monthSet = new Set() const groupSet = new Set() for (const item of items) { const group = item.item_group || 'Sans catégorie' if (filterList.length && !filterList.includes(group)) continue const month = (item.posting_date || '').slice(0, 7) if (!month) continue monthSet.add(month) groupSet.add(group) if (!data[group]) data[group] = {} if (!data[group][month]) data[group][month] = 0 data[group][month] += item.base_net_amount || 0 } const months = [...monthSet].sort() const groups = [...groupSet] const accountData = groups .map(g => ({ name: g, label: g, number: '', monthly: months.map(m => Math.round((data[g][m] || 0) * 100) / 100), total: Math.round(Object.values(data[g]).reduce((s, v) => s + v, 0) * 100) / 100, })) .sort((a, b) => (b.total - a.total)) return json(res, 200, { accounts: accountData, months, mode: 'items', all_accounts: groups.map(g => ({ name: g, label: g, number: '' })), }) } /** * GET /reports/sales?start=2026-01-01&end=2026-01-31 * Sales detail with tax breakdown (mirrors report_vente.php) */ async function handleSales (req, res, url) { const start = url.searchParams.get('start') const end = url.searchParams.get('end') if (!start || !end) return json(res, 400, { error: 'start and end dates required' }) try { // 1. Fetch invoices const invoices = await fetchSalesInvoices(start, end) // 2. Fetch taxes for all invoices const names = invoices.map(i => i.name) const taxes = await fetchInvoiceTaxes(names) // 3. Build tax map: { invoiceName: { TPS: amount, TVQ: amount } } const taxMap = {} for (const t of taxes) { if (!taxMap[t.parent]) taxMap[t.parent] = {} // Detect TPS vs TVQ by account head name const key = (t.account_head || '').toLowerCase().includes('tps') ? 'TPS' : (t.account_head || '').toLowerCase().includes('tvq') ? 'TVQ' : (t.description || '').toLowerCase().includes('tps') ? 'TPS' : (t.description || '').toLowerCase().includes('tvq') ? 'TVQ' : 'Autre' taxMap[t.parent][key] = (taxMap[t.parent][key] || 0) + (t.tax_amount || 0) } // 4. Compose result const rows = invoices.map(inv => ({ name: inv.name, date: inv.posting_date, customer: inv.customer, customer_name: inv.customer_name, subtotal: inv.net_total, tps: taxMap[inv.name]?.TPS || 0, tvq: taxMap[inv.name]?.TVQ || 0, total: inv.grand_total, outstanding: inv.outstanding_amount, status: inv.status, is_return: inv.is_return, })) // 5. Summary const summary = rows.reduce((s, r) => { s.subtotal += r.subtotal s.tps += r.tps s.tvq += r.tvq s.total += r.total s.count++ if (r.is_return) s.returns++ return s }, { subtotal: 0, tps: 0, tvq: 0, total: 0, count: 0, returns: 0 }) return json(res, 200, { rows, summary }) } catch (e) { log('Sales report error:', e.message) return json(res, 500, { error: e.message }) } } /** * GET /reports/taxes?start=2026-01-01&end=2026-03-31&period=monthly|quarterly * Tax report: TPS/TVQ collected vs paid (mirrors rapport_tax.php) */ async function handleTaxes (req, res, url) { const start = url.searchParams.get('start') const end = url.searchParams.get('end') const period = url.searchParams.get('period') || 'monthly' // monthly | quarterly if (!start || !end) return json(res, 400, { error: 'start and end dates required' }) try { // Get all accounts to find tax accounts const allAccounts = await fetchAccounts() // Find TPS/TVQ accounts by name pattern or account number // Legacy: 2300=TPS perçue, 2305=TPS payée, 2350=TVQ perçue, 2355=TVQ payée const matchAny = (a, keywords) => { const name = (a.account_name || '').toLowerCase() const num = a.account_number || '' return keywords.some(k => name.includes(k) || num === k) } const tpsCollected = allAccounts.filter(a => matchAny(a, ['2300']) || (matchAny(a, ['tps']) && matchAny(a, ['perçue', 'percue', 'collect', 'à payer'])) ).map(a => a.name) const tpsPaid = allAccounts.filter(a => matchAny(a, ['2305']) || (matchAny(a, ['tps']) && matchAny(a, ['payée', 'payee', 'paid', 'input'])) ).map(a => a.name) const tvqCollected = allAccounts.filter(a => matchAny(a, ['2350']) || (matchAny(a, ['tvq']) && matchAny(a, ['perçue', 'percue', 'collect', 'à payer'])) ).map(a => a.name) const tvqPaid = allAccounts.filter(a => matchAny(a, ['2355']) || (matchAny(a, ['tvq']) && matchAny(a, ['payée', 'payee', 'paid', 'input'])) ).map(a => a.name) // Also find Income accounts for revenue total const incomeAccounts = allAccounts .filter(a => a.root_type === 'Income') .map(a => a.name) // Fetch all relevant GL entries const allRelevant = [...tpsCollected, ...tpsPaid, ...tvqCollected, ...tvqPaid, ...incomeAccounts] const entries = await fetchGLEntries(start, end, allRelevant, 10000) // Generate period buckets const buckets = generatePeriodBuckets(start, end, period) // Aggregate per bucket const result = buckets.map(bucket => { const bucketEntries = entries.filter(e => e.posting_date >= bucket.start && e.posting_date <= bucket.end ) const sumByAccounts = (accts, creditPositive = true) => { return bucketEntries .filter(e => accts.includes(e.account)) .reduce((sum, e) => sum + (creditPositive ? (e.credit - e.debit) : (e.debit - e.credit)), 0) } return { label: bucket.label, start: bucket.start, end: bucket.end, tps_collected: Math.round(sumByAccounts(tpsCollected) * 100) / 100, tps_paid: Math.round(sumByAccounts(tpsPaid, false) * 100) / 100, tvq_collected: Math.round(sumByAccounts(tvqCollected) * 100) / 100, tvq_paid: Math.round(sumByAccounts(tvqPaid, false) * 100) / 100, revenue: Math.round(sumByAccounts(incomeAccounts) * 100) / 100, } }) // Add net amounts for (const r of result) { r.tps_net = Math.round((r.tps_collected - r.tps_paid) * 100) / 100 r.tvq_net = Math.round((r.tvq_collected - r.tvq_paid) * 100) / 100 r.total_tax_net = Math.round((r.tps_net + r.tvq_net) * 100) / 100 } return json(res, 200, { periods: result, accounts: { tps_collected: tpsCollected, tps_paid: tpsPaid, tvq_collected: tvqCollected, tvq_paid: tvqPaid, income: incomeAccounts.length, }, }) } catch (e) { log('Tax report error:', e.message) return json(res, 500, { error: e.message }) } } /** * GET /reports/accounts-receivable?as_of=2026-04-01 * Aging report (mirrors rapport_age_compte.php) */ async function handleAR (req, res, url) { const asOf = url.searchParams.get('as_of') || new Date().toISOString().slice(0, 10) try { // Fetch unpaid invoices const filters = [ ['docstatus', '=', 1], ['outstanding_amount', '!=', 0], ['posting_date', '<=', asOf], ] const fields = [ 'name', 'posting_date', 'due_date', 'customer', 'customer_name', 'grand_total', 'outstanding_amount', ] const params = new URLSearchParams({ fields: JSON.stringify(fields), filters: JSON.stringify(filters), limit_page_length: 5000, order_by: 'posting_date asc', }) const r = await erpFetch('/api/resource/Sales Invoice?' + params) const invoices = r.status === 200 ? (r.data?.data || []) : [] const asOfDate = new Date(asOf) const buckets = { current: 0, d30: 0, d60: 0, d90: 0, d120: 0 } const customers = {} for (const inv of invoices) { const due = new Date(inv.due_date || inv.posting_date) const daysOverdue = Math.floor((asOfDate - due) / 86400000) const amt = inv.outstanding_amount || 0 const bucket = daysOverdue <= 0 ? 'current' : daysOverdue <= 30 ? 'd30' : daysOverdue <= 60 ? 'd60' : daysOverdue <= 90 ? 'd90' : 'd120' buckets[bucket] += amt if (!customers[inv.customer]) { customers[inv.customer] = { customer: inv.customer, customer_name: inv.customer_name, current: 0, d30: 0, d60: 0, d90: 0, d120: 0, total: 0, } } customers[inv.customer][bucket] += amt customers[inv.customer].total += amt } const rows = Object.values(customers).sort((a, b) => b.total - a.total) const total = Object.values(buckets).reduce((s, v) => s + v, 0) return json(res, 200, { as_of: asOf, summary: { ...buckets, total: Math.round(total * 100) / 100 }, rows: rows.map(r => ({ ...r, current: Math.round(r.current * 100) / 100, d30: Math.round(r.d30 * 100) / 100, d60: Math.round(r.d60 * 100) / 100, d90: Math.round(r.d90 * 100) / 100, d120: Math.round(r.d120 * 100) / 100, total: Math.round(r.total * 100) / 100, })), invoice_count: invoices.length, }) } catch (e) { log('AR report error:', e.message) return json(res, 500, { error: e.message }) } } // ── Helpers ─────────────────────────────────────────────────────────────── function generatePeriodBuckets (startStr, endStr, period) { const buckets = [] const start = new Date(startStr + 'T00:00:00') const end = new Date(endStr + 'T23:59:59') const FR_MONTHS = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'] if (period === 'quarterly') { let cur = new Date(start.getFullYear(), Math.floor(start.getMonth() / 3) * 3, 1) while (cur <= end) { const qEnd = new Date(cur.getFullYear(), cur.getMonth() + 3, 0) // last day of quarter const Q = Math.floor(cur.getMonth() / 3) + 1 buckets.push({ label: `T${Q} ${cur.getFullYear()}`, start: cur.toISOString().slice(0, 10), end: (qEnd > end ? end : qEnd).toISOString().slice(0, 10), }) cur = new Date(cur.getFullYear(), cur.getMonth() + 3, 1) } } else { let cur = new Date(start.getFullYear(), start.getMonth(), 1) while (cur <= end) { const mEnd = new Date(cur.getFullYear(), cur.getMonth() + 1, 0) buckets.push({ label: FR_MONTHS[cur.getMonth()] + ' ' + cur.getFullYear(), start: cur.toISOString().slice(0, 10), end: (mEnd > end ? end : mEnd).toISOString().slice(0, 10), }) cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1) } } return buckets } // ── Route handler ───────────────────────────────────────────────────────── async function handle (req, res, method, path, url) { if (method !== 'GET') return json(res, 405, { error: 'GET only' }) if (path === '/reports/revenue') return handleRevenue(req, res, url) if (path === '/reports/sales') return handleSales(req, res, url) if (path === '/reports/taxes') return handleTaxes(req, res, url) if (path === '/reports/ar') return handleAR(req, res, url) if (path === '/reports/accounts') { const type = url.searchParams.get('type') || 'Income' const accounts = await fetchAccounts(type) return json(res, 200, { accounts }) } return json(res, 404, { error: 'Report not found' }) } module.exports = { handle }