Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-07-16 19:28:12 +03:00
29 changed files with 809 additions and 103 deletions

View File

@@ -913,9 +913,9 @@ export class BillingService {
dueAt,
...(issuing
? {
status: Freight.InvoiceStatus.Pending,
issuedAt: invoice.issuedAt ?? new Date(),
}
status: Freight.InvoiceStatus.Pending,
issuedAt: invoice.issuedAt ?? new Date(),
}
: {}),
};
await mg.update(Invoice, { id: invoice.id }, patch);
@@ -1032,20 +1032,20 @@ export class BillingService {
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
// // DEMO: manually fire the gateway `payment.succeeded` callback here, without
// // waiting for real gateway settlement. Runs AFTER the paymentId link above so
// // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
// // remove — real settlement flips this via the `${source}.invoice.paid` handler.
// if (!result.immediateSuccess) {
// await this.payment.handlePaymentEvent({
// eventType: "payment.succeeded",
// eventId: `demo-${result.intentId}`,
// referenceId: invoice.sourceId,
// intentId: result.intentId,
// providerTxnId: result.providerTxnId,
// paidAt: (result.paidAt ?? new Date()).toISOString(),
// });
// }
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
if (!result.immediateSuccess) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`,
referenceId: invoice.sourceId,
intentId: result.intentId,
providerTxnId: result.providerTxnId,
paidAt: (result.paidAt ?? new Date()).toISOString(),
});
}
if (result.immediateSuccess) {
await this.settleByPaymentId(

View File

@@ -204,17 +204,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
if (contracts.length === 0) return;
const ids = contracts.map((c) => c.id);
const rows: Array<{ contract_id: string; current_phase: string | null }> =
await this.dataSource.query(
`SELECT DISTINCT ON (contract_id) contract_id, current_phase
FROM freight.contract_clearance_cycles
WHERE contract_id = ANY($1)
ORDER BY contract_id, cycle_number DESC`,
[ids],
);
const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase]));
const rows: Array<{
contract_id: string;
current_phase: string | null;
booking_id: string | null;
booking_status: string | null;
}> = await this.dataSource.query(
`SELECT DISTINCT ON (ccc.contract_id)
ccc.contract_id, ccc.current_phase,
b.id AS booking_id, b.status AS booking_status
FROM freight.contract_clearance_cycles ccc
LEFT JOIN freight.bookings b ON b.id = ccc.booking_id
WHERE ccc.contract_id = ANY($1)
ORDER BY ccc.contract_id, ccc.cycle_number DESC`,
[ids],
);
const byContract = new Map(rows.map((r) => [r.contract_id, r]));
for (const contract of contracts) {
contract.clearancePhase = byContract.get(contract.id) ?? null;
const row = byContract.get(contract.id);
contract.clearancePhase = row?.current_phase ?? null;
contract.latestCycleBookingId = row?.booking_id ?? null;
contract.latestCycleBookingStatus = row?.booking_status ?? null;
}
}

View File

@@ -312,6 +312,14 @@ export class Contract extends BaseEntity {
*/
clearancePhase?: string | null;
/**
* Latest clearance cycle's linked booking (id + status), attached alongside
* clearancePhase. Lets the GL queue tell an expired (unpaid) booking apart
* from a live one so it can offer a rebook. Not columns.
*/
latestCycleBookingId?: string | null;
latestCycleBookingStatus?: string | null;
/**
* Body of the most recent CHANGES_REQUESTED review note, attached by
* ContractsService.findById so the portal can show the customer what staff

View File

@@ -0,0 +1,39 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator';
/**
* Admin maintenance reschedule: move a train's departure to a new date/time.
* Every allocated booking rides along (links and wagon assignments untouched);
* only the dates move — the schedule's train set, route, and window rule
* snapshot all stay exactly as they were.
*/
export class MaintenanceRescheduleDto {
@ApiProperty({
example: '2026-07-20T05:00:00.000Z',
description: 'New scheduled departure date/time (ISO 8601)',
})
@IsISO8601()
newDepartureDate!: string;
@ApiPropertyOptional({ description: 'Why the train is being moved (logged)' })
@IsOptional()
@IsString()
reason?: string;
@ApiPropertyOptional({
description: 'Client-side trigger tag (e.g. TRAIN_MAINTENANCE) — logged only',
})
@IsOptional()
@IsString()
trigger?: string;
@ApiPropertyOptional({
description:
"The bookings the client believes are aboard — informational; the server moves the schedule's actual bookings",
type: [String],
})
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
incomingBookingIds?: string[];
}

View File

@@ -49,6 +49,7 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
import { BookingJourneyService } from "./booking-journey.service";
@@ -711,6 +712,20 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post("schedules/:id/maintenance")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged",
})
async maintenanceReschedule(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: MaintenanceRescheduleDto,
) {
await this.trainSchedulingService.maintenanceReschedule(id, dto);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post("schedules/:id/doc-review-complete")
@TrainSchedulingManage()
@ApiOperation({

View File

@@ -91,6 +91,7 @@ import {
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
import { type BookingWindowConfig } from './booking-window.config';
import { BookingWindowGateway } from './booking-window.gateway';
import { BookingNotifierService } from './booking-notifier.service';
@@ -864,6 +865,121 @@ export class TrainSchedulingService {
return fresh ?? schedule;
}
/**
* Maintenance reschedule: the admin moves a train (with everything aboard) to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
* phase and inside the booking lead window — a maintenance move is an
* operational fact, not a planning choice. What moves and what stays:
*
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
* so a booking left on the old day would fall out of its own train's pool).
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
* maxWagons, and the window RULE snapshot. Stamped window times are only
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
* schedule mid- or post-window keeps its timeline untouched.
*
* Customers of every moved booking are notified (maintenanceMoved).
*/
async maintenanceReschedule(
id: string,
dto: MaintenanceRescheduleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
);
}
const departure = new Date(dto.newDepartureDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
// PRE_WINDOW only: the stamped open/close were derived from the old
// departure and the window hasn't opened yet, so re-derive them from the
// schedule's own rule snapshot against the new date (joining the target
// day's route group timeline when one exists, exactly like
// updateScheduleDate). Mid/post-window schedules keep their timeline.
const windowFields =
schedule.windowPhase === 'PRE_WINDOW'
? await (async () => {
const merged = effectiveWindowConfig(
schedule,
await this.getWindowConfig(),
);
const times =
schedule.direction === 'EXPORT'
? computeExportWindowTimes(departure, merged)
: computeImportWindowTimes(departure, merged, new Date());
const anchor =
schedule.direction === 'EXPORT'
? null
: await this.findGroupWindowAnchor(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
departure,
);
return anchor
? this.groupWindowFieldsFrom(anchor, departure)
: {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
};
})()
: {};
await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure,
...(scheduledArrivalDate ? { scheduledArrivalDate } : {}),
...windowFields,
});
// Everything aboard or targeted rides along: bookings linked on the train
// (schedule_bookings) plus reservations still pointing at it via
// train_schedule_id (paid-but-unlinked, awaiting payment, …).
const linkedIds = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId);
const targeted = await this.dataSource.getRepository(Booking).find({
where: [{ trainScheduleId: id }, ...(linkedIds.length ? [{ id: In(linkedIds) }] : [])],
relations: { company: true },
});
const aboard = targeted.filter(
(b) => !['CANCELLED', 'EXPIRED', 'REJECTED'].includes(b.status),
);
if (aboard.length) {
await this.dataSource
.getRepository(Booking)
.update(aboard.map((b) => b.id), { scheduledDate: departure } as never);
for (const booking of aboard) {
this.bookingNotifier.maintenanceMoved(booking, departure);
}
}
this.logger.log(
`[MAINTENANCE] Schedule ${schedule.reference ?? id} moved to ${departure.toISOString()} ` +
`(${dto.trigger ?? 'TRAIN_MAINTENANCE'}${dto.reason ? `: ${dto.reason}` : ''}); ` +
`${aboard.length} booking(s) moved with the train.`,
);
void this.emitWindowState(id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
@@ -4717,6 +4833,7 @@ export class TrainSchedulingService {
createdAt: schedule.createdAt ?? null,
scheduleDate: schedule.scheduledDepartureDate,
trainNumber: schedule.trainNumber ?? null,
direction: schedule.direction ?? null,
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination:
@@ -5942,6 +6059,64 @@ export class TrainSchedulingService {
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
);
// The trainSet slots below are the PLANNED wagons (one per allocation). A
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
// included (the pull-limit check already counts their tare) — so append the
// train's remaining wagons as consist-only entries and the composition views
// (scheduling-v2 finalize, batch-board composition tab) draw the train as it
// really is: loaded slots first, then the empty consist. Skipped for frozen
// (dispatched/arrived) schedules: their wagons are released and re-pinned to
// later trains, so the live consist no longer describes THIS departure.
const coveredPhysicalIds = new Set<string>();
for (const slot of schedule.trainSet?.wagons ?? []) {
const frozenSlot = isWagonAllocationFrozen
? snapshotSlotByTrainSetWagonId.get(slot.id)
: undefined;
const physicalId = frozenSlot
? frozenSlot.physicalWagonId
: slot.physicalWagonId ?? null;
if (physicalId) coveredPhysicalIds.add(physicalId);
}
const maxSlotSequenceNo = Math.max(
0,
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
);
const emptyConsistWagons =
schedule.trainSet?.trainId && !isWagonAllocationFrozen
? (
await this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
})
)
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
.map((wagon, index) => ({
// Physical wagon id — there is no TrainSetWagon slot behind this
// row, so remove/edit affordances must stay disabled (consistOnly).
id: wagon.id,
sequenceNo: maxSlotSequenceNo + index + 1,
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: 'EMPTY',
physicalWagonId: wagon.id,
physicalWagonNumber: wagon.wagonNumber ?? null,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
}
: null,
allocations: [],
consistOnly: true,
}))
: [];
return {
id: schedule.id,
reference: schedule.reference ?? null,
@@ -6109,7 +6284,8 @@ export class TrainSchedulingService {
: null,
})) ?? [],
};
}),
})
.concat(emptyConsistWagons),
}
: null,
bookings:

View File

@@ -26,6 +26,7 @@ import {
TextInput,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
@@ -642,6 +643,20 @@ export default function GlCreateBookingForm() {
});
}, [isContainer, contract, containerLines, contractWithReturn]);
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the booking can never be planned. The server rejects it too (the price
// modal's `pairingErrors`), but that only lands after GL has filled the whole
// form — mirror the customer portal (new-booking-form/schema.ts `calcWagons`)
// and block it inline instead. Size strings arrive as "20ft" from the contract
// scope but as a bare "20" from the rebook seed, so match on the leading digits.
const ft20Total = useMemo(() => {
if (!isContainer) return 0;
return containerLines
.filter((l) => parseInt(l.containerSize, 10) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
}, [isContainer, containerLines]);
const hasOdd20ft = ft20Total % 2 === 1;
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
const bulkErrors = useMemo<BulkErrors>(() => {
@@ -688,7 +703,7 @@ export default function GlCreateBookingForm() {
)
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
const formValid = cargoValid && !dateError && !routeError;
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
/** The create-booking DTO from the current form state — shared by the
* authoritative price preview and the actual submit so what GL confirms is
@@ -1286,6 +1301,21 @@ export default function GlCreateBookingForm() {
</Box>
))
)}
{hasOdd20ft ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Total})`}
>
20ft containers travel two per wagon, so they must be booked in
even numbers. Add one more 20ft container or remove one (e.g.
book {ft20Total + 1} or {ft20Total - 1} instead of {ft20Total})
the booking cannot be created with an unpaired 20ft container.
</Alert>
) : null}
</Stack>
</StepCard>
) : (
@@ -1530,14 +1560,27 @@ export default function GlCreateBookingForm() {
</Alert>
) : null}
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={openPriceModal}
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
>
Review price &amp; book
</Button>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={openPriceModal}
// Same hard block the customer portal applies at review time —
// an unpaired 20ft can never be planned onto a wagon.
disabled={hasOdd20ft}
>
Review price &amp; book
</Button>
</Box>
</Tooltip>
</Group>
</Box>
</Box>

