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

283 lines
12 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 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<string, { billedPaise: number; settledPaise: number }>()
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 }))
}