|
|
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 }))
|
|
|
}
|