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 {
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,

View File

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

View File

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

View File

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

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. */
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");
}

View File

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