/** * Task 1771 — the outstanding invariant, defined exactly once. * * Nothing stores a balance. `outstanding`, `balance` and `amountPaid` are * forbidden properties on :Invoice and :InboundInvoice (schema-base.md * § Forbidden Properties), because a stored figure drifts silently the moment * a payment lands by any other path. Every consumer computes from this module. */ import { toMinor, fromMinor } from './money.js' export type ApplyResult = | { accepted: true; outstandingAfter: number } | { accepted: false; reason: 'over-application'; remaining: number } /** * outstanding = totalPaymentDue - sum(payments) - sum(credits). * * A credit reduces what is owed exactly as a payment does; the two differ in * provenance, not arithmetic, which is why they are summed together here. */ export function computeOutstanding( totalPaymentDue: number, payments: readonly number[], credits: readonly number[], ): number { const applied = [...payments, ...credits].reduce((acc, a) => acc + toMinor(a), 0) return fromMinor(toMinor(totalPaymentDue) - applied) } /** * Validates one candidate payment or credit against what is still outstanding. * * Never clamps. A candidate that would take applied-to-date above the invoice * total is rejected outright and the caller reports it, rather than silently * recording a figure that does not reconcile. A non-positive candidate is * rejected for the same reason: it is not a payment. */ export function applyPayment( totalPaymentDue: number, payments: readonly number[], credits: readonly number[], candidate: number, ): ApplyResult { const remaining = computeOutstanding(totalPaymentDue, payments, credits) if (toMinor(candidate) <= 0) { return { accepted: false, reason: 'over-application', remaining } } if (toMinor(candidate) > toMinor(remaining)) { return { accepted: false, reason: 'over-application', remaining } } return { accepted: true, outstandingAfter: fromMinor(toMinor(remaining) - toMinor(candidate)) } }