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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-07 12:05:46 +03:00
committed by GitHub
7 changed files with 57 additions and 27 deletions

View File

@@ -279,7 +279,9 @@ export class BookingPricingService {
return { return {
lineItems, 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, currency: booking.paymentCurrency,
usedRates: [...usedRatesMap.values()], usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers, appliedModifiers: ruleResult.appliedModifiers,

View File

@@ -161,10 +161,10 @@ export default function ClearanceDocumentsPage() {
<User className="size-4" strokeWidth={1.75} /> <User className="size-4" strokeWidth={1.75} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium text-foreground"> <p className="font-medium text-foreground">
{customer} {customer}
</p> </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" /> <FileText className="size-3 shrink-0 opacity-70" />
{b.reference} {b.reference}
</p> </p>
@@ -182,7 +182,7 @@ export default function ClearanceDocumentsPage() {
<ContractReferenceLink <ContractReferenceLink
contractId={b.contractId} contractId={b.contractId}
contractReference={b.contractReference} 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> <Text size="sm"></Text>
@@ -409,7 +409,7 @@ export default function ClearanceDocumentsPage() {
manualPagination: true, manualPagination: true,
pageCount, 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} footer={DataTableFooter}
/> />
</Box> </Box>

View File

@@ -393,10 +393,10 @@ function ShipmentBookingsTable({
<PackageCheck className="size-4" strokeWidth={1.75} /> <PackageCheck className="size-4" strokeWidth={1.75} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium text-foreground"> <p className="font-medium text-foreground">
{row.original.reference} {row.original.reference}
</p> </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" /> <User className="size-3 shrink-0 opacity-70" />
{row.original.customerLabel} {row.original.customerLabel}
</p> </p>
@@ -413,7 +413,7 @@ function ShipmentBookingsTable({
<Stack gap={4} py={2}> <Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<FileText size={13} className="shrink-0 text-muted-foreground" /> <FileText size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500} truncate maw={150}> <Text size="sm" fw={500}>
{r.contractReference ?? "—"} {r.contractReference ?? "—"}
</Text> </Text>
</Group> </Group>
@@ -431,13 +431,9 @@ function ShipmentBookingsTable({
header: () => <span className={bookingTable.headerCell}>Route</span>, header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<Text size="sm" className="truncate"> <Text size="sm">{row.original.originLabel}</Text>
{row.original.originLabel}
</Text>
<ArrowRight size={13} className="shrink-0 text-muted-foreground" /> <ArrowRight size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" className="truncate"> <Text size="sm">{row.original.destinationLabel}</Text>
{row.original.destinationLabel}
</Text>
</Group> </Group>
), ),
}, },
@@ -610,7 +606,7 @@ function ShipmentBookingsTable({
data={rows} data={rows}
status={loading ? "loading" : error ? "error" : "success"} status={loading ? "loading" : error ? "error" : "success"}
onRowClick={(row) => onOpen(row.id)} 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> </Box>
); );

View File

@@ -226,11 +226,11 @@ function RouteCell({
return ( return (
<Stack gap={4} py={2}> <Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<Text size="sm" fw={500} truncate maw={120}> <Text size="sm" fw={500}>
{origin} {origin}
</Text> </Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" /> <ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500} truncate maw={120}> <Text size="sm" fw={500}>
{destination} {destination}
</Text> </Text>
</Group> </Group>
@@ -385,10 +385,10 @@ export default function GlDjiboutiClearanceListPage() {
<PackageCheck className="size-4" strokeWidth={1.75} /> <PackageCheck className="size-4" strokeWidth={1.75} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium text-foreground"> <p className="font-medium text-foreground">
{r.reference} {r.reference}
</p> </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" /> <User className="size-3 shrink-0 opacity-70" />
{r.customerLabel} {r.customerLabel}
</p> </p>
@@ -403,9 +403,7 @@ export default function GlDjiboutiClearanceListPage() {
cell: ({ row }) => ( cell: ({ row }) => (
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<FileText size={13} className="shrink-0 text-muted-foreground" /> <FileText size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" truncate maw={140}> <Text size="sm">{row.original.contractReference}</Text>
{row.original.contractReference}
</Text>
</Group> </Group>
), ),
}, },
@@ -710,7 +708,7 @@ export default function GlDjiboutiClearanceListPage() {
manualPagination: true, manualPagination: true,
pageCount, 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} footer={DataTableFooter}
/> />
</Box> </Box>

View File

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

View File

@@ -38,8 +38,15 @@ import {
/** Postgres unique_violation — the DB-level idempotency backstop firing on a concurrent duplicate. */ /** Postgres unique_violation — the DB-level idempotency backstop firing on a concurrent duplicate. */
const PG_UNIQUE_VIOLATION = "23505"; 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 * 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); const amount = Number(dto.Amount);
if ( if (
!Number.isFinite(amount) || !Number.isFinite(amount) ||
Math.abs(amount - intent.amountMinor) > !amountsMatchToTheCent(amount, expectedAmount)
intent.amountMinor * AMOUNT_TOLERANCE
) { ) {
throw new CbeBillError("Amount mismatch", "BUSINESS"); throw new CbeBillError("Amount mismatch", "BUSINESS");
} }

View File

@@ -4,6 +4,7 @@
# #
# Build: DOCKER_BUILDKIT=1 docker compose build # Build: DOCKER_BUILDKIT=1 docker compose build
# Run: docker compose up -d # Run: docker compose up -d
services: services:
freight-api: freight-api:
build: build: