From 5fc07b97ed2d04c8bd9904ad1e84a93337abd2cc Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 01:15:50 +0530 Subject: [PATCH] feat(hq): payments with allocation, TDS and advances (HQ-1 task 9) Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 20 +++ apps/hq/src/repos-payments.ts | 258 ++++++++++++++++++++++++++++++++++ apps/hq/test/payments.test.ts | 45 ++++++ 3 files changed, 323 insertions(+) create mode 100644 apps/hq/src/repos-payments.ts create mode 100644 apps/hq/test/payments.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index c444b8d..c0f5e0c 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -15,6 +15,9 @@ import { issueDocument, listDocumentEvents, listDocuments, markStatus, type DocumentFilter, type DraftInput, type DraftLineInput, } from './repos-documents' +import { + clientLedger, modulePaidView, recordPayment, type RecordPaymentInput, +} from './repos-payments' const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id @@ -190,5 +193,22 @@ export function apiRouter(db: DB): Router { return createCreditNote(db, staffId(res), id, lines) })) + // ---------- payments ---------- + r.post('/payments', requireAuth, (req, res) => { + try { + const out = recordPayment(db, staffId(res), req.body as RecordPaymentInput) + res.json({ ok: true, ...out }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.get('/clients/:id/ledger', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClient(db, id) === null) { + res.status(404).json({ ok: false, error: 'Client not found' }); return + } + res.json({ ok: true, ...clientLedger(db, id), modulePaid: modulePaidView(db, id) }) + }) + return r } diff --git a/apps/hq/src/repos-payments.ts b/apps/hq/src/repos-payments.ts new file mode 100644 index 0000000..f3ae94e --- /dev/null +++ b/apps/hq/src/repos-payments.ts @@ -0,0 +1,258 @@ +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' +import { getClient } from './repos-clients' +import { getDocument, listDocuments, type Doc } from './repos-documents' + +/** + * Payments — allocation, TDS, advances, settlement (D12 portable-repo pattern). + * + * Settlement per invoice: outstanding = payable − Σ allocations − Σ credit-note + * payable (CNs whose ref_doc_id = the invoice). TDS spreads with the payment: + * the effective settling power of a payment is amount + tds, allocated as one + * pool. Leftover pool stays unallocated — the advance is computed on read as + * Σ(amount + tds) − Σ allocations; no extra table. + */ + +export type PaymentMode = 'bank' | 'upi' | 'cheque' | 'cash' | 'other' + +const ALL_MODES: PaymentMode[] = ['bank', 'upi', 'cheque', 'cash', 'other'] + +export interface Payment { + id: string; clientId: string; receivedOn: string; mode: PaymentMode + reference: string; amountPaise: number; tdsPaise: number + createdBy: string; createdAt: string +} + +interface PaymentRow { + id: string; client_id: string; received_on: string; mode: string + reference: string; amount_paise: number; tds_paise: number + created_by: string; created_at: string +} + +function toPayment(r: PaymentRow): Payment { + return { + id: r.id, clientId: r.client_id, receivedOn: r.received_on, + mode: r.mode as PaymentMode, reference: r.reference, + amountPaise: r.amount_paise, tdsPaise: r.tds_paise, + createdBy: r.created_by, createdAt: r.created_at, + } +} + +export function getPayment(db: DB, id: string): Payment | null { + const row = db.prepare(`SELECT * FROM payment WHERE id=?`).get(id) as PaymentRow | undefined + return row === undefined ? null : toPayment(row) +} + +export function listPayments(db: DB, clientId: string): Payment[] { + const rows = db.prepare( + `SELECT * FROM payment WHERE client_id=? ORDER BY id`, // uuidv7 ids sort by creation time + ).all(clientId) as PaymentRow[] + return rows.map(toPayment) +} + +// ---------- settlement math ---------- + +function allocatedTo(db: DB, documentId: string): number { + const row = db.prepare( + `SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`, + ).get(documentId) as { total: number } + return row.total +} + +/** Credit notes are "negative" only here, in settlement math (Task 8 stores them positive). */ +function creditedTo(db: DB, invoiceId: string): number { + const row = db.prepare( + `SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document + WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`, + ).get(invoiceId) as { total: number } + return row.total +} + +function outstandingOf(db: DB, doc: Doc): number { + return doc.payablePaise - allocatedTo(db, doc.id) - creditedTo(db, doc.id) +} + +/** Status flips: any allocation > 0 → part_paid; outstanding ≤ 0 → paid. */ +function refreshSettlementStatus(db: DB, userId: string, docId: string): void { + const doc = getDocument(db, docId) + if (doc === null || doc.status === 'cancelled') return + const allocated = allocatedTo(db, docId) + const outstanding = doc.payablePaise - allocated - creditedTo(db, docId) + const status = outstanding <= 0 ? 'paid' : allocated > 0 ? 'part_paid' : doc.status + if (status === doc.status) return + db.prepare(`UPDATE document SET status=? WHERE id=?`).run(status, docId) + writeAudit(db, userId, 'update', 'document', docId, { status: doc.status }, { status }) +} + +/** Issued, unsettled invoices for the client, oldest first. */ +function unsettledInvoices(db: DB, clientId: string): Doc[] { + const ids = db.prepare( + `SELECT id FROM document + WHERE client_id=? AND doc_type='INVOICE' AND doc_no IS NOT NULL + AND status NOT IN ('cancelled', 'paid') + ORDER BY doc_date, doc_no`, + ).all(clientId) as { id: string }[] + return ids.map((r) => getDocument(db, r.id)!) +} + +// ---------- recording ---------- + +export interface AllocationInput { documentId: string; amountPaise: number } + +export interface RecordPaymentInput { + clientId: string; receivedOn: string; mode: PaymentMode; reference?: string + amountPaise: number; tdsPaise?: number; allocations?: AllocationInput[] +} + +export interface RecordPaymentResult { + payment: Payment + allocated: { documentId: string; amountPaise: number }[] +} + +export function recordPayment(db: DB, userId: string, input: RecordPaymentInput): RecordPaymentResult { + if (getClient(db, input.clientId) === null) throw new Error('Client not found') + if (!ALL_MODES.includes(input.mode)) throw new Error(`Unknown payment mode: ${input.mode}`) + if (!Number.isInteger(input.amountPaise) || input.amountPaise <= 0) { + throw new Error('amountPaise must be a positive integer (paise)') + } + const tdsPaise = input.tdsPaise ?? 0 + if (!Number.isInteger(tdsPaise) || tdsPaise < 0) { + throw new Error('tdsPaise must be a non-negative integer (paise)') + } + return db.transaction(() => { + const id = uuidv7() + db.prepare( + `INSERT INTO payment (id, client_id, received_on, mode, reference, amount_paise, tds_paise, + created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, input.clientId, input.receivedOn, input.mode, input.reference ?? '', + input.amountPaise, tdsPaise, userId, new Date().toISOString(), + ) + writeAudit(db, userId, 'create', 'payment', id, undefined, { + clientId: input.clientId, receivedOn: input.receivedOn, mode: input.mode, + amountPaise: input.amountPaise, tdsPaise, + }) + + // One pool: TDS certificates settle invoices just like money received. + let pool = input.amountPaise + tdsPaise + const allocated: { documentId: string; amountPaise: number }[] = [] + const insertAllocation = (documentId: string, amountPaise: number): void => { + const allocId = uuidv7() + db.prepare( + `INSERT INTO payment_allocation (id, payment_id, document_id, amount_paise) VALUES (?, ?, ?, ?)`, + ).run(allocId, id, documentId, amountPaise) + writeAudit(db, userId, 'create', 'payment_allocation', allocId, undefined, { + paymentId: id, documentId, amountPaise, + }) + allocated.push({ documentId, amountPaise }) + refreshSettlementStatus(db, userId, documentId) + } + + if (input.allocations !== undefined) { + // Explicit allocations bypass the oldest-first loop, but never exceed + // an invoice's outstanding or the payment's settling power. + for (const a of input.allocations) { + if (!Number.isInteger(a.amountPaise) || a.amountPaise <= 0) { + throw new Error('Allocation amountPaise must be a positive integer (paise)') + } + const doc = getDocument(db, a.documentId) + if (doc === null) throw new Error(`Document not found: ${a.documentId}`) + if (doc.docType !== 'INVOICE') throw new Error(`Can only allocate payments to invoices: ${a.documentId}`) + if (doc.docNo === null) throw new Error(`Invoice is not issued: ${a.documentId}`) + if (doc.status === 'cancelled') throw new Error(`Invoice is cancelled: ${doc.docNo}`) + if (doc.clientId !== input.clientId) throw new Error(`Invoice ${doc.docNo} belongs to another client`) + const outstanding = outstandingOf(db, doc) + if (a.amountPaise > outstanding) { + throw new Error(`Allocation ${a.amountPaise} exceeds outstanding ${outstanding} on ${doc.docNo}`) + } + if (a.amountPaise > pool) { + throw new Error(`Allocations exceed the payment's settling power (amount + TDS)`) + } + pool -= a.amountPaise + insertAllocation(doc.id, a.amountPaise) + } + } else { + // Default: oldest-invoice-first, each up to its outstanding; leftover + // stays unallocated (an advance). + for (const doc of unsettledInvoices(db, input.clientId)) { + if (pool <= 0) break + const take = Math.min(pool, outstandingOf(db, doc)) + if (take <= 0) continue + pool -= take + insertAllocation(doc.id, take) + } + } + + return { payment: getPayment(db, id)!, allocated } + })() +} + +// ---------- read views ---------- + +export interface ClientLedger { documents: Doc[]; payments: Payment[]; advancePaise: number } + +export function clientLedger(db: DB, clientId: string): ClientLedger { + const payments = listPayments(db, clientId) + const settling = payments.reduce((sum, p) => sum + p.amountPaise + p.tdsPaise, 0) + const row = db.prepare( + `SELECT COALESCE(SUM(a.amount_paise), 0) AS total + FROM payment_allocation a JOIN payment p ON p.id = a.payment_id + WHERE p.client_id=?`, + ).get(clientId) as { total: number } + return { + documents: listDocuments(db, { clientId }), + payments, + advancePaise: settling - row.total, + } +} + +/** + * Largest-remainder pro-rata split (same technique as allocateProRata in + * 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[] { + 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') + const raw = weights.map((w) => (amount * w) / totalWeight) + const shares = raw.map(Math.floor) + let remainder = amount - shares.reduce((a, s) => a + s, 0) + const order = raw + .map((r, i) => ({ frac: r - Math.floor(r), i })) + .sort((a, b) => b.frac - a.frac || a.i - b.i) + for (let k = 0; remainder > 0; k = (k + 1) % order.length, remainder--) shares[order[k]!.i]! += 1 + return shares +} + +export interface ModulePaidRow { moduleId: string; billedPaise: number; settledPaise: number } + +/** + * Per-module billed vs settled: invoice-level settlement spread pro-rata + * across the invoice's lines by lineTotalPaise (largest-remainder, so the + * split sums exactly). + */ +export function modulePaidView(db: DB, clientId: string): ModulePaidRow[] { + const invoices = db.prepare( + `SELECT id FROM document + WHERE client_id=? AND doc_type='INVOICE' AND doc_no IS NOT NULL AND status != 'cancelled' + ORDER BY doc_date, doc_no`, + ).all(clientId) as { id: string }[] + const acc = new Map() + for (const { id } of invoices) { + const inv = getDocument(db, id)! + const settledTotal = Math.min( + inv.payablePaise, allocatedTo(db, inv.id) + creditedTo(db, inv.id), + ) + const shares = splitProRata(settledTotal, inv.payload.lines.map((l) => l.lineTotalPaise)) + inv.payload.lines.forEach((line, i) => { + const entry = acc.get(line.itemId) ?? { billedPaise: 0, settledPaise: 0 } + entry.billedPaise += line.lineTotalPaise + entry.settledPaise += shares[i]! + acc.set(line.itemId, entry) + }) + } + return [...acc.entries()].map(([moduleId, v]) => ({ moduleId, ...v })) +} diff --git a/apps/hq/test/payments.test.ts b/apps/hq/test/payments.test.ts new file mode 100644 index 0000000..ef44ee9 --- /dev/null +++ b/apps/hq/test/payments.test.ts @@ -0,0 +1,45 @@ +// apps/hq/test/payments.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, getDocument } from '../src/repos-documents' +import { recordPayment, clientLedger, modulePaidView } from '../src/repos-payments' + +function invoiceFor(db: any, clientId: string, moduleId: string) { + return issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId, + lines: [{ moduleId, qty: 1, kind: 'yearly' }] }).id) +} + +describe('payments', () => { + it('oldest-first default allocation; part_paid then paid; leftover is an advance', () => { + const db = openDb(':memory:') + db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() + db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) + const inv1 = invoiceFor(db, c.id, m.id) // ₹11,800 + const inv2 = invoiceFor(db, c.id, m.id) // ₹11,800 + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 15_000_00 }) + expect(getDocument(db, inv1.id)!.status).toBe('paid') // 11,800 settled + expect(getDocument(db, inv2.id)!.status).toBe('part_paid') // 3,200 of 11,800 + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-11', mode: 'upi', amountPaise: 10_000_00 }) + expect(getDocument(db, inv2.id)!.status).toBe('paid') + expect(clientLedger(db, c.id).advancePaise).toBe(1_400_00) // 25,000 − 23,600 + }) + it('invoice-minus-TDS settles in full', () => { + const db = openDb(':memory:') + db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() + db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) + const inv = invoiceFor(db, c.id, m.id) // ₹11,800; client pays minus 10% TDS on ₹10,000 + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', + amountPaise: 10_800_00, tdsPaise: 1_000_00 }) + expect(getDocument(db, inv.id)!.status).toBe('paid') + const view = modulePaidView(db, c.id) + expect(view[0]).toMatchObject({ moduleId: m.id, billedPaise: 11_800_00, settledPaise: 11_800_00 }) + }) +})