From 80a14c176be1ed37d9838ed46b08472c17330e8b Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 8 Aug 2026 20:45:05 +0000 Subject: [PATCH] keep cents in CBE bills and prices --- .../modules/billing/billing.service.spec.ts | 24 +++++++++---------- .../src/modules/billing/billing.service.ts | 19 ++++++++------- .../bookings/BookingPricingSummary.tsx | 11 +++++++-- .../bookings/detail/BookingTrucksPanel.tsx | 5 +++- .../BookingDetailPage/DraftBookingView.tsx | 7 +++--- .../components/BookingPaymentPanel.tsx | 10 ++++++-- .../components/WagonCancellationCard.tsx | 4 ++-- .../components/WarehousePaymentsSection.tsx | 3 ++- .../pages/bookings/BookingDetailPage/utils.ts | 16 +++++++++++-- 9 files changed, 65 insertions(+), 34 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 9cc5e9315..576c7b166 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -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", - // .40 — the case Math.round gets wrong (rounds down, underpays). - balanceAmount: 12345.4, - totalAmount: 12345.4, + // .43 — cents 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, }); }); }); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index ef4b30f8c..9a31466f0 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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), diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx index 6c5dd9b07..814fb9a95 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx @@ -19,7 +19,10 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { const lineItems = booking.pricingBreakdown?.lineItems ?? []; const fmt = (n: number) => - `${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`; + `${booking.paymentCurrency} ${n.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; return ( @@ -74,7 +77,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { {li.description} - {Number(li.amount).toLocaleString()} {li.currency} + {Number(li.amount).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{" "} + {li.currency} ))} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx index 4427367cb..65c9719ed 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx @@ -11,7 +11,10 @@ import { SectionCard } from "./SectionCard"; import { MetricTile } from "./MetricTile"; const money = (amount: number, currency: string) => - `${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; + `${Number(amount).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} ${currency === "ETB" ? "Birr (ETB)" : currency}`; /** * Cargo costs (booking-level totals) plus the same truck-import block the diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx index 65af9f45d..cc9084d85 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -45,6 +45,7 @@ import { StatusHero } from "./components/StatusHero"; import { StepGhostButton, StepLine } from "./components/Steps"; import { SupportCard } from "./components/SupportCard"; import { BodyGrid } from "./components/layout"; +import { formatAmount } from "./utils"; export function DraftBookingView({ booking, @@ -439,7 +440,7 @@ export function DraftBookingView({ Previous total - {priceChangeModal.previousTotalAmount.toLocaleString()}{" "} + {formatAmount(priceChangeModal.previousTotalAmount)}{" "} {priceChangeModal.currency} @@ -447,7 +448,7 @@ export function DraftBookingView({ New total - {priceChangeModal.totalAmount.toLocaleString()}{" "} + {formatAmount(priceChangeModal.totalAmount)}{" "} {priceChangeModal.currency} @@ -459,7 +460,7 @@ export function DraftBookingView({ {item.description} - {item.amount.toLocaleString()} {item.currency} + {formatAmount(item.amount)} {item.currency} ))} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx index af1bb18fb..b94ea9fed 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -20,7 +20,13 @@ import { paymentStatusLabel } from "@/pages/bookings/booking-display"; import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment"; import { saveBlob } from "@/utils/download"; -import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils"; +import { + fmtDate, + formatAmount, + priceLineItems, + priceTotal, + type Pricing, +} from "../utils"; import { CardTitle, SectionCard } from "./layout"; const Divider = () => ; @@ -197,7 +203,7 @@ export function BookingPaymentPanel({ booking.adjustedTotalAmount !== undefined; const currency = pricing?.currency ?? booking.paymentCurrency; const total = isAdjusted - ? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}` + ? `${formatAmount(booking.adjustedTotalAmount)} ${currency}` : priceTotal(pricing); const items = priceLineItems(pricing); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx index 00b3195a2..05e7ed8d8 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx @@ -29,7 +29,7 @@ import { OperationDatePicker } from "@/pages/bookings/clearance"; import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment"; import type { BookingDetail } from "../booking-detail-types"; -import { fmtDate } from "../utils"; +import { fmtDate, formatAmount } from "../utils"; import { CardTitle, SectionCard } from "./layout"; import { PaymentMethodModal } from "./PaymentMethodModal"; @@ -62,7 +62,7 @@ function StatusPill({ status }: { status: WagonCancellation["status"] }) { } const fmtMoney = (amount: number | string, currency: string) => - `${Number(amount).toLocaleString()} ${currency}`; + `${formatAmount(amount)} ${currency}`; const apiErrorMessage = (error: unknown, fallback: string) => { const data = ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx index d3bbc55e9..a28fc2077 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -10,6 +10,7 @@ import { type PortalWarehouseInvoice, } from "@/services/warehouse-invoices.service"; import { saveBlob } from "@/utils/download"; +import { formatAmount } from "../utils"; import { PaymentMethodModal } from "./PaymentMethodModal"; import { CardTitle, SectionCard } from "./layout"; @@ -20,7 +21,7 @@ const isPayable = (inv: PortalWarehouseInvoice) => PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0; const money = (amount: number | string | null | undefined, currency: string) => - `${Number(amount ?? 0).toLocaleString()} ${currency}`; + `${formatAmount(amount)} ${currency}`; const STATUS_STYLE: Record = { DRAFT: { bg: "#EEF2F6", fg: "#64748B" }, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts index 22b347e85..5f9a71702 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts @@ -151,15 +151,27 @@ export function bookingSubtitle(b: BookingDetail) { export type Pricing = Freight.PricingBreakdown | null | undefined; +/** + * Money always prints its cents. Bare toLocaleString() defaults to + * maximumFractionDigits: 0, which silently hid the cents the customer is + * actually charged — CBE bills the exact amount, so the shown figure must match. + */ +export function formatAmount(amount: number | string | null | undefined) { + return Number(amount ?? 0).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + export function priceLineItems(pricing: Pricing) { return (pricing?.lineItems ?? []).map((li) => ({ label: li.description, - value: `${li.amount.toLocaleString()} ${li.currency}`, + value: `${formatAmount(li.amount)} ${li.currency}`, })); } export function priceTotal(pricing: Pricing) { if (!pricing) return "—"; const total = pricing.lineItems.reduce((s, li) => s + li.amount, 0); - return `${total.toLocaleString()} ${pricing.currency}`; + return `${formatAmount(total)} ${pricing.currency}`; }