import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import type { DB } from './db' import { getClient } from './repos-clients' import { generateReceipt, 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 async function getPayment(db: DB, id: string): Promise { const row = await db.get(`SELECT * FROM payment WHERE id=?`, id) return row === undefined ? null : toPayment(row) } export async function listPayments(db: DB, clientId: string): Promise { const rows = await db.all( `SELECT * FROM payment WHERE client_id=? ORDER BY id`, // uuidv7 ids sort by creation time clientId) return rows.map(toPayment) } // ---------- settlement math ---------- async function allocatedTo(db: DB, documentId: string): Promise { const row = (await db.get<{ total: number }>( `SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`, documentId))! return row.total } /** Credit notes are "negative" only here, in settlement math (Task 8 stores them positive). */ async function creditedTo(db: DB, invoiceId: string): Promise { const row = (await db.get<{ total: number }>( `SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`, invoiceId))! return row.total } async function outstandingOf(db: DB, doc: Doc): Promise { return doc.payablePaise - await allocatedTo(db, doc.id) - await creditedTo(db, doc.id) } /** Exported outstanding read for the scheduler and dashboard. */ export async function outstandingPaise(db: DB, docId: string): Promise { const doc = await getDocument(db, docId) return doc === null ? 0 : outstandingOf(db, doc) } /** Status flips: any allocation > 0 → part_paid; outstanding ≤ 0 → paid. */ async function refreshSettlementStatus(db: DB, userId: string, docId: string): Promise { const doc = await getDocument(db, docId) if (doc === null || doc.status === 'cancelled') return const allocated = await allocatedTo(db, docId) const outstanding = doc.payablePaise - allocated - await creditedTo(db, docId) const status = outstanding <= 0 ? 'paid' : allocated > 0 ? 'part_paid' : doc.status if (status === doc.status) return await db.run(`UPDATE document SET status=? WHERE id=?`, status, docId) await writeAudit(db, userId, 'update', 'document', docId, { status: doc.status }, { status }) } /** Issued, unsettled invoices for the client, oldest first. */ async function unsettledInvoices(db: DB, clientId: string): Promise { const ids = await db.all<{ id: string }>( `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`, clientId) const docs: Doc[] = [] for (const r of ids) docs.push((await getDocument(db, r.id))!) return docs } // ---------- 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[] issueReceipt?: boolean } export interface RecordPaymentResult { payment: Payment allocated: { documentId: string; amountPaise: number }[] receipt?: Doc } export async function recordPayment(db: DB, userId: string, input: RecordPaymentInput): Promise { if (await 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)') } // D24 (red-team): TDS settles invoices from the same pool as cash, so an unbounded TDS // could 'pay' an invoice with no money. TDS is a fraction withheld from what's billed; // it can never exceed the cash actually received (a generous ceiling — real TDS on // professional services is 10%). Blocks fabricated-TDS settlement. if (tdsPaise > input.amountPaise) { throw new Error('TDS cannot exceed the cash amount received on the payment') } return db.transaction(async () => { const id = uuidv7() await db.run( `INSERT INTO payment (id, client_id, received_on, mode, reference, amount_paise, tds_paise, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, input.clientId, input.receivedOn, input.mode, input.reference ?? '', input.amountPaise, tdsPaise, userId, new Date().toISOString()) await 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 = async (documentId: string, amountPaise: number): Promise => { const allocId = uuidv7() await db.run( `INSERT INTO payment_allocation (id, payment_id, document_id, amount_paise) VALUES (?, ?, ?, ?)`, allocId, id, documentId, amountPaise) await writeAudit(db, userId, 'create', 'payment_allocation', allocId, undefined, { paymentId: id, documentId, amountPaise, }) allocated.push({ documentId, amountPaise }) await 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 = await 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 = await 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 await insertAllocation(doc.id, a.amountPaise) } } else { // Default: oldest-invoice-first, each up to its outstanding; leftover // stays unallocated (an advance). for (const doc of await unsettledInvoices(db, input.clientId)) { if (pool <= 0) break const take = Math.min(pool, await outstandingOf(db, doc)) if (take <= 0) continue pool -= take await insertAllocation(doc.id, take) } } const receipt = input.issueReceipt === true ? await receiptForPayment(db, userId, id) : undefined return { payment: (await getPayment(db, id))!, allocated, ...(receipt !== undefined ? { receipt } : {}) } }) } /** Build a RECEIPT for an already-recorded payment from its stored allocations. */ export async function receiptForPayment(db: DB, userId: string, paymentId: string): Promise { const p = await getPayment(db, paymentId) if (p === null) throw new Error('Payment not found') const allocs = await db.all<{ amount_paise: number; doc_no: string | null }>( `SELECT a.amount_paise, d.doc_no FROM payment_allocation a JOIN document d ON d.id = a.document_id WHERE a.payment_id=?`, paymentId) return generateReceipt(db, userId, { clientId: p.clientId, paymentId: p.id, receivedOn: p.receivedOn, mode: p.mode, reference: p.reference, amountPaise: p.amountPaise, tdsPaise: p.tdsPaise, allocations: allocs.map((a) => ({ docNo: a.doc_no ?? '—', amountPaise: a.amount_paise })), }) } // ---------- read views ---------- export interface ClientLedger { documents: Doc[]; payments: Payment[]; advancePaise: number } export async function clientLedger(db: DB, clientId: string): Promise { const payments = await listPayments(db, clientId) const settling = payments.reduce((sum, p) => sum + p.amountPaise + p.tdsPaise, 0) const row = (await db.get<{ total: number }>( `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=?`, clientId))! // The ledger must show EVERY document for the client (rule 8): walk the // paginated repo in max-size batches instead of trusting one page. const documents: Doc[] = [] for (let page = 1; ; page++) { const batch = await listDocuments(db, { clientId, page, pageSize: 200 }) documents.push(...batch.documents) if (page * batch.pageSize >= batch.total) break } return { documents, 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`. */ 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') 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 async function modulePaidView(db: DB, clientId: string): Promise { const invoices = await db.all<{ id: string }>( `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`, clientId) const acc = new Map() for (const { id } of invoices) { const inv = (await getDocument(db, id))! const settledTotal = Math.min( inv.payablePaise, await allocatedTo(db, inv.id) + await 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 })) }