diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 140b2c4..ab8dce9 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -40,6 +40,7 @@ import { dashboardView } from './repos-dashboard' import { costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput, } from './repos-aws' +import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports' import { documentHtml } from './templates' import { renderPdf } from './pdf' import { emailStatus } from './repos-email' @@ -503,5 +504,22 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { } }) + // ---------- reports ---------- + const rangeOf = (req: Request): DateRange => { + const range: DateRange = {} + if (typeof req.query['from'] === 'string') range.from = req.query['from'] + if (typeof req.query['to'] === 'string') range.to = req.query['to'] + return range + } + r.get('/reports/dues-aging', requireAuth, (_req, res) => { + res.json({ ok: true, rows: duesAging(db, new Date().toISOString().slice(0, 10)) }) + }) + r.get('/reports/module-revenue', requireAuth, (req, res) => { + res.json({ ok: true, rows: moduleRevenue(db, rangeOf(req)) }) + }) + r.get('/reports/profitability', requireAuth, (req, res) => { + res.json({ ok: true, rows: clientProfitability(db, rangeOf(req)) }) + }) + return r } diff --git a/apps/hq/src/repos-payments.ts b/apps/hq/src/repos-payments.ts index 8eaecf4..004e9c8 100644 --- a/apps/hq/src/repos-payments.ts +++ b/apps/hq/src/repos-payments.ts @@ -219,7 +219,7 @@ export function clientLedger(db: DB, clientId: string): ClientLedger { * packages/billing-engine/src/compute.ts — reimplemented locally so engine * internals stay unexported). Shares sum to exactly `amount`. */ -function splitProRata(amount: number, weights: number[]): number[] { +export function splitProRata(amount: number, weights: number[]): number[] { const totalWeight = weights.reduce((a, w) => a + w, 0) if (amount === 0) return weights.map(() => 0) if (totalWeight === 0) throw new Error('Cannot allocate a nonzero amount over zero total weight') diff --git a/apps/hq/src/repos-reports.ts b/apps/hq/src/repos-reports.ts new file mode 100644 index 0000000..d20e337 --- /dev/null +++ b/apps/hq/src/repos-reports.ts @@ -0,0 +1,110 @@ +import type { DB } from './db' +import { getDocument } from './repos-documents' +import { getModule } from './repos-modules' +import { outstandingPaise, splitProRata } from './repos-payments' +import { awsCostForClient } from './repos-aws' + +/** Read-only reporting aggregations (D12 plain-repo pattern). Settlement per + * invoice is payable − outstandingPaise (= allocations + credit-notes), clamped + * to [0, payable] — identical to modulePaidView, so a paisa split here matches + * a paisa split there. Nothing here mutates. */ + +export interface DateRange { from?: string; to?: string } + +/** Whole-day difference on YYYY-MM-DD (UTC), lexical-safe like scheduler.addDaysIso. */ +function daysBetween(fromIso: string, toIso: string): number { + return Math.floor((Date.parse(toIso) - Date.parse(fromIso)) / 86_400_000) +} + +/** Issued, non-cancelled invoices, optionally within a doc_date range. */ +function invoiceIds(db: DB, clientId: string | undefined, range?: DateRange): string[] { + let sql = `SELECT id FROM document WHERE doc_type='INVOICE' AND doc_no IS NOT NULL AND status != 'cancelled'` + const args: unknown[] = [] + if (clientId !== undefined) { sql += ` AND client_id=?`; args.push(clientId) } + if (range?.from !== undefined) { sql += ` AND doc_date >= ?`; args.push(range.from) } + if (range?.to !== undefined) { sql += ` AND doc_date <= ?`; args.push(range.to) } + sql += ` ORDER BY doc_date, doc_no` + return (db.prepare(sql).all(...args) as { id: string }[]).map((r) => r.id) +} + +/** Settled amount on an invoice, clamped to [0, payable]. */ +function settledOf(db: DB, docId: string, payablePaise: number): number { + return Math.max(0, Math.min(payablePaise, payablePaise - outstandingPaise(db, docId))) +} + +export interface DuesAgingRow { + clientId: string; clientName: string + b0_30: number; b31_60: number; b61_90: number; b90p: number; totalPaise: number +} + +export function duesAging(db: DB, today: string): DuesAgingRow[] { + const invoices = db.prepare( + `SELECT d.id, d.client_id, d.doc_date, c.name AS client_name + FROM document d JOIN client c ON c.id = d.client_id + WHERE d.doc_type='INVOICE' AND d.doc_no IS NOT NULL AND d.status NOT IN ('paid','cancelled','lost')`, + ).all() as { id: string; client_id: string; doc_date: string; client_name: string }[] + const acc = new Map() + for (const inv of invoices) { + const out = outstandingPaise(db, inv.id) + if (out <= 0) continue + const row = acc.get(inv.client_id) + ?? { clientId: inv.client_id, clientName: inv.client_name, b0_30: 0, b31_60: 0, b61_90: 0, b90p: 0, totalPaise: 0 } + const age = daysBetween(inv.doc_date, today) + if (age <= 30) row.b0_30 += out + else if (age <= 60) row.b31_60 += out + else if (age <= 90) row.b61_90 += out + else row.b90p += out + row.totalPaise += out + acc.set(inv.client_id, row) + } + return [...acc.values()].sort((a, b) => b.totalPaise - a.totalPaise) +} + +export interface ModuleRevenueRow { + moduleId: string; moduleCode: string; moduleName: string; billedPaise: number; settledPaise: number +} + +export function moduleRevenue(db: DB, range?: DateRange): ModuleRevenueRow[] { + const acc = new Map() + for (const id of invoiceIds(db, undefined, range)) { + const inv = getDocument(db, id)! + const settledTotal = settledOf(db, inv.id, inv.payablePaise) + const shares = splitProRata(settledTotal, inv.payload.lines.map((l) => l.lineTotalPaise)) + inv.payload.lines.forEach((line, i) => { + const e = acc.get(line.itemId) ?? { billed: 0, settled: 0 } + e.billed += line.lineTotalPaise + e.settled += shares[i]! + acc.set(line.itemId, e) + }) + } + return [...acc.entries()].map(([moduleId, v]) => { + const mod = getModule(db, moduleId) + return { + moduleId, moduleCode: mod?.code ?? moduleId, moduleName: mod?.name ?? moduleId, + billedPaise: v.billed, settledPaise: v.settled, + } + }).sort((a, b) => b.billedPaise - a.billedPaise) +} + +export interface ProfitabilityRow { + clientId: string; clientName: string + billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number +} + +export function clientProfitability(db: DB, range?: DateRange): ProfitabilityRow[] { + const clients = db.prepare(`SELECT id, name FROM client ORDER BY name`).all() as { id: string; name: string }[] + const out: ProfitabilityRow[] = [] + for (const c of clients) { + let billed = 0 + let settled = 0 + for (const id of invoiceIds(db, c.id, range)) { + const inv = getDocument(db, id)! + billed += inv.payablePaise + settled += settledOf(db, inv.id, inv.payablePaise) + } + const awsCostPaise = awsCostForClient(db, c.id, range?.from, range?.to) + if (billed === 0 && settled === 0 && awsCostPaise === 0) continue // omit inactive clients + out.push({ clientId: c.id, clientName: c.name, billedPaise: billed, settledPaise: settled, awsCostPaise, marginPaise: settled - awsCostPaise }) + } + return out.sort((a, b) => b.marginPaise - a.marginPaise) +} diff --git a/apps/hq/test/reports.test.ts b/apps/hq/test/reports.test.ts new file mode 100644 index 0000000..fad5734 --- /dev/null +++ b/apps/hq/test/reports.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, setPrice, assignModule } from '../src/repos-modules' +import { createDraft, issueDocument } from '../src/repos-documents' +import { recordPayment } from '../src/repos-payments' +import { upsertAwsUsage } from '../src/repos-aws' +import { duesAging, moduleRevenue, clientProfitability } from '../src/repos-reports' + +function issuedInvoice(db: any, clientId: string, moduleId: string, docDate: string) { + const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { + docType: 'INVOICE', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }], + }).id) + db.prepare(`UPDATE document SET doc_date=? WHERE id=?`).run(docDate, inv.id) + return inv +} + +function setup() { + const db = openDb(':memory:'); seedIfEmpty(db) // company.state_code=32, GST18 + const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' }) + const pos = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] }) + setPrice(db, 'u1', { moduleId: pos.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) + assignModule(db, 'u1', { clientId: c.id, moduleId: pos.id, kind: 'yearly' }) + return { db, c, pos } +} + +describe('duesAging', () => { + it('buckets each client outstanding by invoice age', () => { + const { db, c, pos } = setup() + issuedInvoice(db, c.id, pos.id, '2026-07-01') // ~9 days old on 2026-07-10 → 0-30 + issuedInvoice(db, c.id, pos.id, '2026-05-20') // ~51 days → 31-60 + issuedInvoice(db, c.id, pos.id, '2026-01-01') // >90 days → 90+ + const rows = duesAging(db, '2026-07-10') + expect(rows).toHaveLength(1) + const r = rows[0]! + expect(r.clientId).toBe(c.id) + expect(r.b0_30).toBe(11_800_00) // 10,000 + 18% GST + expect(r.b31_60).toBe(11_800_00) + expect(r.b90p).toBe(11_800_00) + expect(r.totalPaise).toBe(35_400_00) + }) + it('drops fully-settled invoices', () => { + const { db, c, pos } = setup() + const inv = issuedInvoice(db, c.id, pos.id, '2026-07-01') + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-05', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }] }) + expect(duesAging(db, '2026-07-10')).toHaveLength(0) + }) +}) + +describe('moduleRevenue', () => { + it('reports billed and settled per module, settlement split pro-rata', () => { + const { db, c, pos } = setup() + const inv = issuedInvoice(db, c.id, pos.id, '2026-07-01') + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-05', mode: 'bank', amountPaise: 5_900_00, allocations: [{ documentId: inv.id, amountPaise: 5_900_00 }] }) + const rows = moduleRevenue(db) + expect(rows).toHaveLength(1) + expect(rows[0]!.moduleCode).toBe('POS') + expect(rows[0]!.billedPaise).toBe(11_800_00) + expect(rows[0]!.settledPaise).toBe(5_900_00) + }) + it('honours a date range', () => { + const { db, c, pos } = setup() + issuedInvoice(db, c.id, pos.id, '2026-04-01') + issuedInvoice(db, c.id, pos.id, '2026-07-01') + expect(moduleRevenue(db, { from: '2026-07-01', to: '2026-07-31' })[0]!.billedPaise).toBe(11_800_00) + }) +}) + +describe('clientProfitability', () => { + it('reports billed, settled, aws cost and margin per client', () => { + const { db, c, pos } = setup() + const inv = issuedInvoice(db, c.id, pos.id, '2026-07-01') + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-05', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }] }) + upsertAwsUsage(db, 'u1', { clientId: c.id, month: '2026-07', costPaise: 1_000_00 }) + const rows = clientProfitability(db, { from: '2026-07-01', to: '2026-07-31' }) + expect(rows).toHaveLength(1) + expect(rows[0]!).toMatchObject({ + billedPaise: 11_800_00, settledPaise: 11_800_00, awsCostPaise: 1_000_00, marginPaise: 10_800_00, + }) + }) +})