mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1159 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -279,7 +279,9 @@ export class BookingPricingService {
|
||||
|
||||
return {
|
||||
lineItems,
|
||||
totalAmount: total,
|
||||
// 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),
|
||||
currency: booking.paymentCurrency,
|
||||
usedRates: [...usedRatesMap.values()],
|
||||
appliedModifiers: ruleResult.appliedModifiers,
|
||||
|
||||
@@ -161,10 +161,10 @@ export default function ClearanceDocumentsPage() {
|
||||
<User className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
<p className="font-medium text-foreground">
|
||||
{customer}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileText className="size-3 shrink-0 opacity-70" />
|
||||
{b.reference}
|
||||
</p>
|
||||
@@ -182,7 +182,7 @@ export default function ClearanceDocumentsPage() {
|
||||
<ContractReferenceLink
|
||||
contractId={b.contractId}
|
||||
contractReference={b.contractReference}
|
||||
className="block truncate text-sm text-foreground underline underline-offset-2 hover:text-muted-foreground"
|
||||
className="block text-sm text-foreground underline underline-offset-2 hover:text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm">—</Text>
|
||||
@@ -409,7 +409,7 @@ export default function ClearanceDocumentsPage() {
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -393,10 +393,10 @@ function ShipmentBookingsTable({
|
||||
<PackageCheck className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
<p className="font-medium text-foreground">
|
||||
{row.original.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{row.original.customerLabel}
|
||||
</p>
|
||||
@@ -413,7 +413,7 @@ function ShipmentBookingsTable({
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500} truncate maw={150}>
|
||||
<Text size="sm" fw={500}>
|
||||
{r.contractReference ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -431,13 +431,9 @@ function ShipmentBookingsTable({
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" className="truncate">
|
||||
{row.original.originLabel}
|
||||
</Text>
|
||||
<Text size="sm">{row.original.originLabel}</Text>
|
||||
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" className="truncate">
|
||||
{row.original.destinationLabel}
|
||||
</Text>
|
||||
<Text size="sm">{row.original.destinationLabel}</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
@@ -610,7 +606,7 @@ function ShipmentBookingsTable({
|
||||
data={rows}
|
||||
status={loading ? "loading" : error ? "error" : "success"}
|
||||
onRowClick={(row) => onOpen(row.id)}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -226,11 +226,11 @@ function RouteCell({
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
<Text size="sm" fw={500}>
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
<Text size="sm" fw={500}>
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -385,10 +385,10 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
<PackageCheck className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
<p className="font-medium text-foreground">
|
||||
{r.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{r.customerLabel}
|
||||
</p>
|
||||
@@ -403,9 +403,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" truncate maw={140}>
|
||||
{row.original.contractReference}
|
||||
</Text>
|
||||
<Text size="sm">{row.original.contractReference}</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
@@ -710,7 +708,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { amountsMatchToTheCent } from "./cbe-bill.service";
|
||||
|
||||
describe("amountsMatchToTheCent (CBE payment amount gate)", () => {
|
||||
it("accepts the exact amount", () => {
|
||||
expect(amountsMatchToTheCent(1234.34, 1234.34)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a cents-only difference (the 1234.89 vs 1234.34 bug)", () => {
|
||||
expect(amountsMatchToTheCent(1234.89, 1234.34)).toBe(false);
|
||||
expect(amountsMatchToTheCent(1234.35, 1234.34)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects whole-unit differences", () => {
|
||||
expect(amountsMatchToTheCent(1235.34, 1234.34)).toBe(false);
|
||||
});
|
||||
|
||||
it("absorbs double-precision storage noise", () => {
|
||||
expect(amountsMatchToTheCent(1234.34, 1234.3399999999999)).toBe(true);
|
||||
// classic float artifact: 0.1 + 0.2 !== 0.3
|
||||
expect(amountsMatchToTheCent(0.1 + 0.2, 0.3)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -38,8 +38,15 @@ import {
|
||||
/** Postgres unique_violation — the DB-level idempotency backstop firing on a concurrent duplicate. */
|
||||
const PG_UNIQUE_VIOLATION = "23505";
|
||||
|
||||
/** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */
|
||||
const AMOUNT_TOLERANCE = 0.01;
|
||||
/**
|
||||
* CBE must pay the bill to the exact cent — compare in integer cents so
|
||||
* double-precision storage noise (1234.34 stored as 1234.33999…) can neither
|
||||
* mask nor fabricate a difference. A relative tolerance is wrong here: 1% of a
|
||||
* 1234.34 bill would wave through anything up to ±12.34.
|
||||
*/
|
||||
export function amountsMatchToTheCent(a: number, b: number): boolean {
|
||||
return Math.round(a * 100) === Math.round(b * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate an intent's own terminal state into the same reason vocabulary the domain apps
|
||||
@@ -265,11 +272,15 @@ export class CbeBillService {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate against the freshly-quoted amount — the same figure bill-query
|
||||
// just showed the payer — not the intent's amount asserted at creation,
|
||||
// which can go stale when the domain re-prices the invoice. Fallback to
|
||||
// the intent amount only for domain builds that return no current amount.
|
||||
const expectedAmount = billQuery.currentAmountMinor ?? intent.amountMinor;
|
||||
const amount = Number(dto.Amount);
|
||||
if (
|
||||
!Number.isFinite(amount) ||
|
||||
Math.abs(amount - intent.amountMinor) >
|
||||
intent.amountMinor * AMOUNT_TOLERANCE
|
||||
!amountsMatchToTheCent(amount, expectedAmount)
|
||||
) {
|
||||
throw new CbeBillError("Amount mismatch", "BUSINESS");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#
|
||||
# Build: DOCKER_BUILDKIT=1 docker compose build
|
||||
# Run: docker compose up -d
|
||||
|
||||
services:
|
||||
freight-api:
|
||||
build:
|
||||
|
||||
Reference in New Issue
Block a user