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 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) } /** Exported outstanding read for the scheduler and dashboard. */ export function outstandingPaise(db: DB, docId: string): number { const doc = getDocument(db, docId) return doc === null ? 0 : outstandingOf(db, doc) } /** 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[] issueReceipt?: boolean } export interface RecordPaymentResult { payment: Payment allocated: { documentId: string; amountPaise: number }[] receipt?: Doc } 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) } } const receipt = input.issueReceipt === true ? receiptForPayment(db, userId, id) : undefined return { payment: getPayment(db, id)!, allocated, ...(receipt !== undefined ? { receipt } : {}) } })() } /** Build a RECEIPT for an already-recorded payment from its stored allocations. */ export function receiptForPayment(db: DB, userId: string, paymentId: string): Doc { const p = getPayment(db, paymentId) if (p === null) throw new Error('Payment not found') const allocs = db.prepare( `SELECT a.amount_paise, d.doc_no FROM payment_allocation a JOIN document d ON d.id = a.document_id WHERE a.payment_id=?`, ).all(paymentId) as { amount_paise: number; doc_no: string | null }[] 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 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`. */ 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 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 })) }