/** * Shared payment/settlement math for invoices. Both the global * `BillingService.recordPayment` and the warehouse fee invoice flow apply a * payment the same way — accumulate `paidAmount`, derive the outstanding * `balanceAmount`, and decide whether the invoice is now fully settled. Keeping * it here means the two flows can never drift on rounding or the * partial-vs-full threshold. */ /** Round to 2 decimals, avoiding binary float drift. */ export const round2 = (n: number): number => Math.round(n * 100) / 100; export interface SettlementResult { /** New cumulative amount paid. */ paidAmount: number; /** Remaining balance (0 once fully paid). */ balanceAmount: number; /** True once the balance reaches zero. */ fullyPaid: boolean; } /** * Apply a single payment of `amount` to an invoice with `totalAmount` already * carrying `currentPaid`. Caller is responsible for validating `amount > 0` and * the invoice being in a payable state. */ export function applySettlement( totalAmount: number, currentPaid: number, amount: number, ): SettlementResult { const total = Number(totalAmount); const paidAmount = round2(Number(currentPaid) + Number(amount)); const balanceAmount = Math.max(0, round2(total - paidAmount)); return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total }; } /** * SQL for an invoice's settled payment method, normalised to one vocabulary. * * Two sources have to be merged: gateway settlements carry the real provider on * the linked `freight.payments` row (`cbe-bill`, `telebirr`, …) while the * invoice's own `payments` ledger only records a flat `"GATEWAY"`; manual * settlements have no payments row at all and the ledger is the ONLY source * (`BANK_TRANSFER`, `OFFLINE`, or whatever `PayInvoiceDto.method` carried). * So: provider first, newest ledger entry as the fallback. * * `-> -1` is the last ledger element — the ledger is appended newest-last. * `::text` is not cosmetic: `payments.method` is a real Postgres enum, and * COALESCE against a text fallback fails without the cast. * * Normalised UPPER_SNAKE so `cbe-bill` and a hand-typed `CBE_BILL` are one * value on screen, in the filter and in the export. */ export const invoicePaymentMethodExpr = (invoice: string, payment: string): string => `UPPER(REPLACE(COALESCE(${payment}.method::text, ${invoice}.payments -> -1 ->> 'method'), '-', '_'))`; /** * The methods the filter offers. Not exhaustive by construction — the manual * pay endpoint takes a free-form `method` string — so nothing validates against * this list; it is the pick-list, not a constraint. */ export const INVOICE_PAYMENT_METHODS = [ "TELEBIRR", "CBE_BIRR", "CBE_BILL", "EBIRR", "WAAFI", "CARD", "DMONEY", "CAC_BANK", "BANK_TRANSFER", "OFFLINE", /** Settled at a gateway whose provider row is no longer linked. */ "GATEWAY", ] as const;