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