feat(hq): reports — dues aging, module revenue, client profitability with margin
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
51732df5e5
commit
42cfa3d0b2
@ -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…
Reference in New Issue