keep cents in CBE bills and prices

This commit is contained in:
Marshal
2026-08-08 20:45:05 +00:00
parent aad9ebaf78
commit 80a14c176b
9 changed files with 65 additions and 34 deletions

View File

@@ -677,11 +677,11 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
});
});
describe("BillingService — CBE bill amounts round UP to whole birr", () => {
// CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down
// settles 0.40 short while markInvoiceAsPaid still writes paidAmount =
// totalAmount — money missing from the bank with the books saying paid.
// payInvoice and billQuery must agree, or /cbe/payment sees a mismatch.
describe("BillingService — CBE bill amounts carry cents, never rounded", () => {
// CBE settles to the cent (/cbe/payment gates on amountsMatchToTheCent), so the
// bill must quote the exact balance. Rounding UP overcharged the payer by up to
// a birr; rounding DOWN underpaid while markInvoiceAsPaid still wrote paidAmount
// = totalAmount. payInvoice and billQuery must agree, or /cbe/payment mismatches.
const invoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
@@ -690,9 +690,9 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
type: "PREPAID",
invoiceNumber: "INV-20260101-00001",
currency: "ETB",
// .40the case Math.round gets wrong (rounds down, underpays).
balanceAmount: 12345.4,
totalAmount: 12345.4,
// .43cents that must survive all the way to the bill.
balanceAmount: 12345.43,
totalAmount: 12345.43,
company: { name: "Acme PLC" },
paymentId: null,
dueAt: null,
@@ -716,7 +716,7 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
return { service, repo };
};
it("opens the intent for the ceiled balance, never below it", async () => {
it("opens the intent for the exact balance, cents included", async () => {
const initiate = jest.fn().mockResolvedValue({
intentId: "intent-1",
immediateSuccess: false,
@@ -727,16 +727,16 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
await service.payInvoice("inv-1", { method: "CBE_BILL" });
expect(initiate).toHaveBeenCalledWith(
expect.objectContaining({ amountMinor: 12346 }),
expect.objectContaining({ amountMinor: 12345.43 }),
);
});
it("quotes the same ceiled amount on bill-query as payInvoice opened", async () => {
it("quotes the same exact amount on bill-query as payInvoice opened", async () => {
const { service } = build();
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
stillPayable: true,
currentAmountMinor: 12346,
currentAmountMinor: 12345.43,
});
});
});

View File

@@ -1341,11 +1341,11 @@ export class BillingService {
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
// Whole birr, always UP. CBE bills this amount verbatim, so it must never
// land below the outstanding balance — Math.round would let a .40 balance
// settle 0.40 short. Ceil overcharges by <1 birr instead, and the same
// ceil in billQuery keeps the quoted and debited amounts identical.
amountMinor: Math.ceil(Number(invoice.balanceAmount)),
// Exact balance, cents included. CBE bills this verbatim and /cbe/payment
// matches the debited amount to the cent (amountsMatchToTheCent), so any
// rounding here would overcharge the payer and leave the invoice balance
// non-zero. billQuery quotes the same unrounded value.
amountMinor: round2(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",
@@ -1490,9 +1490,10 @@ export class BillingService {
});
if (open) {
// Ceil, matching payInvoice — the amount CBE quotes at the counter has to
// be the amount the intent was opened for, or /cbe/payment sees a mismatch.
const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount));
// Unrounded, matching payInvoice — the amount CBE quotes at the counter has
// to be the amount the intent was opened for, to the cent, or /cbe/payment
// sees a mismatch.
const balance = round2(Number(open.balanceAmount ?? open.totalAmount));
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
return {
stillPayable: balance > 0 && !expired,
@@ -1527,7 +1528,7 @@ export class BillingService {
return {
stillPayable: false,
payerName: latest.company?.name ?? null,
currentAmountMinor: Math.ceil(Number(latest.totalAmount)),
currentAmountMinor: round2(Number(latest.totalAmount)),
currency: latest.currency,
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
reason: closedInvoiceReason(latest.status),