mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
</Box>
|
||||
<Text fz={14} fw={700} style={{ color: GREEN }}>
|
||||
{(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "}
|
||||
{formatAmount(item.unitPrice)} {pricing.currency}{" "}
|
||||
<Text span fz={12} fw={600} c="dimmed">
|
||||
/ {formatRateUnit(item.unit)}
|
||||
</Text>
|
||||
@@ -1336,9 +1337,7 @@ export default function ContractDetailPage() {
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{amount > 0
|
||||
? `ETB ${amount.toLocaleString()}`
|
||||
: "—"}
|
||||
{amount > 0 ? `ETB ${formatAmount(amount)}` : "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -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({
|
||||
))}
|
||||
<Text fz="xs" c="#9A5B00" mt={2}>
|
||||
{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({
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{line.quantity.toLocaleString()} ×{" "}
|
||||
{line.unitPrice.toLocaleString()} {total.currency} ·{" "}
|
||||
{formatAmount(line.unitPrice)} {total.currency} ·{" "}
|
||||
{formatRateUnit(line.unit)}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -1048,7 +1048,7 @@ function PriceConfirmModal({
|
||||
c="#10202F"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{line.amount.toLocaleString()} {total.currency}
|
||||
{formatAmount(line.amount)} {total.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
@@ -1070,7 +1070,7 @@ function PriceConfirmModal({
|
||||
Total
|
||||
</Text>
|
||||
<Text fw={800} fz={28} c="#10202F">
|
||||
{total.total.toLocaleString()}{" "}
|
||||
{formatAmount(total.total)}{" "}
|
||||
<Text span fz={16} fw={700} c="edr-muted">
|
||||
{total.currency}
|
||||
</Text>
|
||||
|
||||
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user