feat(hq): reports — dues aging, module revenue, client profitability with margin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 51732df5e5
commit 42cfa3d0b2

@ -40,6 +40,7 @@ import { dashboardView } from './repos-dashboard'
import { import {
costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput, costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput,
} from './repos-aws' } from './repos-aws'
import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports'
import { documentHtml } from './templates' import { documentHtml } from './templates'
import { renderPdf } from './pdf' import { renderPdf } from './pdf'
import { emailStatus } from './repos-email' import { emailStatus } from './repos-email'
@ -503,5 +504,22 @@ export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): 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 return r
} }

@ -219,7 +219,7 @@ export function clientLedger(db: DB, clientId: string): ClientLedger {
* packages/billing-engine/src/compute.ts reimplemented locally so engine * packages/billing-engine/src/compute.ts reimplemented locally so engine
* internals stay unexported). Shares sum to exactly `amount`. * 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) const totalWeight = weights.reduce((a, w) => a + w, 0)
if (amount === 0) return weights.map(() => 0) if (amount === 0) return weights.map(() => 0)
if (totalWeight === 0) throw new Error('Cannot allocate a nonzero amount over zero total weight') if (totalWeight === 0) throw new Error('Cannot allocate a nonzero amount over zero total weight')

@ -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<string, DuesAgingRow>()
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<string, { billed: number; settled: number }>()
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)
}

@ -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,
})
})
})
Loading…
Cancel
Save