View File

@@ -36,7 +36,9 @@ export function PinWagonsForm({
autoFillOnMount?: boolean;
}) {
const originYardId = schedule.originStation?.id;
const slots = schedule.trainSet?.wagons ?? [];
// Consist-only rows are the built train's coupled-but-empty wagons — display
// entries with no TrainSetWagon slot behind them, so nothing can be pinned.
const slots = (schedule.trainSet?.wagons ?? []).filter((w) => !w.consistOnly);
const [assignments, setAssignments] = useState<Record<string, string>>({});
const wagonOptionsByType = useMemo(() => {

View File

@@ -359,7 +359,9 @@ function WagonCar({
{isEmpty ? (
<Text size="xs" c="dimmed">
Empty slot available for allocation.
{wagon.consistOnly
? "Empty wagon — coupled on the train, no load planned."
: "Empty slot — available for allocation."}
</Text>
) : (
<Stack gap={6}>

View File

@@ -167,9 +167,9 @@ export const WagonCard = ({
<TrainFront size={18} />
</ThemeIcon>
<Text size="sm" c="dimmed">
Empty slot
{wagon.consistOnly ? "Empty wagon — coupled on the train" : "Empty slot"}
</Text>
{!isDispatched ? (
{!isDispatched && !wagon.consistOnly ? (
<Button
variant="subtle"
color="gray"

View File

@@ -13,3 +13,6 @@ export function fileViewUrl(fileId: string, download = false): string {
const base = `${API_BASE_URL}/api/files/${fileId}`;
return download ? `${base}?download=1` : base;
}

View File

@@ -5,6 +5,7 @@ import {
Alert,
Badge,
Box,
Button,
Grid,
Group,
Loader,
@@ -21,10 +22,18 @@ import {
CheckCircle2,
Clock,
PackageCheck,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { Link } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import {
FREIGHT_PERMS,
hasPermission,
isDjiboutiGl,
} from "@/lib/permissions";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
@@ -44,6 +53,7 @@ import {
export default function ContractClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { view, viewer } = useFileViewer();
const { user } = useAuth();
const { data: contract, refetch: refetchContract } = useContractDetail(id);
const {
@@ -106,6 +116,16 @@ export default function ContractClearanceDetailPage() {
const reviewReadOnly = shipmentLocked;
const queriesLocked = Boolean(clearance?.preClearanceFinalized);
const bookingHref = `/dashboard/contracts/${id}/create-booking`;
// The GL-created booking expired unpaid — the slot is free again and GL
// rebooks on the customer's behalf (customs bookings are never self-booked).
const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED";
const canRebook =
bookingExpired &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const rebookHref = linkedBookingId
? `${bookingHref}?copyFrom=${linkedBookingId}`
: bookingHref;
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
useBookingMilestones(linkedBookingId);
@@ -165,7 +185,16 @@ export default function ContractClearanceDetailPage() {
{ label: reference },
]}
meta={
bookingAlreadyCreated ? (
bookingExpired ? (
<Badge
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={13} />}
>
Payment expired rebook
</Badge>
) : bookingAlreadyCreated ? (
<Badge
variant="light"
color="blue"
@@ -211,7 +240,49 @@ export default function ContractClearanceDetailPage() {
it can actually create the booking without checking the schedule board. */}
{id ? <GlUpcomingWindowsSection contractId={id} /> : null}
{bookingAlreadyCreated ? (
{bookingExpired ? (
<Alert
color="orange"
radius="md"
icon={<RefreshCw size={16} />}
title="Booking expired — payment not received"
>
<Stack gap="sm" align="flex-start">
<Text size="sm">
The customer did not pay before the deadline, so the booking
expired and its train slot was released. The contract slot is
free again GL Ethiopia can rebook on the customer&apos;s
behalf without re-running clearance.
{linkedBookingId ? (
<>
{" "}
<Text
component={Link}
to={`/dashboard/bookings/${linkedBookingId}/clearance`}
inherit
fw={600}
c="orange.8"
>
View expired booking
</Text>
</>
) : null}
</Text>
{canRebook ? (
<Button
component={Link}
to={rebookHref}
color="grape"
radius="md"
size="sm"
leftSection={<RefreshCw size={15} />}
>
Rebook for customer
</Button>
) : null}
</Stack>
</Alert>
) : bookingAlreadyCreated ? (
<Alert
color="blue"
radius="md"

View File

@@ -92,6 +92,10 @@ interface ClearanceRow {
ready: boolean;
/** true once GL Ethiopia created the shipment booking. */
bookingCreated: boolean;
/** true when the created booking EXPIRED unpaid — GL must rebook. */
paymentExpired: boolean;
/** The expired booking, so rebook can copy its cargo. */
expiredBookingId: string | null;
}
function yardLabel(
@@ -145,6 +149,13 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
status: contract.status,
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
bookingCreated: contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS",
paymentExpired:
contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS" &&
contract.latestCycleBookingStatus === "EXPIRED",
expiredBookingId:
contract.latestCycleBookingStatus === "EXPIRED"
? (contract.latestCycleBookingId ?? null)
: null,
};
}
@@ -186,6 +197,24 @@ function DirectionIcon({ direction }: { direction: string }) {
}
function StatusBadge({ row }: { row: ClearanceRow }) {
if (row.paymentExpired) {
return (
<Tooltip
label="The customer did not pay in time — the booking expired. GL rebooks on the customer's behalf."
withArrow
>
<Badge
size="sm"
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={12} />}
>
Payment expired
</Badge>
</Tooltip>
);
}
if (row.bookingCreated) {
return (
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
@@ -519,6 +548,27 @@ export default function ContractClearanceListPage() {
Create booking
</Button>
</Group>
) : row.original.paymentExpired && canCreateBooking ? (
<Group justify="flex-end" pr="xs">
<Button
size="compact-sm"
color="grape"
radius="md"
leftSection={<RefreshCw size={14} />}
onClick={(e) => {
e.stopPropagation();
navigate(
`/dashboard/contracts/${row.original.id}/create-booking${
row.original.expiredBookingId
? `?copyFrom=${row.original.expiredBookingId}`
: ""
}`,
);
}}
>
Rebook
</Button>
</Group>
) : (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />

View File

@@ -31,8 +31,6 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
import {
directionColor,
directionRowStyle,
trainStatusColor,
trainStatusLabel,
} from "@/components/trainBuilder/trainStatus";
@@ -164,14 +162,9 @@ export default function TrainBuilderListPage() {
return (
<Stack gap={2}>
{active?.trainNumber ? (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
{active.trainNumber}
</Text>
<Badge size="xs" variant="light" color={directionColor(active.direction)}>
{active.direction ?? "—"}
</Badge>
</Group>
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
{active.trainNumber}
</Text>
) : null}
<Text size="xs" c="dimmed" ff="monospace" lh={1.2}>
IMP {row.original.importTrainNumber ?? "—"} · EXP{" "}
@@ -348,7 +341,6 @@ export default function TrainBuilderListPage() {
data={trains}
status={tableStatus}
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
rowStyle={(train) => directionRowStyle(train.activeSchedule?.direction)}
error={
trainsQuery.isError
? {

View File

@@ -1,6 +1,7 @@
import type { ColumnDef } from "@edr/ui-common";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
@@ -38,6 +39,10 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
directionColor,
directionRowStyle,
} from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
@@ -303,9 +308,20 @@ export default function TrainScheduleV2ListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Stack gap={4}>
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
{row.original.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(row.original.direction)}
>
{row.original.direction}
</Badge>
) : null}
</Group>
<Box maw={220}>
<RouteCorridor
origin={row.original.origin}
@@ -660,6 +676,7 @@ export default function TrainScheduleV2ListPage() {
onRowClick={(schedule) =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
rowStyle={(schedule) => directionRowStyle(schedule.direction)}
error={
schedulesQuery.isError
? {
@@ -909,7 +926,18 @@ function ScheduleCard({
</Box>
<Group justify="space-between" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<Group gap={6} wrap="nowrap">
<FreightTypeBadge freightType={schedule.freightType} />
{schedule.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(schedule.direction)}
>
{schedule.direction}
</Badge>
) : null}
</Group>
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />
<MetricChip value={schedule.wagonCount} label="wgn" />

View File

@@ -168,6 +168,8 @@ export interface TrainScheduleListItem {
createdAt?: string | null;
scheduleDate: string;
trainNumber?: string | null;
/** Trade direction of this departure (IMPORT / EXPORT), when known. */
direction?: string | null;
routeName?: string | null;
origin: string | null;
destination: string | null;
@@ -608,6 +610,11 @@ export interface TrainScheduleDetail {
name: string;
} | null;
allocations: TrainScheduleWagonAllocation[];
/**
* Coupled-but-empty wagon of the built train — no TrainSetWagon slot
* behind it, so remove/edit actions do not apply.
*/
consistOnly?: boolean;
}>;
} | null;
bookings: Array<{

View File

@@ -21,6 +21,7 @@ import {
Textarea,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
@@ -269,6 +270,19 @@ function NewShipmentBookingForm({
mode: "onChange",
});
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the booking can never be planned. The server's shipment validation reports
// it too, but only once the price modal opens — block it inline instead, the
// same way the direct-booking wizard does (new-booking-form `calcWagons`).
const watchedContainers = form.watch("containers");
const ft20Total =
contract.freightType === "CONTAINER"
? (watchedContainers ?? [])
.filter((l) => l.containerSize === "20ft")
.reduce((sum, l) => sum + Number(l.quantity || 0), 0)
: 0;
const hasOdd20ft = ft20Total % 2 === 1;
const submitMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
completeBookingId
@@ -367,6 +381,8 @@ function NewShipmentBookingForm({
// run it for every freight type; container contracts additionally get
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
const handleReview = form.handleSubmit((values) => {
// An unpaired 20ft can never be planned onto a wagon — don't even price it.
if (hasOdd20ft) return;
setPendingValues(values);
validateMutation.reset();
validateMutation.mutate(buildDto(values));
@@ -483,15 +499,26 @@ function NewShipmentBookingForm({
}}
>
<Group justify="flex-end" className="mx-auto max-w-4xl">
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
>
Review price &amp; book
</Button>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
disabled={hasOdd20ft}
>
Review price &amp; book
</Button>
</Box>
</Tooltip>
</Group>
</Box>
</form>
@@ -1265,6 +1292,28 @@ function CargoStep({
This contract has no container sizes in scope.
</Text>
)}
{(() => {
const ft20 = lines
.filter((l) => l.containerSize === "20ft")
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
if (ft20 % 2 !== 1) return null;
return (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20})`}
>
<Text fz={13}>
20ft containers travel two per wagon, so they must be booked in
even numbers. Please add one more 20ft container or remove one
(e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) the
booking cannot be submitted with an unpaired 20ft container.
</Text>
</Alert>
);
})()}
</Stack>
</StepCard>
);

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Group,
@@ -13,7 +14,7 @@ import {
Textarea,
Title,
} from "@mantine/core";
import { ArrowLeft, CalendarDays, Send } from "lucide-react";
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { DatePickerInput } from "@mantine/dates";
@@ -98,7 +99,14 @@ export default function NewShipmentRequestPage() {
contract.cargoScope?.[0];
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
// 20ft containers ride two per wagon, so an odd total can never be planned —
// and GL's create-booking form blocks it too, so an odd request would only
// dead-end there. Same even-number rule the booking forms apply.
const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0;
const hasOdd20ft = ft20Requested % 2 === 1;
const handleSubmit = () => {
if (hasOdd20ft) return;
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
@@ -202,6 +210,23 @@ export default function NewShipmentRequestPage() {
/>
)}
{hasOdd20ft ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Requested})`}
>
<Text fz={13}>
20ft containers travel two per wagon, so they must be requested
in even numbers. Please add one more 20ft container or remove
one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "}
instead of {ft20Requested}).
</Text>
</Alert>
) : null}
{capacity?.length ? (
<Text size="xs" c="dimmed">
Remaining capacity is shown on the contract GL will validate your request.
@@ -220,6 +245,7 @@ export default function NewShipmentRequestPage() {
leftSection={<Send size={16} />}
loading={submit.isPending}
onClick={handleSubmit}
disabled={hasOdd20ft}
>
Submit shipment request
</Button>

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import {
@@ -18,7 +19,15 @@ const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
export class PaymentEventsConsumer {
private readonly logger = new Logger(PaymentEventsConsumer.name);
constructor(private readonly paymentsService: PaymentsService) {}
// IMPORTANT: do NOT constructor-inject PaymentsService here. It is a REQUEST/TRANSIENT-scoped
// provider (its scope bubbles up from a scoped dependency), so it has no singleton instance at
// bootstrap. Constructor-injecting it makes THIS consumer scoped too — and golevelup binds the
// @RabbitSubscribe handler to the singleton instance it discovers at bootstrap. With no such
// instance, the subscription still registers but delivered messages are never dispatched to
// handle(): they pile up unacked and the booking never confirms. Injecting only the lightweight
// (singleton) ModuleRef keeps this consumer a clean singleton; PaymentsService is resolved per
// message via resolve() (get() throws for scoped providers).
constructor(private readonly moduleRef: ModuleRef) {}
@IsPublic()
@RabbitSubscribe({
@@ -37,7 +46,13 @@ export class PaymentEventsConsumer {
`RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`,
);
try {
const result = await this.paymentsService.handlePaymentEvent(
// resolve() (not get()) because PaymentsService is scoped — get() throws for scoped providers.
const paymentsService = await this.moduleRef.resolve(
PaymentsService,
undefined,
{ strict: false },
);
const result = await paymentsService.handlePaymentEvent(
event as unknown as PaymentEventDto,
);
this.logger.log(

View File

@@ -154,6 +154,13 @@ export class IntentStatusDto {
@ApiPropertyOptional() paidAt?: string;
@ApiPropertyOptional() failureCode?: string;
@ApiPropertyOptional() failureMessage?: string;
@ApiPropertyOptional({
type: "object",
additionalProperties: true,
description:
"Raw provider payload (initiation response merged with the latest status query) for inspection/debugging. Provider-specific shape; never trusted for state.",
})
providerResponse?: Record<string, unknown>;
}
export class BookingAmountResponseDto {

View File

@@ -432,6 +432,9 @@ export class PaymentsService {
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null,
failureCode: snapshot.failureCode ?? null,
failureMessage: snapshot.failureMessage ?? null,
rawInitiation: snapshot.providerResponse
? (snapshot.providerResponse as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
};
return this.prisma.paymentIntent.upsert({
where: { bookingId },
@@ -589,6 +592,10 @@ export class PaymentsService {
paidAt: intent.paidAt?.toISOString(),
failureCode: intent.failureCode ?? undefined,
failureMessage: intent.failureMessage ?? undefined,
providerResponse:
intent.rawInitiation && typeof intent.rawInitiation === "object"
? (intent.rawInitiation as Record<string, unknown>)
: undefined,
};
}

View File

@@ -7,7 +7,7 @@ import { useBookingStore } from "@/lib/booking-store";
import { usePaymentStore } from "@/lib/payment-store";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { CheckCircle, Clock, Copy, Train, FileText } from "lucide-react";
import { format } from "date-fns";
import { isChild, isFirstChild } from "@/utils/fare-utils";
@@ -60,6 +60,27 @@ export default function ConfirmationPage() {
const [copied, setCopied] = useState(false);
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
// Grace period after landing on this page: keep showing the generic "processing"
// spinner instead of the "payment pending" screen, and poll the payment intent
// frequently — booking.status and paymentIntent.status flip to CONFIRMED/SUCCEEDED
// together (see finalizePaymentSuccess in payments.service.ts), so a payment that
// already succeeded at the provider often just needs a few more seconds for its
// webhook to reach us. Once the grace period elapses, fall back to the normal
// pending screen with slower background polling.
const CONFIRMATION_GRACE_PERIOD_MS = 10_000;
const FAST_POLL_INTERVAL_MS = 2_500;
const SLOW_POLL_INTERVAL_MS = 10_000;
const mountTimeRef = useRef(Date.now());
const [withinGracePeriod, setWithinGracePeriod] = useState(true);
useEffect(() => {
const timer = setTimeout(
() => setWithinGracePeriod(false),
CONFIRMATION_GRACE_PERIOD_MS,
);
return () => clearTimeout(timer);
}, []);
// Warms the code-split voucher module ahead of the click so the handler's own
// `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered
// too long after the originating click's synchronous execution window is silently
@@ -68,26 +89,54 @@ export default function ConfirmationPage() {
import("@/lib/generate-voucher");
}, []);
const { data: _booking } = useQuery<BookingWithTicket>({
const {
data: _booking,
refetch: refetchBooking,
isLoading: isBookingLoading,
isError: isBookingError,
} = useQuery<BookingWithTicket>({
queryKey: ["booking", bookingId],
queryFn: async (): Promise<BookingWithTicket> => {
try {
return await apiClient.get(`/bookings/${bookingId}`);
} catch (error) {
return {
id: bookingId || "",
pnr: pnr || undefined,
status: "PENDING_PAYMENT",
totalMinor: passengers.reduce(
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
0,
),
};
}
},
// Let a real fetch failure surface as a real error (React Query's global retry:1
// default then retries once automatically) instead of silently returning a
// fabricated "PENDING_PAYMENT" object — that used to mask genuine failures (a
// transient blip right after a cross-domain redirect from the payment gateway is
// common) as normal pending state forever, since a caught error that returns data
// looks like a success to React Query and never gets retried.
queryFn: (): Promise<BookingWithTicket> => apiClient.get(`/bookings/${bookingId}`),
// Payment status must never be served from a stale cache — the app-wide default
// (providers.tsx) is a 60s staleTime, which would otherwise block React Query's
// own refetch-on-window-focus from firing (it only refetches stale data). Without
// this override, a tab left open past a payment completing can sit showing
// "pending" long after it's actually confirmed, even after being refocused,
// until the interval below happens to tick — which browsers throttle heavily in
// backgrounded tabs, so that can take a very long time.
staleTime: 0,
enabled: !!bookingId,
});
// Poll the payment intent while the booking is PENDING_PAYMENT — fast during the
// grace period (catches a webhook that's just a few seconds behind), then slower
// in the background afterward. The backend auto-confirms (and generates tickets)
// when the payment-api reports SUCCEEDED, so detecting that here means the
// booking is now CONFIRMED — refetch to update the UI without requiring the user
// to refresh.
const { data: intentStatus } = useQuery<any>({
queryKey: ["payment-intent-status", bookingId],
queryFn: () => apiClient.get(`/payments/intents/${bookingId}`),
enabled: _booking?.status === "PENDING_PAYMENT" && !!bookingId,
staleTime: 0,
refetchInterval: () =>
Date.now() - mountTimeRef.current < CONFIRMATION_GRACE_PERIOD_MS
? FAST_POLL_INTERVAL_MS
: SLOW_POLL_INTERVAL_MS,
});
useEffect(() => {
if (intentStatus?.status === "SUCCEEDED") {
refetchBooking();
}
}, [intentStatus?.status]);
// Only trust an actually-confirmed booking to show ticket numbers / a "CONFIRMED" badge —
// a gateway redirect back here does not mean payment succeeded (see payment return pages).
// Ticket generation itself is never triggered from this page — the payment webhook
@@ -254,6 +303,53 @@ export default function ConfirmationPage() {
if (!bookingId || !pnr) return null;
// Wait for the actual booking status before deciding pending vs. confirmed —
// without this, _booking is briefly undefined on first load, isConfirmed reads
// as false, and the page flashes "payment pending" before flipping to
// "confirmed" once the fetch resolves (common, since the payment webhook has
// often already completed by the time the user lands here). Also keep showing
// this same spinner through the grace period above if the booking is still
// PENDING_PAYMENT — most of the time the webhook lands within that window, so
// the user goes straight to "confirmed" without ever seeing "pending" at all.
if (isBookingLoading || (withinGracePeriod && _booking?.status === "PENDING_PAYMENT")) {
return (
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto flex flex-col items-center justify-center py-24 text-center">
<div className="w-12 h-12 rounded-full border-4 border-primary/20 border-t-primary animate-spin mb-4" />
<p className="text-gray-600 dark:text-gray-400">
Please wait, we are processing your booking
</p>
</div>
</div>
</div>
);
}
// A real fetch failure (not just "still pending") — surface it honestly instead of
// silently pretending the booking is pending, and let the user retry the check
// without needing a full page refresh.
if (isBookingError) {
return (
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto flex flex-col items-center justify-center py-24 text-center">
<p className="text-gray-600 dark:text-gray-400 mb-4">
We&apos;re having trouble loading your booking status right
now. This is usually temporary tap below to try again.
</p>
<button
onClick={() => refetchBooking()}
className="btn-primary"
>
Check again
</button>
</div>
</div>
</div>
);
}
return (
<div className="booking-page">
<div className="container mx-auto px-4">

View File

@@ -125,6 +125,22 @@ function BookingDetailContent() {
booking?.status === "PENDING_PAYMENT" || booking?.status === "DRAFT",
});
// When the booking is PENDING_PAYMENT, poll the payment intent endpoint every 10 s.
// The backend auto-confirms the booking when it finds a SUCCEEDED intent, so detecting
// SUCCEEDED here means the booking is now CONFIRMED — refetch to update the UI.
const { data: intentStatus } = useQuery<any>({
queryKey: ["payment-intent-status", booking?.id],
queryFn: () => apiClient.get(`/payments/intents/${booking!.id}`),
enabled: booking?.status === "PENDING_PAYMENT" && !!booking?.id,
refetchInterval: 10_000,
});
useEffect(() => {
if (intentStatus?.status === "SUCCEEDED") {
refetch();
}
}, [intentStatus?.status]);
const selectedPaymentMethod =
(paymentMethods || []).find((m: any) => m.type === selectedMethod) || null;

View File

@@ -55,13 +55,20 @@ function DmoneySuccessContent() {
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
// client-side here; just hand off without claiming an outcome we can't confirm.
if (!bookingId) {
if (!cancelled) {
router.push(target);
}
if (manageBookingRef) {
router.push(target);
return;
}
// Normal booking flow: bookingId comes from a Zustand store persisted to localStorage.
// This page is always reached via a real cross-domain redirect from the payment gateway
// (a full page load, not an in-app navigation), so that store has to rehydrate from
// localStorage asynchronously — bookingId can read as empty on the first render or two.
// Wait for it instead of treating an empty first-render value as "nothing to verify",
// which would silently skip this page's whole verification step and hand off to
// /booking/confirmation without ever having checked payment status here.
if (!bookingId) return;
verifyBookingPaid(bookingId).then((result) => {
if (cancelled) return;
if (result === 'SUCCEEDED') {
@@ -82,8 +89,7 @@ function DmoneySuccessContent() {
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [bookingId, router, updateStatus]);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">

View File

@@ -55,13 +55,20 @@ function TelebirrSuccessContent() {
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
// client-side here; just hand off without claiming an outcome we can't confirm.
if (!bookingId) {
if (!cancelled) {
router.push(target);
}
if (manageBookingRef) {
router.push(target);
return;
}
// Normal booking flow: bookingId comes from a Zustand store persisted to localStorage.
// This page is always reached via a real cross-domain redirect from the payment gateway
// (a full page load, not an in-app navigation), so that store has to rehydrate from
// localStorage asynchronously — bookingId can read as empty on the first render or two.
// Wait for it instead of treating an empty first-render value as "nothing to verify",
// which would silently skip this page's whole verification step and hand off to
// /booking/confirmation without ever having checked payment status here.
if (!bookingId) return;
verifyBookingPaid(bookingId).then((result) => {
if (cancelled) return;
if (result === 'SUCCEEDED') {
@@ -82,8 +89,7 @@ function TelebirrSuccessContent() {
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [bookingId, router, updateStatus]);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">

View File

@@ -55,13 +55,20 @@ function WaafiSuccessContent() {
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
// client-side here; just hand off without claiming an outcome we can't confirm.
if (!bookingId) {
if (!cancelled) {
router.push(target);
}
if (manageBookingRef) {
router.push(target);
return;
}
// Normal booking flow: bookingId comes from a Zustand store persisted to localStorage.
// This page is always reached via a real cross-domain redirect from the payment gateway
// (a full page load, not an in-app navigation), so that store has to rehydrate from
// localStorage asynchronously — bookingId can read as empty on the first render or two.
// Wait for it instead of treating an empty first-render value as "nothing to verify",
// which would silently skip this page's whole verification step and hand off to
// /booking/confirmation without ever having checked payment status here.
if (!bookingId) return;
verifyBookingPaid(bookingId).then((result) => {
if (cancelled) return;
if (result === 'SUCCEEDED') {
@@ -82,8 +89,7 @@ function WaafiSuccessContent() {
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [bookingId, router, updateStatus]);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">

View File

@@ -41,6 +41,8 @@ export interface ProviderResultInput {
confirmedAmountMinor?: number;
failureCode?: string;
failureMessage?: string;
/** Raw provider status-query body, merged into the intent's audit payload when present. */
rawResponse?: Record<string, unknown>;
}
@Injectable()
@@ -357,6 +359,7 @@ export class IntentsService {
providerTxnId: status.providerTxnId,
failureCode: status.failureCode,
failureMessage: status.failureMessage,
rawResponse: status.rawResponse,
};
}
@@ -388,6 +391,15 @@ export class IntentsService {
return { alreadyTerminal: true };
}
// Keep the audit payload current with the latest provider status body (surfaced as
// `providerResponse` in the snapshot). Merged so the initiation keys are preserved.
if (result.rawResponse) {
intent.rawInitiation = {
...(intent.rawInitiation ?? {}),
statusResponse: result.rawResponse,
};
}
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
const paidAt = result.paidAt ?? new Date();
intent.status = ProviderPaymentStatus.SUCCEEDED;
@@ -482,6 +494,7 @@ export class IntentsService {
failureCode: intent.failureCode ?? undefined,
failureMessage: intent.failureMessage ?? undefined,
expiresAt: intent.expiresAt?.toISOString(),
providerResponse: intent.rawInitiation ?? undefined,
};
}
}

View File

@@ -154,6 +154,13 @@ export type PaymentIntentSnapshot ={
failureCode?: string;
failureMessage?: string;
expiresAt?: string;
/**
* Raw provider payload for inspection/debugging — the audit copy of the provider
* initiation response merged with the latest status-query response (secrets redacted
* upstream). Not a contract with the provider; shape is provider-specific. Never trusted
* for state decisions — the state machine drives `status`.
*/
providerResponse?: Record<string, unknown>;
}
export type PaymentEventType = "payment.succeeded" | "payment.failed";

View File

@@ -620,6 +620,12 @@ export interface IContract extends BaseEntity {
* clearance view per contract.
*/
clearancePhase?: ContractDocPhase | string | null;
/**
* Latest clearance cycle's linked booking id + status (list responses only).
* An EXPIRED status means the customer never paid — GL may rebook.
*/
latestCycleBookingId?: string | null;
latestCycleBookingStatus?: string | null;
pricingBreakdown?: ContractPricingBreakdown | null;
pricingDisplayMode?: "UNIT_RATES";