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;