From 49c453b82a44f5be77b9ab70ccc206b9c7f3fdb8 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 5 Aug 2026 07:26:41 +0000 Subject: [PATCH 1/3] always-on calendar, currency cards --- .../bookings/booking-pricing.service.spec.ts | 27 +++++++++++++++++++ .../bookings/booking-pricing.service.ts | 25 ++++++++--------- .../contracts/contract-pricing.service.ts | 3 ++- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 4996f45a9..75a179b5c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -87,6 +87,33 @@ describe('BookingPricingService — domestic corridor', () => { expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE)); }); + it('keeps the exchange rate decimals — ETB amounts round to cents, not whole birr', async () => { + exchangeService.getRate.mockResolvedValue(162.2132); + const booking = { + id: 'b-1-frac', + freightType: 'BULK', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'ETB', + cargoTotalWeightVgm: 120, + originYardId: MOJO, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { containers: [] }, + ) => Promise<{ lineItems: Array<{ amount: number }> }>; + } + ).computeBaseRailLinesWithRates(booking, { containers: [] }); + + // 35 × 120 × 162.2132 = 681,295.44 — the .44 must survive (whole-birr + // rounding here billed with the integer part of the rate, in effect). + expect(result.lineItems[0].amount).toBe(681295.44); + }); + it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => { const booking = { id: 'b-1-usd', diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 9bbe6bed5..522bac47e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -6,6 +6,7 @@ import { RatesService } from '../rule-engine/services/rates.service'; import { Rate } from '../rule-engine/entities/rate.entity'; import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; +import { round2 } from '../billing/invoice-settlement.util'; import { ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, @@ -205,14 +206,14 @@ export class BookingPricingService { const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking - ? Math.round(unitUsd * usdToEtb) + ? round2(unitUsd * usdToEtb) : unitUsd; const convertedAmount = frozen ? isEtbBooking - ? Math.round(unitAmount * quantity) + ? round2(unitAmount * quantity) : unitAmount * quantity : isEtbBooking - ? Math.round(usdAmount * usdToEtb) + ? round2(usdAmount * usdToEtb) : usdAmount; const item: PriceLineItemDto = { @@ -583,8 +584,8 @@ export class BookingPricingService { } else { const unitUsd = Number(rate!.rateValue); const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons); - amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; - unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd; } if (rate) usedRatesMap.set(rate.id, rate); lines.push({ @@ -652,8 +653,8 @@ export class BookingPricingService { ); } else { const usdAmount = this.amountForRate(fallback, quantity, wagonCount); - amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; - unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd; } lines.push({ code: rateType, @@ -763,12 +764,12 @@ export class BookingPricingService { if (frozen) { unitAmount = Number(frozen.unitPrice); amount = isEtbBooking - ? Math.round(unitAmount * quantity) + ? round2(unitAmount * quantity) : unitAmount * quantity; } else { const usdAmount = value * quantity; - amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; - unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value; + amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? round2(value * usdToEtb) : value; } // Skip legs that resolve to nothing (zero rate, or zero km / count / tons). if (!(amount > 0)) continue; @@ -971,7 +972,7 @@ export class BookingPricingService { if (!(usdToEtb > 0)) return null; const converted = snap.currency === 'USD' && bookingCurrency === 'ETB' - ? Math.round(unitPrice * usdToEtb) + ? round2(unitPrice * usdToEtb) : snap.currency === 'ETB' && bookingCurrency === 'USD' ? unitPrice / usdToEtb : null; @@ -1027,7 +1028,7 @@ export class BookingPricingService { const currency = booking.paymentCurrency; const isEtb = currency === 'ETB'; const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd); + const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); const onLeg = liveRates.filter( (r) => diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index d54b0b544..3f395c369 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -2,6 +2,7 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { RatesService } from '../rule-engine/services/rates.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { round2 } from '../billing/invoice-settlement.util'; import { ExchangeService } from '@edr/api-common'; import { ContractsRepository } from './contracts.repository'; import { Contract } from './entities/contract.entity'; @@ -79,7 +80,7 @@ export class ContractPricingService { const currency = contract.paymentCurrency; const isEtb = currency === 'ETB'; const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd); + const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); const lineItems: ContractUnitRateLineItem[] = []; const baseType = this.baseRateType(contract); From ceabe163e79b8feb9fd73af70d4d57b161d10e2d Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 5 Aug 2026 09:46:39 +0000 Subject: [PATCH 2/3] fix issue --- apps/edr-freight-web/backoffice/src/App.tsx | 16 + .../backoffice/src/pages/SettingsPage.tsx | 3 - .../BookingDetailPage/ReadonlyBookingView.tsx | 17 +- .../BookingDetailPage/components/CargoTab.tsx | 465 ++++++++++++++++++ .../src/pages/bookings/NewBookingPage.tsx | 7 +- .../src/pages/contracts/NewShipmentPage.tsx | 18 +- 6 files changed, 507 insertions(+), 19 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CargoTab.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index d4892d198..4c1b346e9 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -115,6 +115,7 @@ import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2De import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage"; +import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard"; import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; @@ -600,6 +601,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/configuration/trade-access", permission: FREIGHT_PERMS.admin, }, + { + label: "Exchange rate", + href: "/dashboard/configuration/exchange-rate", + permission: FREIGHT_PERMS.admin, + }, ], }, { @@ -1574,6 +1580,16 @@ const App = () => { } /> + +
+ +
+ + } + /> {/* - - diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index ac211b5be..147262472 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,5 +1,12 @@ import { Group, Tabs } from "@mantine/core"; -import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react"; +import { + Clock, + CreditCard, + FileText, + LayoutGrid, + Package, + Truck, +} from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useFileViewer } from "@/hooks/useFileViewer"; @@ -9,6 +16,7 @@ import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; import { ActivityCard } from "./components/ActivityCard"; import { ClearanceCard } from "./components/ClearanceCard"; import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner"; +import { CargoTab } from "./components/CargoTab"; import { DocumentsTab } from "./components/DocumentsTab"; import { CompanyInfoCard } from "./components/CompanyInfoCard"; import { ContainersCard } from "./components/ContainersCard"; @@ -199,6 +207,9 @@ export function ReadonlyBookingView({ }> Overview + }> + Cargo + }> Logistics @@ -254,6 +265,10 @@ export function ReadonlyBookingView({ + + + +
+ {icon} + {label} + + ); +} + +function StatTile({ + icon, + label, + value, + sub, +}: { + icon: ReactNode; + label: string; + value: string; + sub?: string; +}) { + return ( + + + {icon} + + {label} + + + + {value} + + {sub && ( + + {sub} + + )} + + ); +} + +function DetailRow({ label, value }: { label: string; value: ReactNode }) { + return ( + + + {label} + + + {value} + + + ); +} + +const th = { color: "#9AA8B5", fontSize: 11 } as const; + +// ─── Containers ─────────────────────────────────────────────────────────────── + +function lineTypeLabel(line: BookingContainerLineDetail): string { + const t = line.containerType; + if (t?.label) return t.label; + if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""} container`; + return t?.code ?? "Container"; +} + +function UnitRow({ unit }: { unit: BookingContainerUnitDetail }) { + return ( + + + + {unit.containerNumber} + + + + + {unit.sealNumber || "—"} + + + + + {Number(unit.vgmTons || 0) ? fmtWeight(Number(unit.vgmTons)) : "—"} + + + + + {unit.isHazardous && ( + } label="Hazardous" /> + )} + {unit.isReefer && ( + } label="Reefer" /> + )} + {unit.isReturn && } label="Return" />} + {!unit.isHazardous && !unit.isReefer && !unit.isReturn && ( + + — + + )} + + + + + {unit.grnNumber || "—"} + + + + {unit.receivedToPort ? ( + + + Received + + {unit.receivedAt && ( + + {fmtDate(unit.receivedAt)} + + )} + + ) : ( + + Pending + + )} + + + ); +} + +function ContainerLineCard({ line, index }: { line: BookingContainerLineDetail; index: number }) { + const units = line.units ?? []; + return ( + + + + + + + + + {lineTypeLabel(line)} + + + Line {index + 1} · {line.quantity} unit{line.quantity !== 1 ? "s" : ""} + + + + + {line.isOverweight && ( + } + label={ + Number(line.overweightExcessTons || 0) + ? `Overweight +${Number(line.overweightExcessTons)} t` + : "Overweight" + } + /> + )} + {!!line.hazardousQuantity && ( + } label={`${line.hazardousQuantity} hazardous`} /> + )} + {!!line.reeferQuantity && ( + } label={`${line.reeferQuantity} reefer`} /> + )} + {!!line.returnQuantity && ( + } label={`${line.returnQuantity} return`} /> + )} + + + + + } + label="Quantity" + value={`${line.quantity}`} + sub={`container${line.quantity !== 1 ? "s" : ""}`} + /> + } + label="VGM / unit" + value={fmtWeight(Number(line.vgmPerUnitTons || 0))} + /> + } + label="Line total VGM" + value={fmtWeight(Number(line.totalVgmTons || 0))} + /> + + + {units.length > 0 && ( + + + + + Container no. + Seal no. + VGM + Flags + GRN + Port status + + + + {units.map((u) => ( + + ))} + +
+
+ )} + {units.length === 0 && ( + + Container numbers will appear here once the physical units are assigned. + + )} +
+ ); +} + +// ─── Bulk ───────────────────────────────────────────────────────────────────── + +function BulkCargoCard({ booking }: { booking: BookingDetail }) { + const unit = booking.cargoType?.unitOfMeasure; + const isPerItem = unit === "PER_ITEM"; + // Break-bulk (PER_ITEM): cargoTotalWeightVgm holds the ITEM COUNT and the + // real tonnage lives in bulkTotalWeightTons; PER_TON stores tons directly. + const quantity = Number(booking.cargoTotalWeightVgm || 0); + const tons = totalVgmTons(booking); + + return ( + + + + + + + + {commodityLabel(booking)} + + + Bulk cargo{booking.cargoType?.code ? ` · ${booking.cargoType.code}` : ""} + + + + + + {isPerItem && ( + } + label="Items" + value={quantity ? quantity.toLocaleString() : "—"} + sub="declared item count" + /> + )} + } + label="Total weight" + value={fmtWeight(tons)} + sub={isPerItem ? "actual tonnage" : "declared tonnage"} + /> + } + label="Billing unit" + value={isPerItem ? "Per item" : "Per ton"} + /> + + + + + {booking.cargoType?.code && ( + + )} + {booking.cargoFreeText && booking.cargoType?.cargoTypeName && ( + + )} + + + + + + + ); +} + +// ─── Tab ────────────────────────────────────────────────────────────────────── + +/** + * Dedicated cargo breakdown tab: bulk bookings get the full bulk declaration + * (commodity, unit of measure, item count vs tonnage, hazardous/reefer + * quantities); container bookings get one card per container line with its + * per-unit numbers, seals, VGM, GRN and port-arrival status. + */ +export function CargoTab({ booking }: { booking: BookingDetail }) { + const isBulk = booking.freightType === "BULK"; + const lines = booking.bookingContainers ?? []; + const totalUnits = lines.reduce((s, c) => s + Number(c.quantity || 0), 0); + const totalVgm = totalVgmTons(booking); + const receivedCount = lines + .flatMap((l) => l.units ?? []) + .filter((u) => u.receivedToPort).length; + + return ( +
+ + Cargo summary + + : } + label="Freight type" + value={isBulk ? "Bulk" : "Container"} + /> + } + label="Commodity" + value={commodityLabel(booking)} + /> + } + label="Total weight" + value={fmtWeight(totalVgm)} + /> + {isBulk ? ( + } + label="Special handling" + value={ + [ + booking.isHazardous && "Hazardous", + booking.isRefrigerated && "Reefer", + ] + .filter(Boolean) + .join(" · ") || "None" + } + /> + ) : ( + } + label="Containers" + value={`${totalUnits}`} + sub={`${receivedCount} received at port`} + /> + )} + + + + {isBulk ? ( + + ) : lines.length > 0 ? ( + lines.map((line, i) => ( + + )) + ) : ( + + + No container details recorded for this booking yet. + + + )} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 5a8705610..6b80ebd32 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -292,7 +292,10 @@ export default function NewBookingPage() { // physically carry the selected cargo/container type. Quantity is NOT part // of this gate — an oversized booking is accepted and gets a partial split // offer later. Only selectable days reach the UI; no capacity counts. - const gateContainerTypeIds = useMemo(() => { + // Computed per render on purpose — NOT useMemo. react-hook-form mutates the + // watched containers array in place on nested edits (containers.0.containerType), + // so a reference-based dep list never sees per-line changes. + const gateContainerTypeIds = (() => { if (watchedCargoKind !== "container") return []; const groups = referenceData?.containers ?? []; const ids = new Set(); @@ -304,7 +307,7 @@ export default function NewBookingPage() { } } return [...ids]; - }, [watchedCargoKind, watchedContainers, referenceData]); + })(); const gateCargoTypeId = watchedCargoKind === "bulk" ? watchedCargoTypePath?.[1] : undefined; const gateReady = diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 4d423abe8..54f678049 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -1006,9 +1006,11 @@ function ScheduleStep({ const isContainer = contract.freightType === "CONTAINER"; const containerLines = form.watch("containers"); const cargoWeightTons = form.watch("cargoWeightTons"); - const itemCount = form.watch("itemCount"); - const cargoQuery = useMemo(() => { + // Computed per render on purpose — NOT useMemo. react-hook-form mutates the + // watched containers array in place on nested edits (containers.0.quantity), + // so a reference-based dep list never sees manual quantity changes. + const cargoQuery = ((): Freight.AvailableDaysForCargoQuery | null => { if (!route?.originYardId || !route?.destinationYardId) return null; if (isContainer) { const containers = (containerLines ?? []) @@ -1036,17 +1038,7 @@ function ScheduleStep({ ?.cargoTypeCode ?? undefined, totalWeightTons: tons, }; - // Tonnage is the sizing input the day-feasibility endpoint takes, and - // PER_ITEM cargo now captures it too — itemCount stays in the deps so the - // query still refreshes when only the item count changes. - }, [ - route, - isContainer, - containerLines, - cargoWeightTons, - itemCount, - contract.pricingBreakdown, - ]); + })(); const isIntercity = contract.tradeDirection === "DOMESTIC"; const { data: availableDays, isLoading } = useQuery({ From b7dcc1bf0aed33cf7e0a9b9b5d006f68c2e1f4fb Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 5 Aug 2026 12:59:37 +0000 Subject: [PATCH 3/3] feat(billing): add PAYMENT_PROCESSING invoice status on payment success redirect (all except CBE bill) --- ...00000000-InvoicePaymentProcessingStatus.ts | 22 ++++++++ .../src/modules/billing/billing.service.ts | 56 +++++++++++++++++++ .../src/modules/payment/payment.controller.ts | 10 ++++ .../src/modules/payment/payment.service.ts | 39 ++++++++++++- .../src/components/customers/badges.tsx | 1 + .../components/warehouses/FeePreviewModal.tsx | 1 + .../src/pages/invoices/InvoicesPage.tsx | 1 + .../src/pages/operations/FirstMilePage.tsx | 1 + .../src/pages/operations/LastMilePage.tsx | 1 + .../src/pages/reports/reportConfigs.ts | 1 + .../warehouses/WarehouseInvoicesPage.tsx | 1 + .../backoffice/src/types/warehouse.ts | 1 + .../portal/src/constants/URLS.ts | 2 + .../portal/src/pages/billing/invoice-ui.tsx | 1 + .../BookingClearanceWorkflowBanner.tsx | 1 + .../src/pages/bookings/booking-display.tsx | 2 + .../src/pages/payments/PaymentSuccessPage.tsx | 14 ++++- .../portal/src/services/payments.service.ts | 5 ++ .../src/modules/cbe-bill/cbe-bill.service.ts | 1 + packages/types/src/freight/index.ts | 2 + 20 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3260000000000-InvoicePaymentProcessingStatus.ts diff --git a/apps/edr-freight-api/src/migrations/3260000000000-InvoicePaymentProcessingStatus.ts b/apps/edr-freight-api/src/migrations/3260000000000-InvoicePaymentProcessingStatus.ts new file mode 100644 index 000000000..76fee7d22 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3260000000000-InvoicePaymentProcessingStatus.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Adds PAYMENT_PROCESSING to the invoice status enum: the customer completed + * provider checkout (success redirect) and settlement is awaiting the + * provider webhook. + */ +export class InvoicePaymentProcessingStatus3260000000000 + implements MigrationInterface +{ + name = "InvoicePaymentProcessingStatus3260000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PAYMENT_PROCESSING' AFTER 'PENDING'`, + ); + } + + public async down(): Promise { + // Postgres cannot drop an enum value; PAYMENT_PROCESSING stays. Harmless. + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index bd2355df5..60f173596 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -52,6 +52,8 @@ const DEFAULT_DUE_DAYS = 14; const OPEN_STATUSES: Freight.InvoiceStatus[] = [ Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending, + // Success-redirect ack; still unsettled, so it must stay payable/settleable. + Freight.InvoiceStatus.PaymentProcessing, Freight.InvoiceStatus.PartiallyPaid, Freight.InvoiceStatus.Overdue, ]; @@ -924,6 +926,26 @@ export class BillingService { }); if (!invoice) return null; + // Reconcile-before-expire, caller-proof: an invoice with a payment intent may + // have settled at the gateway without the webhook landing yet. `paid` — leave + // it open, the (re-emitted) payment.succeeded settles it. `unverifiable` — + // never expire on unknown; the caller's next sweep retries. Invoices with no + // intent (`paymentId` null) were never payable at a gateway and expire directly. + if (invoice.paymentId) { + const { paid, unverifiable } = await this.reconcilePayable( + invoice.sourceId, + ); + if (paid || unverifiable) { + this.logger.warn( + `expirePayable skipped for invoice ${invoice.invoiceNumber} (${invoice.id}) — ` + + (paid + ? "gateway reconcile found a settled payment" + : "settlement unverifiable at the gateway"), + ); + return null; + } + } + return this.transition( invoice.id, Freight.InvoiceStatus.Expired, @@ -1028,6 +1050,40 @@ export class BillingService { ); } + /** + * Success-redirect ack (see PaymentService.acknowledgeSuccessRedirect): move + * the invoice linked to a gateway intent to PAYMENT_PROCESSING. Only from + * ISSUED/PENDING — never overwrites a settlement (PAID/PARTIALLY_PAID) and + * is idempotent. Balance untouched: this is a display state, not a + * settlement; settleByPaymentId still performs the real transition. + */ + async markInvoicePaymentProcessing(paymentId: string): Promise { + await this.dataSource.getRepository(Invoice).update( + { + paymentId, + status: In([ + Freight.InvoiceStatus.Issued, + Freight.InvoiceStatus.Pending, + ]), + }, + { status: Freight.InvoiceStatus.PaymentProcessing }, + ); + } + + /** + * Counterpart of {@link markInvoicePaymentProcessing} for a failed intent: + * PAYMENT_PROCESSING → PENDING so the invoice reads payable again for a + * retry. No-op from any other status. + */ + async revertInvoicePaymentProcessing(paymentId: string): Promise { + await this.dataSource + .getRepository(Invoice) + .update( + { paymentId, status: Freight.InvoiceStatus.PaymentProcessing }, + { status: Freight.InvoiceStatus.Pending }, + ); + } + // ── Payment initiation & settlement (the gateway boundary) ─────────────────── /** diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 3602657de..b1dafbc85 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -4,6 +4,7 @@ import { HttpStatus, Param, ParseUUIDPipe, + Post, Query, Res, } from "@nestjs/common"; @@ -84,6 +85,15 @@ export class PaymentController { return this.paymentService.getIntentByBookingId(bookingId); } + @Post("redirect-success/:bookingId") + @ApiOperation({ + summary: + "Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", + }) + acknowledgeSuccessRedirect(@Param("bookingId") bookingId: string) { + return this.paymentService.acknowledgeSuccessRedirect(bookingId); + } + @Get("receipt/:orderId") @Public() @ApiOperation({ summary: "Generate a payment receipt HTML page" }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 99c4aca46..afe6009ef 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -253,8 +253,10 @@ export class PaymentService { payerAccount: input.payerAccount, payerName: input.payerName, expiresAt: input.expiresAt, + // bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING). returnUrl: - input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", + input.returnUrl ?? + `https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`, failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", }); @@ -492,6 +494,37 @@ export class PaymentService { return { alreadyFinalized: false }; } + /** + * Success-redirect ack from the portal: the customer finished provider + * checkout, settlement webhook not (necessarily) in yet. Optimistic + * intermediate only — the webhook stays the source of truth. Never + * downgrades: only action-required → processing, and the invoice moves to + * PAYMENT_PROCESSING only from an open unpaid status. CBE_BILL is excluded + * (bank-counter flow, it has no redirect). + */ + async acknowledgeSuccessRedirect( + referenceId: string, + ): Promise<{ acknowledged: boolean }> { + const intent = await this.paymentRepo.findOneBy({ refId: referenceId }); + if (!intent || intent.method === "cbe-bill") { + return { acknowledged: false }; + } + + if (intent.status === "action-required") { + await this.paymentRepo.update( + { id: intent.id, status: "action-required" }, + { status: "processing" }, + ); + } + // Even if the intent already advanced (e.g. webhook raced the redirect to + // "processing"), the invoice ack is idempotent and status-guarded. + if (intent.status === "action-required" || intent.status === "processing") { + await this.billing.markInvoicePaymentProcessing(intent.id); + return { acknowledged: true }; + } + return { acknowledged: false }; + } + async markPaymentFailed(input: { intentId: string; failureCode?: string; @@ -510,7 +543,9 @@ export class PaymentService { }, ); - // Invoice stays open for retry — nothing to settle. Logged only. + // Invoice stays open for retry — nothing to settle. A redirect-acked + // PAYMENT_PROCESSING invoice is put back to PENDING so it reads payable. + await this.billing.revertInvoicePaymentProcessing(intent.id); this.logger.warn( `Payment ${intent.id} failed for ${intent.refId}` + (input.failureMessage ? `: ${input.failureMessage}` : ""), diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 112026ca6..046493b0f 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -249,6 +249,7 @@ const INVOICE_STATUS_COLOR: Record = { DRAFT: "gray", ISSUED: "cyan", PENDING: "yellow", + PAYMENT_PROCESSING: "indigo", PARTIALLY_PAID: "orange", PAID: "edr-green", OVERDUE: "red", diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index d3c8e1897..cb2cbc658 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -14,6 +14,7 @@ import { openPdfBlob } from './pdf'; const INVOICE_STATUS_COLOR: Record = { DRAFT: 'gray', ISSUED: 'orange', + PAYMENT_PROCESSING: 'indigo', PARTIALLY_PAID: 'yellow', PAID: 'edr-green', CANCELLED: 'gray', diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 7eca77ddf..fd839de16 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -183,6 +183,7 @@ export default function InvoicesPage() { data={[ { label: "All", value: "all" }, { label: "Pending", value: "PENDING" }, + { label: "Payment processing", value: "PAYMENT_PROCESSING" }, { label: "Paid", value: "PAID" }, { label: "Overdue", value: "OVERDUE" }, ]} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 835d1a82f..192a748c3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -79,6 +79,7 @@ const INVOICE_STATUS_META: Record = { PAID: { label: "Paid", color: "green" }, PARTIALLY_PAID: { label: "Partially Paid", color: "teal" }, PENDING: { label: "Pending", color: "yellow" }, + PAYMENT_PROCESSING: { label: "Payment Processing", color: "indigo" }, UNPAID: { label: "Unpaid", color: "yellow" }, OPEN: { label: "Open", color: "yellow" }, ISSUED: { label: "Issued", color: "blue" }, diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 33e9bbed8..f402f1792 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -86,6 +86,7 @@ const INVOICE_STATUS_META: Record = { PAID: { label: "Paid", color: "green" }, PARTIALLY_PAID: { label: "Partially Paid", color: "teal" }, PENDING: { label: "Pending", color: "yellow" }, + PAYMENT_PROCESSING: { label: "Payment Processing", color: "indigo" }, UNPAID: { label: "Unpaid", color: "yellow" }, OPEN: { label: "Open", color: "yellow" }, ISSUED: { label: "Issued", color: "blue" }, diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts index 9aec4fec0..ced916fbb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts +++ b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts @@ -75,6 +75,7 @@ const CONTRACT_STATUSES = [ const INVOICE_STATUSES = [ "ISSUED", "PENDING", + "PAYMENT_PROCESSING", "PARTIALLY_PAID", "PAID", "OVERDUE", diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 92f807363..c8e3d111d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -42,6 +42,7 @@ import { extractErrorMessage } from '@/components/warehouses/options'; const STATUS_COLOR: Record = { DRAFT: 'gray', ISSUED: 'orange', + PAYMENT_PROCESSING: 'indigo', PARTIALLY_PAID: 'yellow', PAID: 'edr-green', CANCELLED: 'gray', diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 8ff87c251..6b11d4dcd 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -907,6 +907,7 @@ export interface AllocationCriteria { export const WAREHOUSE_INVOICE_STATUSES = [ 'DRAFT', 'ISSUED', + 'PAYMENT_PROCESSING', 'PARTIALLY_PAID', 'PAID', 'CANCELLED', diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 22421400f..f050818ed 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -194,6 +194,8 @@ export const URL_CONSTANTS = { PAYMENTS: { INITIATE: "/api/payments/initiate", INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`, + REDIRECT_SUCCESS: (bookingId: string) => + `/api/payments/redirect-success/${bookingId}`, CHECKOUT: "/api/payments/checkout", }, diff --git a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx index dac9cb18c..39cdc5333 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx @@ -21,6 +21,7 @@ const STATUS_STYLE: Record< [Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "#EEF2F6", fg: "#64748B" }, [Freight.InvoiceStatus.Issued]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" }, [Freight.InvoiceStatus.Pending]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" }, + [Freight.InvoiceStatus.PaymentProcessing]: { label: "Payment processing", bg: "#EAF1FB", fg: "#2563EB" }, [Freight.InvoiceStatus.PartiallyPaid]: { label: "Partially paid", bg: "#FEF9E7", fg: "#A16207" }, [Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "#E6F7EF", fg: "#0A6F4D" }, [Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" }, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx index 6e8546dd8..ad188a206 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx @@ -41,6 +41,7 @@ const INVOICE_STATUS_LABELS: Record = { DRAFT: "Draft", ISSUED: "Issued", PENDING: "Due", + PAYMENT_PROCESSING: "Payment processing", PARTIALLY_PAID: "Partially paid", PAID: "Paid", OVERDUE: "Overdue", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx index 7dc1109eb..134eae3b5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx @@ -120,6 +120,7 @@ const PAYMENT_COLORS: Record = { // Backend emits the long form on some flows; keep the short alias too. VERIFICATION_IN_PROGRESS: "yellow", PAYMENT_VERIFICATION_IN_PROGRESS: "yellow", + PAYMENT_PROCESSING: "yellow", OVERDUE: "red", REFUNDED: "blue", CANCELLED: "gray", @@ -132,6 +133,7 @@ const PAYMENT_LABELS: Record = { PNR_GENERATED: "PNR generated", VERIFICATION_IN_PROGRESS: "Verifying", PAYMENT_VERIFICATION_IN_PROGRESS: "Verifying", + PAYMENT_PROCESSING: "Payment processing", OVERDUE: "Overdue", REFUNDED: "Refunded", CANCELLED: "Cancelled", diff --git a/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx index 93a040191..d0bb9c348 100644 --- a/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx @@ -8,10 +8,22 @@ import { ThemeIcon, } from "@mantine/core"; import { CheckCircle2, FileText, Home } from "lucide-react"; -import { useNavigate } from "react-router-dom"; +import { useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { paymentsService } from "@/services/payments.service"; export default function PaymentSuccessPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const bookingId = searchParams.get("bookingId"); + + // Fire-and-forget ack: payment → processing, invoice → PAYMENT_PROCESSING. + // The provider webhook remains the source of truth for the final PAID state. + useEffect(() => { + if (bookingId) { + paymentsService.acknowledgeSuccessRedirect(bookingId).catch(() => {}); + } + }, [bookingId]); return ( => { + await client.post(P.REDIRECT_SUCCESS(bookingId)); + }, + checkoutUrl: buildCheckoutUrl, checkoutUrlForInvoice: buildCheckoutUrlForInvoice, }; diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts index 5b1a6cb28..9f60ab910 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts @@ -107,6 +107,7 @@ export class CbeBillService { consented_on: Math.floor(Date.now() / 1000), }; } + /* ------------------------------------------------------------------ query */ diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 37997ea18..d99dc07f8 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -161,6 +161,8 @@ export enum InvoiceStatus { /** Issued and awaiting payment (alias of PENDING for fee invoices). */ Issued = "ISSUED", Pending = "PENDING", + /** Customer completed provider checkout (success redirect); awaiting webhook confirmation. */ + PaymentProcessing = "PAYMENT_PROCESSING", /** Some, but not all, of the balance has been settled. */ PartiallyPaid = "PARTIALLY_PAID", Paid = "PAID",