feat(payments): ledger exposes outstanding docs + per-payment allocations

clientLedger now attaches allocations (docNo + amount) to each payment and
returns an outstanding[] list — issued invoices with outstandingPaise > 0
plus open (draft/sent/accepted) proformas, oldest-first by docNo. Read-only
extension over the existing settlement math (outstandingPaise, allocations);
no money/allocation logic changed. Mirrors the widened Payment/Ledger shapes
onto apps/hq-web/src/api.ts for the upcoming Payments tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent 4b2e44c3ea
commit fdf31b4dbd

@ -217,11 +217,13 @@ export interface Payment {
id: string; clientId: string; receivedOn: string; mode: PaymentMode
reference: string; amountPaise: number; tdsPaise: number
createdBy: string; createdAt: string
allocations: { docNo: string; amountPaise: number }[]
}
export interface Ledger {
documents: Doc[]; payments: Payment[]; advancePaise: number
modulePaid: { moduleId: string; billedPaise: number; settledPaise: number }[]
outstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[]
}
// ---------- typed calls ----------

@ -231,7 +231,13 @@ export async function receiptForPayment(db: DB, userId: string, paymentId: strin
// ---------- read views ----------
export interface ClientLedger { documents: Doc[]; payments: Payment[]; advancePaise: number }
export interface LedgerOutstanding {
docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number
}
export interface LedgerPayment extends Payment { allocations: { docNo: string; amountPaise: number }[] }
export interface ClientLedger {
documents: Doc[]; payments: LedgerPayment[]; advancePaise: number; outstanding: LedgerOutstanding[]
}
export async function clientLedger(db: DB, clientId: string): Promise<ClientLedger> {
const payments = await listPayments(db, clientId)
@ -249,10 +255,40 @@ export async function clientLedger(db: DB, clientId: string): Promise<ClientLedg
documents.push(...batch.documents)
if (page * batch.pageSize >= batch.total) break
}
// Per-payment allocations (docNo + amount) for the "Settled → document" column.
const allocRows = await db.all<{ payment_id: string; doc_no: string | null; amount_paise: number }>(
`SELECT a.payment_id, d.doc_no, a.amount_paise
FROM payment_allocation a JOIN document d ON d.id = a.document_id
JOIN payment p ON p.id = a.payment_id WHERE p.client_id=?`, clientId)
const allocByPayment = new Map<string, { docNo: string; amountPaise: number }[]>()
for (const r of allocRows) {
const list = allocByPayment.get(r.payment_id) ?? []
list.push({ docNo: r.doc_no ?? '—', amountPaise: r.amount_paise })
allocByPayment.set(r.payment_id, list)
}
const ledgerPayments: LedgerPayment[] = payments.map((p) => ({ ...p, allocations: allocByPayment.get(p.id) ?? [] }))
// Open documents with money still owed — issued invoices (outstanding > 0) + open proformas.
const moduleName = async (doc: Doc): Promise<string> =>
doc.payload.lines.map((l) => l.name).filter((n) => n !== '').join(', ')
const outstanding: LedgerOutstanding[] = []
for (const d of documents) {
if (d.status === 'cancelled' || d.status === 'paid' || d.status === 'lost') continue
if (d.docType === 'INVOICE' && d.docNo !== null) {
const out = await outstandingPaise(db, d.id)
if (out > 0) outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: out })
} else if (d.docType === 'PROFORMA' && (d.status === 'sent' || d.status === 'accepted' || d.status === 'draft')) {
outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: d.payablePaise })
}
}
outstanding.sort((a, b) => (a.docNo ?? '').localeCompare(b.docNo ?? ''))
return {
documents,
payments,
payments: ledgerPayments,
advancePaise: settling - row.total,
outstanding,
}
}

@ -0,0 +1,86 @@
// apps/hq/test/ledger-outstanding.test.ts
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { createDraft, issueDocument, markStatus } from '../src/repos-documents'
import { recordPayment, clientLedger } from '../src/repos-payments'
/**
* Fixture: one client, one module priced 10,000/yr ( 11,800 with GST18).
* - INVOICE (qty 1): issued as INV/26-27-0001, payable 11,800.
* Partly paid 5,000 outstanding 6,800.
* - PROFORMA (qty 2): issued as PI/26-27-0001, payable 23,600, marked 'sent'
* (open nothing paid against a proforma).
*/
async function buildFixture(db: ReturnType<typeof openDb>) {
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})).id) // payable ₹11,800 (taxable ₹10,000 + GST18 ₹1,800)
const pf = await markStatus(db, 'u1', (await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }],
})).id)).id, 'sent') // payable ₹23,600 (taxable ₹20,000 + GST18 ₹3,600)
const { payment } = await recordPayment(db, 'u1', {
clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 5_000_00,
}) // ₹5,000 of ₹11,800 → outstanding ₹6,800
return { clientId: c.id, inv, pf, payment }
}
describe('clientLedger — outstanding + per-payment allocations', () => {
it('reports outstanding open documents oldest-first with correct due amounts', async () => {
const db = openDb(':memory:')
const { clientId, inv, pf } = await buildFixture(db)
const led = await clientLedger(db, clientId)
// INV/26-27-0001 sorts before PI/26-27-0001 (docNo lexical order, both oldest).
expect(led.outstanding.map((o) => o.docNo)).toEqual([inv.docNo, pf.docNo])
expect(led.outstanding[0]).toMatchObject({
docId: inv.id, docNo: inv.docNo, docType: 'INVOICE', label: 'POS',
outstandingPaise: 6_800_00, // 11,800 payable 5,000 allocated 0 credited
})
expect(led.outstanding[1]).toMatchObject({
docId: pf.id, docNo: pf.docNo, docType: 'PROFORMA', label: 'POS',
outstandingPaise: 23_600_00, // open proforma: full payable, nothing settled against it
})
})
it('excludes a fully-paid invoice and a cancelled/lost document from outstanding', async () => {
const db = openDb(':memory:')
const { clientId, inv } = await buildFixture(db)
// Settle the remaining 6,800 in full.
await recordPayment(db, 'u1', { clientId, receivedOn: '2026-07-11', mode: 'upi', amountPaise: 6_800_00 })
const led = await clientLedger(db, clientId)
expect(led.outstanding.find((o) => o.docId === inv.id)).toBeUndefined()
})
it('attaches allocations to each payment', async () => {
const db = openDb(':memory:')
const { clientId, inv, payment } = await buildFixture(db)
const led = await clientLedger(db, clientId)
const p = led.payments.find((x) => x.id === payment.id)!
expect(p.allocations).toEqual([{ docNo: inv.docNo, amountPaise: 5_000_00 }])
expect(p.allocations.reduce((s, a) => s + a.amountPaise, 0)).toBeLessThanOrEqual(p.amountPaise + p.tdsPaise)
})
it('a payment with no allocation (pure advance) reports an empty allocations array', async () => {
const db = openDb(':memory:')
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = await createClient(db, 'u1', { name: 'NoInvoiceCo', stateCode: '32' })
const { payment } = await recordPayment(db, 'u1', {
clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 1_000_00,
})
const led = await clientLedger(db, c.id)
expect(led.payments[0]!.id).toBe(payment.id)
expect(led.payments[0]!.allocations).toEqual([])
expect(led.outstanding).toEqual([])
})
})
Loading…
Cancel
Save