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-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index ba6fece8c..4ebebe879 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -279,9 +279,10 @@ export class BookingPricingService { return { lineItems, - // Grand total is billed in whole currency units — fractional line sums - // (rate × tons can yield e.g. 260519.2) round to the nearest whole birr/USD. - totalAmount: Math.round(total), + // Grand total keeps its cents, matching the line items it sums — rounding + // to whole birr made the total disagree with the breakdown (135,375.61 of + // lines shown as a 135,376.00 total) and CBE bills this figure to the cent. + totalAmount: round2(total), currency: booking.paymentCurrency, usedRates: [...usedRatesMap.values()], appliedModifiers: ruleResult.appliedModifiers, 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}`; } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 6ceb1fb69..ded69afb1 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -64,6 +64,7 @@ import { } from "@/pages/bookings/booking-display"; import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction"; import { formatRateUnit } from "./new-contract-form/unit-rates"; +import { formatAmount } from "./new-shipment-form/total"; import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction"; import { getContractBookingAction } from "./contract-booking-action"; import { closedWindowMessage, hasOpenWindow } from "./booking-window"; @@ -719,7 +720,7 @@ export default function ContractDetailPage() { )} - {(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "} + {formatAmount(item.unitPrice)} {pricing.currency}{" "} / {formatRateUnit(item.unit)} @@ -1336,9 +1337,7 @@ export default function ContractDetailPage() { whiteSpace: "nowrap", }} > - {amount > 0 - ? `ETB ${amount.toLocaleString()}` - : "—"} + {amount > 0 ? `ETB ${formatAmount(amount)}` : "—"} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index a5b29a299..347cdd2bb 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -75,7 +75,7 @@ import { createShipmentFormSchema, initialShipmentFormValues, } from "./new-shipment-form/schema"; -import { computeShipmentTotal } from "./new-shipment-form/total"; +import { computeShipmentTotal, formatAmount } from "./new-shipment-form/total"; import { downloadContainerImportTemplate, parseContainerExcel, @@ -1014,7 +1014,7 @@ function PriceConfirmModal({ ))} {overweightSurchargeAmount > 0 - ? `An overweight surcharge of ${overweightSurchargeAmount.toLocaleString()} ${ + ? `An overweight surcharge of ${formatAmount(overweightSurchargeAmount)} ${ validation?.currency ?? total?.currency ?? "" } applies (included in the total below). You can still submit, or go back and adjust weights.` : "An overweight surcharge applies. You can still submit, or go back and adjust weights."} @@ -1038,7 +1038,7 @@ function PriceConfirmModal({ {line.quantity.toLocaleString()} ×{" "} - {line.unitPrice.toLocaleString()} {total.currency} ·{" "} + {formatAmount(line.unitPrice)} {total.currency} ·{" "} {formatRateUnit(line.unit)} @@ -1048,7 +1048,7 @@ function PriceConfirmModal({ c="#10202F" style={{ whiteSpace: "nowrap" }} > - {line.amount.toLocaleString()} {total.currency} + {formatAmount(line.amount)} {total.currency} ))} @@ -1070,7 +1070,7 @@ function PriceConfirmModal({ Total - {total.total.toLocaleString()}{" "} + {formatAmount(total.total)}{" "} {total.currency} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts index cbe9ae083..7c80ca1d6 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts @@ -15,6 +15,19 @@ export interface ShipmentTotal { total: number; } +/** + * Money always prints its cents. Bare toLocaleString() defaults to + * maximumFractionDigits: 0, which rounded the total away from the line items it + * sums (118,171.21 shown as 118,171) — and the customer is billed the exact + * amount, so the shown figure must match to the cent. + */ +export function formatAmount(amount: number | string | null | undefined) { + return Number(amount ?? 0).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + /** * Quantity a bulk rate bills, in ITS OWN unit. PER_ITEM cargo carries both * figures — the item count prices the booking, the tonnage sizes the wagons —