mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +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 {
|
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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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. */
|
/** 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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user