Merge pull request #1195 from Tria-plc/freight_feature/usermanagement

keep cents in CBE bills and prices
This commit is contained in:
marshal
2026-08-08 23:46:17 +03:00
committed by GitHub
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),

View File

@@ -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 (
<SectionCard icon={Banknote} title="Pricing & payment">
@@ -74,7 +77,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
{li.description}
</Text>
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
{Number(li.amount).toLocaleString()} {li.currency}
{Number(li.amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{" "}
{li.currency}
</Text>
</Group>
))}

View File

@@ -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

View File

@@ -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
</Text>
<Text size="sm" td="line-through">
{priceChangeModal.previousTotalAmount.toLocaleString()}{" "}
{formatAmount(priceChangeModal.previousTotalAmount)}{" "}
{priceChangeModal.currency}
</Text>
</Group>
@@ -447,7 +448,7 @@ export function DraftBookingView({
<Group justify="space-between">
<Text fw={700}>New total</Text>
<Text fw={800} c="edr-green">
{priceChangeModal.totalAmount.toLocaleString()}{" "}
{formatAmount(priceChangeModal.totalAmount)}{" "}
{priceChangeModal.currency}
</Text>
</Group>
@@ -459,7 +460,7 @@ export function DraftBookingView({
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
{formatAmount(item.amount)} {item.currency}
</Text>
</Group>
))}

View File

@@ -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 = () => <Box my={16} h={1} w="100%" bg="#EEF2F6" />;
@@ -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);

View File

@@ -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 = (

View File

@@ -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<string, { bg: string; fg: string }> = {
DRAFT: { bg: "#EEF2F6", fg: "#64748B" },

View File

@@ -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}`;
}