mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
The settled method is split across two stores: a gateway settlement records the real provider on the linked freight.payments row (cbe-bill, telebirr) while the invoice's own payments ledger only writes a flat "GATEWAY"; a manual settlement has no payments row at all and the ledger is the only source (BANK_TRANSFER, OFFLINE, or whatever PayInvoiceDto.method carried). invoicePaymentMethodExpr folds both into one UPPER_SNAKE vocabulary — provider first, newest ledger entry as the fallback — and the list filter, the export field and the export filter all use that same expression, so the screen and the file can never disagree. The paymentMethods param is deliberately not validated against a fixed list: the manual pay endpoint takes a free-form method, so an IsIn would silently drop real values.
77 lines
2.8 KiB
TypeScript
77 lines
2.8 KiB
TypeScript
/**
|
|
* 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;
|