feat(hq): payments with allocation, TDS and advances (HQ-1 task 9)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 21ccaff363
commit 5fc07b97ed

@ -15,6 +15,9 @@ import {
issueDocument, listDocumentEvents, listDocuments, markStatus,
type DocumentFilter, type DraftInput, type DraftLineInput,
} from './repos-documents'
import {
clientLedger, modulePaidView, recordPayment, type RecordPaymentInput,
} from './repos-payments'
const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id
@ -190,5 +193,22 @@ export function apiRouter(db: DB): Router {
return createCreditNote(db, staffId(res), id, lines)
}))
// ---------- payments ----------
r.post('/payments', requireAuth, (req, res) => {
try {
const out = recordPayment(db, staffId(res), req.body as RecordPaymentInput)
res.json({ ok: true, ...out })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.get('/clients/:id/ledger', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getClient(db, id) === null) {
res.status(404).json({ ok: false, error: 'Client not found' }); return
}
res.json({ ok: true, ...clientLedger(db, id), modulePaid: modulePaidView(db, id) })
})
return r
}

@ -0,0 +1,258 @@
import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit'
import type { DB } from './db'
import { getClient } from './repos-clients'
import { 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)
}
/** 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[]
}
export interface RecordPaymentResult {
payment: Payment
allocated: { documentId: string; amountPaise: number }[]
}
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)
}
}
return { payment: getPayment(db, id)!, allocated }
})()
}
// ---------- 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`.
*/
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 }))
}

@ -0,0 +1,45 @@
// apps/hq/test/payments.test.ts
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { createDraft, issueDocument, getDocument } from '../src/repos-documents'
import { recordPayment, clientLedger, modulePaidView } from '../src/repos-payments'
function invoiceFor(db: any, clientId: string, moduleId: string) {
return issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId,
lines: [{ moduleId, qty: 1, kind: 'yearly' }] }).id)
}
describe('payments', () => {
it('oldest-first default allocation; part_paid then paid; leftover is an advance', () => {
const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run()
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run()
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const inv1 = invoiceFor(db, c.id, m.id) // ₹11,800
const inv2 = invoiceFor(db, c.id, m.id) // ₹11,800
recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 15_000_00 })
expect(getDocument(db, inv1.id)!.status).toBe('paid') // 11,800 settled
expect(getDocument(db, inv2.id)!.status).toBe('part_paid') // 3,200 of 11,800
recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-11', mode: 'upi', amountPaise: 10_000_00 })
expect(getDocument(db, inv2.id)!.status).toBe('paid')
expect(clientLedger(db, c.id).advancePaise).toBe(1_400_00) // 25,000 23,600
})
it('invoice-minus-TDS settles in full', () => {
const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run()
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run()
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const inv = invoiceFor(db, c.id, m.id) // ₹11,800; client pays minus 10% TDS on ₹10,000
recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank',
amountPaise: 10_800_00, tdsPaise: 1_000_00 })
expect(getDocument(db, inv.id)!.status).toBe('paid')
const view = modulePaidView(db, c.id)
expect(view[0]).toMatchObject({ moduleId: m.id, billedPaise: 11_800_00, settledPaise: 11_800_00 })
})
})
Loading…
Cancel
Save