You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/repos-payments.ts

343 lines
16 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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<Payment | null> {
const row = await db.get<PaymentRow>(`SELECT * FROM payment WHERE id=?`, id)
return row === undefined ? null : toPayment(row)
}
export async function listPayments(db: DB, clientId: string): Promise<Payment[]> {
const rows = await db.all<PaymentRow>(
`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<number> {
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<number> {
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<number> {
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<number> {
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<void> {
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<Doc[]> {
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<RecordPaymentResult> {
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<void> => {
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)
}
// D24 (red-team): serialize concurrent allocations to the SAME invoice so two payments
// cannot both read the old outstanding and over-allocate. On Postgres, take a row lock
// before reading outstanding — held to COMMIT. On SQLite the connection mutex already
// serializes whole transactions, and FOR UPDATE is a syntax error there, so it's a no-op.
const lockInvoice = async (docId: string): Promise<void> => {
if (db.engine === 'postgres') await db.run(`SELECT id FROM document WHERE id=? FOR UPDATE`, docId)
}
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`)
await lockInvoice(doc.id)
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
await lockInvoice(doc.id)
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<Doc> {
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 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)
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
}
// 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')) {
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: ledgerPayments,
advancePaise: settling - row.total,
outstanding,
}
}
/**
* 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<ModulePaidRow[]> {
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<string, { billedPaise: number; settledPaise: number }>()
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 }))
}