mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1195 from Tria-plc/freight_feature/usermanagement
keep cents in CBE bills and prices
This commit is contained in:
@@ -677,11 +677,11 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
describe("BillingService — CBE bill amounts carry cents, never rounded", () => {
|
||||||
// CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down
|
// CBE settles to the cent (/cbe/payment gates on amountsMatchToTheCent), so the
|
||||||
// settles 0.40 short while markInvoiceAsPaid still writes paidAmount =
|
// bill must quote the exact balance. Rounding UP overcharged the payer by up to
|
||||||
// totalAmount — money missing from the bank with the books saying paid.
|
// a birr; rounding DOWN underpaid while markInvoiceAsPaid still wrote paidAmount
|
||||||
// payInvoice and billQuery must agree, or /cbe/payment sees a mismatch.
|
// = totalAmount. payInvoice and billQuery must agree, or /cbe/payment mismatches.
|
||||||
const invoice = {
|
const invoice = {
|
||||||
id: "inv-1",
|
id: "inv-1",
|
||||||
status: Freight.InvoiceStatus.Pending,
|
status: Freight.InvoiceStatus.Pending,
|
||||||
@@ -690,9 +690,9 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
|||||||
type: "PREPAID",
|
type: "PREPAID",
|
||||||
invoiceNumber: "INV-20260101-00001",
|
invoiceNumber: "INV-20260101-00001",
|
||||||
currency: "ETB",
|
currency: "ETB",
|
||||||
// .40 — the case Math.round gets wrong (rounds down, underpays).
|
// .43 — cents that must survive all the way to the bill.
|
||||||
balanceAmount: 12345.4,
|
balanceAmount: 12345.43,
|
||||||
totalAmount: 12345.4,
|
totalAmount: 12345.43,
|
||||||
company: { name: "Acme PLC" },
|
company: { name: "Acme PLC" },
|
||||||
paymentId: null,
|
paymentId: null,
|
||||||
dueAt: null,
|
dueAt: null,
|
||||||
@@ -716,7 +716,7 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
|||||||
return { service, repo };
|
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({
|
const initiate = jest.fn().mockResolvedValue({
|
||||||
intentId: "intent-1",
|
intentId: "intent-1",
|
||||||
immediateSuccess: false,
|
immediateSuccess: false,
|
||||||
@@ -727,16 +727,16 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
|||||||
await service.payInvoice("inv-1", { method: "CBE_BILL" });
|
await service.payInvoice("inv-1", { method: "CBE_BILL" });
|
||||||
|
|
||||||
expect(initiate).toHaveBeenCalledWith(
|
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();
|
const { service } = build();
|
||||||
|
|
||||||
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
|
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
|
||||||
stillPayable: true,
|
stillPayable: true,
|
||||||
currentAmountMinor: 12346,
|
currentAmountMinor: 12345.43,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1341,11 +1341,11 @@ export class BillingService {
|
|||||||
// service branches on a domain-specific reference type.
|
// service branches on a domain-specific reference type.
|
||||||
referenceType: PaymentReferenceType.SHIPMENT,
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
|
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
|
||||||
// Whole birr, always UP. CBE bills this amount verbatim, so it must never
|
// Exact balance, cents included. CBE bills this verbatim and /cbe/payment
|
||||||
// land below the outstanding balance — Math.round would let a .40 balance
|
// matches the debited amount to the cent (amountsMatchToTheCent), so any
|
||||||
// settle 0.40 short. Ceil overcharges by <1 birr instead, and the same
|
// rounding here would overcharge the payer and leave the invoice balance
|
||||||
// ceil in billQuery keeps the quoted and debited amounts identical.
|
// non-zero. billQuery quotes the same unrounded value.
|
||||||
amountMinor: Math.ceil(Number(invoice.balanceAmount)),
|
amountMinor: round2(Number(invoice.balanceAmount)),
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||||
method: opts.method ?? "TELEBIRR",
|
method: opts.method ?? "TELEBIRR",
|
||||||
@@ -1490,9 +1490,10 @@ export class BillingService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
// Ceil, matching payInvoice — the amount CBE quotes at the counter has to
|
// Unrounded, matching payInvoice — the amount CBE quotes at the counter has
|
||||||
// be the amount the intent was opened for, or /cbe/payment sees a mismatch.
|
// to be the amount the intent was opened for, to the cent, or /cbe/payment
|
||||||
const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount));
|
// sees a mismatch.
|
||||||
|
const balance = round2(Number(open.balanceAmount ?? open.totalAmount));
|
||||||
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
||||||
return {
|
return {
|
||||||
stillPayable: balance > 0 && !expired,
|
stillPayable: balance > 0 && !expired,
|
||||||
@@ -1527,7 +1528,7 @@ export class BillingService {
|
|||||||
return {
|
return {
|
||||||
stillPayable: false,
|
stillPayable: false,
|
||||||
payerName: latest.company?.name ?? null,
|
payerName: latest.company?.name ?? null,
|
||||||
currentAmountMinor: Math.ceil(Number(latest.totalAmount)),
|
currentAmountMinor: round2(Number(latest.totalAmount)),
|
||||||
currency: latest.currency,
|
currency: latest.currency,
|
||||||
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
||||||
reason: closedInvoiceReason(latest.status),
|
reason: closedInvoiceReason(latest.status),
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
|||||||
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
|
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
|
||||||
|
|
||||||
const fmt = (n: number) =>
|
const fmt = (n: number) =>
|
||||||
`${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
|
`${booking.paymentCurrency} ${n.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard icon={Banknote} title="Pricing & payment">
|
<SectionCard icon={Banknote} title="Pricing & payment">
|
||||||
@@ -74,7 +77,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
|||||||
{li.description}
|
{li.description}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
|
<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>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { SectionCard } from "./SectionCard";
|
|||||||
import { MetricTile } from "./MetricTile";
|
import { MetricTile } from "./MetricTile";
|
||||||
|
|
||||||
const money = (amount: number, currency: string) =>
|
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
|
* 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 { StepGhostButton, StepLine } from "./components/Steps";
|
||||||
import { SupportCard } from "./components/SupportCard";
|
import { SupportCard } from "./components/SupportCard";
|
||||||
import { BodyGrid } from "./components/layout";
|
import { BodyGrid } from "./components/layout";
|
||||||
|
import { formatAmount } from "./utils";
|
||||||
|
|
||||||
export function DraftBookingView({
|
export function DraftBookingView({
|
||||||
booking,
|
booking,
|
||||||
@@ -439,7 +440,7 @@ export function DraftBookingView({
|
|||||||
Previous total
|
Previous total
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" td="line-through">
|
<Text size="sm" td="line-through">
|
||||||
{priceChangeModal.previousTotalAmount.toLocaleString()}{" "}
|
{formatAmount(priceChangeModal.previousTotalAmount)}{" "}
|
||||||
{priceChangeModal.currency}
|
{priceChangeModal.currency}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -447,7 +448,7 @@ export function DraftBookingView({
|
|||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={700}>New total</Text>
|
<Text fw={700}>New total</Text>
|
||||||
<Text fw={800} c="edr-green">
|
<Text fw={800} c="edr-green">
|
||||||
{priceChangeModal.totalAmount.toLocaleString()}{" "}
|
{formatAmount(priceChangeModal.totalAmount)}{" "}
|
||||||
{priceChangeModal.currency}
|
{priceChangeModal.currency}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -459,7 +460,7 @@ export function DraftBookingView({
|
|||||||
{item.description}
|
{item.description}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{item.amount.toLocaleString()} {item.currency}
|
{formatAmount(item.amount)} {item.currency}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ import { paymentStatusLabel } from "@/pages/bookings/booking-display";
|
|||||||
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
|
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
|
||||||
import { saveBlob } from "@/utils/download";
|
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";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
const Divider = () => <Box my={16} h={1} w="100%" bg="#EEF2F6" />;
|
const Divider = () => <Box my={16} h={1} w="100%" bg="#EEF2F6" />;
|
||||||
@@ -197,7 +203,7 @@ export function BookingPaymentPanel({
|
|||||||
booking.adjustedTotalAmount !== undefined;
|
booking.adjustedTotalAmount !== undefined;
|
||||||
const currency = pricing?.currency ?? booking.paymentCurrency;
|
const currency = pricing?.currency ?? booking.paymentCurrency;
|
||||||
const total = isAdjusted
|
const total = isAdjusted
|
||||||
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
|
? `${formatAmount(booking.adjustedTotalAmount)} ${currency}`
|
||||||
: priceTotal(pricing);
|
: priceTotal(pricing);
|
||||||
const items = priceLineItems(pricing);
|
const items = priceLineItems(pricing);
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { OperationDatePicker } from "@/pages/bookings/clearance";
|
|||||||
import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment";
|
import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||||
|
|
||||||
import type { BookingDetail } from "../booking-detail-types";
|
import type { BookingDetail } from "../booking-detail-types";
|
||||||
import { fmtDate } from "../utils";
|
import { fmtDate, formatAmount } from "../utils";
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
import { PaymentMethodModal } from "./PaymentMethodModal";
|
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ function StatusPill({ status }: { status: WagonCancellation["status"] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fmtMoney = (amount: number | string, currency: string) =>
|
const fmtMoney = (amount: number | string, currency: string) =>
|
||||||
`${Number(amount).toLocaleString()} ${currency}`;
|
`${formatAmount(amount)} ${currency}`;
|
||||||
|
|
||||||
const apiErrorMessage = (error: unknown, fallback: string) => {
|
const apiErrorMessage = (error: unknown, fallback: string) => {
|
||||||
const data = (
|
const data = (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type PortalWarehouseInvoice,
|
type PortalWarehouseInvoice,
|
||||||
} from "@/services/warehouse-invoices.service";
|
} from "@/services/warehouse-invoices.service";
|
||||||
import { saveBlob } from "@/utils/download";
|
import { saveBlob } from "@/utils/download";
|
||||||
|
import { formatAmount } from "../utils";
|
||||||
|
|
||||||
import { PaymentMethodModal } from "./PaymentMethodModal";
|
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
@@ -20,7 +21,7 @@ const isPayable = (inv: PortalWarehouseInvoice) =>
|
|||||||
PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0;
|
PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0;
|
||||||
|
|
||||||
const money = (amount: number | string | null | undefined, currency: string) =>
|
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 }> = {
|
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
|
||||||
DRAFT: { bg: "#EEF2F6", fg: "#64748B" },
|
DRAFT: { bg: "#EEF2F6", fg: "#64748B" },
|
||||||
|
|||||||
@@ -151,15 +151,27 @@ export function bookingSubtitle(b: BookingDetail) {
|
|||||||
|
|
||||||
export type Pricing = Freight.PricingBreakdown | null | undefined;
|
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) {
|
export function priceLineItems(pricing: Pricing) {
|
||||||
return (pricing?.lineItems ?? []).map((li) => ({
|
return (pricing?.lineItems ?? []).map((li) => ({
|
||||||
label: li.description,
|
label: li.description,
|
||||||
value: `${li.amount.toLocaleString()} ${li.currency}`,
|
value: `${formatAmount(li.amount)} ${li.currency}`,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function priceTotal(pricing: Pricing) {
|
export function priceTotal(pricing: Pricing) {
|
||||||
if (!pricing) return "—";
|
if (!pricing) return "—";
|
||||||
const total = pricing.lineItems.reduce((s, li) => s + li.amount, 0);
|
const total = pricing.lineItems.reduce((s, li) => s + li.amount, 0);
|
||||||
return `${total.toLocaleString()} ${pricing.currency}`;
|
return `${formatAmount(total)} ${pricing.currency}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user