diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 2fb9b3032..e2a3d9b0c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -34,6 +34,7 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { paymentDrainEndsAtIso } from '../train-scheduling/booking-batch.constants'; import { BookingContractService } from './booking-contract.service'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; @@ -57,6 +58,24 @@ import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto' import { PdfRenderService } from '../billing/documents/pdf-render.service'; import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; +/** + * The allocated train as the backoffice booking detail page needs it: which + * train, its window phase, and both the planned and actual clock. Attached by + * `findById` only when the booking is on a schedule. + */ +export interface TrainScheduleSummary { + id: string; + reference: string | null; + trainNumber: string | null; + status: string | null; + scheduledDepartureDate: string | null; + scheduledArrivalDate: string | null; + actualDepartureAt: string | null; + actualArrivalAt: string | null; + windowPhase: string | null; + paymentPhaseEndsAt: string | null; +} + /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { items: Booking[]; @@ -1738,6 +1757,20 @@ export class BookingsService { (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = pending.has(b.id); } + this.attachPaymentDrainEnds(bookings); + } + + /** + * Derived, no query: end of the settlement drain tail after `paymentDeadline`. + * The portal hides "Pay now" between the deadline and this instant — a payment + * started just before the buzzer is still settling, so offering to pay again + * would invite a double payment. + */ + private attachPaymentDrainEnds(bookings: Booking[]): void { + for (const b of bookings) { + (b as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = + paymentDrainEndsAtIso(b.paymentDeadline); + } } async findAll( @@ -2105,8 +2138,33 @@ export class BookingsService { .findOne({ where: { id: booking.trainScheduleId } }); (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus = schedule?.status ?? null; + // Backoffice staff view: the allocated train's identity and clock, so the + // detail page can state which train the booking rides and when it runs + // without a second round-trip to the schedules API. + ( + booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null } + ).trainScheduleSummary = schedule + ? { + id: schedule.id, + reference: schedule.reference ?? null, + trainNumber: schedule.trainNumber ?? null, + status: schedule.status ?? null, + scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, + scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null, + actualDepartureAt: schedule.actualDepartureAt?.toISOString() ?? null, + actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null, + windowPhase: schedule.windowPhase ?? null, + paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + } + : null; } + // End of this booking's own pay window including the settlement drain tail — + // the deadline staff should quote, since a payment landing inside the drain + // still counts (see paymentDrainEndsAtIso). + (booking as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = + paymentDrainEndsAtIso(booking.paymentDeadline); + // A generated-but-unsigned handover means the customer must approve delivery // from the portal. Self-haul: booking-based, one per booking. EDR last-mile: // per delivering truck (generated on truck exit), signed one by one. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 23024bfdf..cb568171a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -3680,23 +3680,24 @@ export class BookingBatchService implements OnModuleInit { // (provider query errored / payment still in flight) means we could not // confirm "not paid" — never expire on unknown; the next settle tick // asks again. - if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { - const reconcile = await this.billing.reconcilePayable(booking.id); - if (reconcile.paid) { - this.logger.log( - `[BATCH] expire skipped for ${booking.reference} — gateway ` + - `reconcile found a settled payment; payment.succeeded will allocate it`, - ); - return; - } - if (reconcile.unverifiable) { - this.logger.warn( - `[BATCH] expire deferred for ${booking.reference} — settlement ` + - `unverifiable at the gateway; retrying next settle tick`, - ); - return; - } - } + // TODO: CBE has no reconcile endpoint yet — re-enable once available. + // if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { + // const reconcile = await this.billing.reconcilePayable(booking.id); + // if (reconcile.paid) { + // this.logger.log( + // `[BATCH] expire skipped for ${booking.reference} — gateway ` + + // `reconcile found a settled payment; payment.succeeded will allocate it`, + // ); + // return; + // } + // if (reconcile.unverifiable) { + // this.logger.warn( + // `[BATCH] expire deferred for ${booking.reference} — settlement ` + + // `unverifiable at the gateway; retrying next settle tick`, + // ); + // return; + // } + // } } const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx new file mode 100644 index 000000000..84f0645a8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx @@ -0,0 +1,215 @@ +import { useEffect, useState } from "react"; +import { Badge, Box, Group, Stack, Text } from "@mantine/core"; +import { CalendarClock } from "lucide-react"; + +import type { BookingDetail } from "@/types/booking"; +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; + +import { SectionCard } from "./SectionCard"; + +export interface BookingSchedulingWindowCardProps { + booking: BookingDetail; +} + +/** Full date + time — staff read these against the operating clock, so no time is dropped. */ +function formatStamp(iso: string | null | undefined): string | null { + if (!iso) return null; + const ms = new Date(iso).getTime(); + if (!Number.isFinite(ms)) return null; + return new Date(ms).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** "in 2h 14m" / "12m ago" — the at-a-glance read next to an absolute stamp. */ +function formatRelative(iso: string, nowMs: number): string { + const diff = new Date(iso).getTime() - nowMs; + const past = diff < 0; + const totalMinutes = Math.floor(Math.abs(diff) / 60_000); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const minutes = totalMinutes % 60; + + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + // Keep minutes when they're the only unit, so sub-hour gaps never read "0". + if (minutes || parts.length === 0) parts.push(`${minutes}m`); + + const span = parts.slice(0, 2).join(" "); + return past ? `${span} ago` : `in ${span}`; +} + +function Row({ + label, + value, + hint, + tone, +}: { + label: string; + value: string; + hint?: string | null; + tone?: "muted" | "warning" | "danger"; +}) { + const valueColor = + tone === "danger" ? "red.7" : tone === "warning" ? "orange.7" : "dark"; + return ( + + + {label} + + + + {value} + + {hint ? ( + + {hint} + + ) : null} + + + ); +} + +/** + * Backoffice-only staff view of the scheduling clock: which batch/train the + * booking is scheduled for, when its pay window closes, and the train's + * planned vs actual departure/arrival (i.e. when the run actually ended). + */ +export function BookingSchedulingWindowCard({ + booking, +}: BookingSchedulingWindowCardProps) { + const schedule = booking.trainScheduleSummary ?? null; + + // The pay-window end staff should quote is the drain end (a payment landing + // inside the drain still counts); fall back to the raw deadline if the API + // predates that field. + const payWindowEndsAt = booking.paymentDrainEndsAt ?? booking.paymentDeadline ?? null; + + // One shared ticking clock so every relative label in the card stays in sync. + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const interval = setInterval(() => setNowMs(Date.now()), 30_000); + return () => clearInterval(interval); + }, []); + + const hasAnything = + Boolean(schedule) || Boolean(payWindowEndsAt) || Boolean(booking.holdExpiresAt); + if (!hasAnything) return null; + + const payWindowClosed = payWindowEndsAt + ? new Date(payWindowEndsAt).getTime() <= nowMs + : false; + + const trainLabel = + schedule?.trainNumber ?? + schedule?.reference ?? + (schedule ? "Assigned train" : null); + + return ( + } + > + + {trainLabel ? ( + + ) : ( + + )} + + {schedule?.status ? ( + + + Train status + + + {schedule.windowPhase ? ( + + {schedule.windowPhase.replace(/_/g, " ")} + + ) : null} + + {schedule.status} + + + + ) : null} + + {payWindowEndsAt ? ( + + ) : null} + + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( + + ) : null} + + {schedule ? ( + <> + + + + ) : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index ecbb0488e..b0b024977 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard"; export * from "./BookingCargoCard"; export * from "./BookingContractSummaryCard"; export * from "./BookingCompanyCard"; +export * from "./BookingSchedulingWindowCard"; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index feee70b9d..4367fc708 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -40,6 +40,7 @@ import { BookingCompanyCard, BookingContractSummaryCard, BookingContainerUnitsCard, + BookingSchedulingWindowCard, BookingDocumentsPanel, BookingTrucksPanel, ContractOrdersPanel, @@ -246,6 +247,7 @@ export default function BookingRequestDetailPage() { + = { icon: Wallet, iconColor: "edr-amber-text", tile: "edr-amber-soft", - hint: "Selected for batch · payment due within 1 hour", + hint: "Selected for batch · payment due before the deadline", step: "edr-accent", badgeLabel: "Pay Now", badgeBg: "edr-amber-soft", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx index b94ea9fed..710c6b40f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -18,6 +18,8 @@ import { invoicesService, type PortalInvoice } from "@/services/invoices.service import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui"; import { paymentStatusLabel } from "@/pages/bookings/booking-display"; import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment"; +import { payWindowState } from "@/pages/bookings/payments/payment-drain"; +import { PaymentProcessingNotice } from "@/pages/bookings/payments/PaymentProcessingNotice"; import { saveBlob } from "@/utils/download"; import { @@ -216,6 +218,11 @@ export function BookingPaymentPanel({ (booking.status === "SELECTED_FOR_BATCH" || Boolean(booking.paymentDeadline)); + // Pay deadline passed but the settlement drain tail hasn't: in-flight payments + // are still landing, so the pay action gives way to a processing countdown. + const payWindow = payWindowState(booking); + const draining = payWindow.phase === "draining" && Boolean(payWindow.drainEndsAt); + const { data: invoices = [] } = useQuery({ queryKey: ["booking-invoices", booking.id], queryFn: () => invoicesService.listForSource("booking", booking.id), @@ -266,9 +273,11 @@ export function BookingPaymentPanel({ {paid ? : showCountdown ? : null} {paid ? "Paid" - : showCountdown - ? "Pay window open" - : paymentStatusLabel(booking.paymentStatus ?? "PENDING")} + : draining + ? "Payment processing" + : showCountdown + ? "Pay window open" + : paymentStatusLabel(booking.paymentStatus ?? "PENDING")} @@ -279,7 +288,11 @@ export function BookingPaymentPanel({ {/* USD: no online payment — bank transfer + slip to Finance, who confirm the payment (backoffice flow lands in a later phase). Shown for any unpaid USD booking, with or without an open pay window. */} - {!paid && offlineUsd && ( + {!paid && draining && payWindow.drainEndsAt && ( + + )} + + {!paid && !draining && offlineUsd && ( )} - {showCountdown && booking.paymentDeadline && ( + {showCountdown && !draining && booking.paymentDeadline && ( ; } // Contract ready for the customer's signature → full-page contract viewer. diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx index db50bd57f..63ca637b5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx @@ -7,6 +7,8 @@ import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal"; import { priceTotal } from "../BookingDetailPage/utils"; import { isUsdOfflineBooking } from "./offline-payment"; +import { payWindowState } from "./payment-drain"; +import { PaymentProcessingNotice } from "./PaymentProcessingNotice"; import { useBookingPayment } from "./useBookingPayment"; interface PayNowButtonProps { @@ -29,6 +31,24 @@ export function PayNowButton({ }: PayNowButtonProps) { const pay = useBookingPayment(booking.id); const pricing = booking.pricingBreakdown; + const payWindow = payWindowState(booking); + + // Pay deadline passed but in-flight payments are still settling: show the + // drain countdown instead of any pay action, so nobody pays a second time. + // Checked before the USD branch — a bank transfer is just as double-payable. + if (payWindow.phase === "draining" && payWindow.drainEndsAt) { + return ( + + ); + } + + // Window fully over (drain included) — nothing to pay against anymore. + if (payWindow.phase === "closed") { + return null; + } // USD is paid by bank transfer and confirmed by Finance — no online payment. if (isUsdOfflineBooking(booking)) { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx new file mode 100644 index 000000000..56b7897ee --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { Box, Group, Text } from "@mantine/core"; +import { Loader2 } from "lucide-react"; + +/** mm:ss left until `target`; clamped at zero so it never shows a negative. */ +function secondsLeft(target: number, now: number): string { + const total = Math.max(0, Math.ceil((target - now) / 1000)); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +export interface PaymentProcessingNoticeProps { + /** ISO end of the drain tail — the countdown target. */ + drainEndsAt: string; + /** Compact single-line form for list rows; full block for the detail page. */ + variant?: "inline" | "block"; + /** Called once the drain elapses, so the parent can refetch the new state. */ + onElapsed?: () => void; +} + +/** + * Shown in place of "Pay now" during the settlement drain tail: the pay deadline + * has passed but in-flight payments are still landing, so the customer waits + * rather than paying again. + */ +export function PaymentProcessingNotice({ + drainEndsAt, + variant = "block", + onElapsed, +}: PaymentProcessingNoticeProps) { + const targetMs = new Date(drainEndsAt).getTime(); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + setNow(Date.now()); + const interval = setInterval(() => { + const next = Date.now(); + setNow(next); + if (next >= targetMs) { + clearInterval(interval); + onElapsed?.(); + } + }, 1000); + return () => clearInterval(interval); + }, [targetMs, onElapsed]); + + const remaining = secondsLeft(targetMs, now); + + if (variant === "inline") { + return ( + + + + Processing · {remaining} + + + ); + } + + return ( + + + + + Payment processing — {remaining} left + + + + The payment window has closed and we're confirming the payments that + came in. If you already paid, it can take a few minutes to appear — + please don't pay again. This page updates on its own. + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts b/apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts new file mode 100644 index 000000000..61d97864b --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts @@ -0,0 +1,60 @@ +import { Freight } from "@edr/types"; + +/** + * Where a booking sits relative to its pay window. + * + * - `open` — the window is live; the customer can pay. + * - `draining` — the deadline passed but the settlement drain tail has not. A + * payment started just before the buzzer may still be settling, + * so we show "processing" and hide every pay action rather than + * invite a second payment for the same booking. + * - `closed` — the drain tail elapsed too; the window is over. + * - `none` — no deadline on the booking (nothing to gate). + */ +export type PayWindowPhase = "open" | "draining" | "closed" | "none"; + +export interface PayWindowState { + phase: PayWindowPhase; + /** True only while the customer may actually start a payment. */ + canPay: boolean; + /** End of the drain tail — the countdown target while `draining`. */ + drainEndsAt: string | null; +} + +type PayableBooking = Pick< + Freight.IBooking, + "paymentDeadline" | "paymentDrainEndsAt" +>; + +/** + * Classify a booking's pay window against `now`. + * + * Falls back to the raw deadline when the server sent no `paymentDrainEndsAt` + * (older payload): with no known tail there is no drain to wait out, so the + * window goes straight from open to closed. + */ +export function payWindowState( + booking: PayableBooking | null | undefined, + now: number = Date.now(), +): PayWindowState { + const deadline = booking?.paymentDeadline ?? null; + if (!deadline) { + return { phase: "none", canPay: true, drainEndsAt: null }; + } + + const deadlineMs = new Date(deadline).getTime(); + if (!Number.isFinite(deadlineMs)) { + return { phase: "none", canPay: true, drainEndsAt: null }; + } + if (now < deadlineMs) { + return { phase: "open", canPay: true, drainEndsAt: null }; + } + + const drainRaw = booking?.paymentDrainEndsAt ?? null; + const drainMs = drainRaw ? new Date(drainRaw).getTime() : NaN; + if (Number.isFinite(drainMs) && now < drainMs) { + return { phase: "draining", canPay: false, drainEndsAt: drainRaw }; + } + + return { phase: "closed", canPay: false, drainEndsAt: null }; +} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 7ff14721c..68f48e187 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -717,6 +717,12 @@ export interface IBooking extends BaseEntity { selectedForBatchAt?: string | null; /** End of the pay window once the booking is SELECTED_FOR_BATCH. */ paymentDeadline?: string | null; + /** + * End of the settlement drain tail that follows `paymentDeadline`. Between the + * two, an in-flight payment can still land, so the customer is shown a + * "payment processing" state instead of a pay action. + */ + paymentDrainEndsAt?: string | null; containers?: Array<{ type: string; qty: number; vgm: number }> | null;