From c6ca5ed8e8f6c44f324f0d401a59860083accc42 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 16 Jul 2026 12:05:47 +0300 Subject: [PATCH 01/10] add provider detail on the --- .../src/modules/payments/payments.dto.ts | 7 +++++++ .../src/modules/payments/payments.service.ts | 7 +++++++ .../src/modules/intents/intents.service.ts | 13 +++++++++++++ packages/types/src/common/payments.ts | 7 +++++++ 4 files changed, 34 insertions(+) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 825fa114e..e0b594fd2 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -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; } export class BookingAmountResponseDto { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 5bd068255..458175fe2 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -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) + : undefined, }; } diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index 0ebaaad05..a14469dd1 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -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; } @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, }; } } diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index ebc6e34dd..fc7769db7 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -153,6 +153,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; } export type PaymentEventType = "payment.succeeded" | "payment.failed"; From e8e12c13632e89ace0adfbc851c1d73349baf153 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 16 Jul 2026 12:02:11 +0000 Subject: [PATCH 02/10] fix u=issue --- .../dto/maintenance-reschedule.dto.ts | 39 ++++ .../train-scheduling.controller.ts | 15 ++ .../train-scheduling.service.ts | 178 +++++++++++++++++- .../contracts/GlCreateBookingForm.tsx | 59 +++++- .../trainScheduling/PinWagonsForm.tsx | 4 +- .../InteractiveTrainConsist.tsx | 4 +- .../compositionEditor/WagonCard.tsx | 4 +- .../trainBuilder/TrainBuilderListPage.tsx | 14 +- .../TrainScheduleV2ListPage.tsx | 36 +++- .../backoffice/src/types/trainScheduling.ts | 7 + .../src/pages/contracts/NewShipmentPage.tsx | 65 ++++++- .../contracts/NewShipmentRequestPage.tsx | 28 ++- 12 files changed, 416 insertions(+), 37 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/maintenance-reschedule.dto.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/maintenance-reschedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/maintenance-reschedule.dto.ts new file mode 100644 index 000000000..d6b8ba10e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/maintenance-reschedule.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index e4efaab9a..bbb19dbee 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index f417f3dc2..b92a7ea90 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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 { + 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(); + 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: diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 9e7fa15ca..b7807f3cd 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -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(() => { @@ -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() { )) )} + + {hasOdd20ft ? ( + } + 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. + + ) : null} ) : ( @@ -1530,14 +1560,27 @@ export default function GlCreateBookingForm() { ) : null} - + {/* Mantine tooltips get no pointer events from a disabled button, + so the wrapper carries the hover target. */} + + + + diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx index 80f72d9d0..ecaa43161 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx @@ -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>({}); const wagonOptionsByType = useMemo(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index ee69fe145..8af9a6e34 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -359,7 +359,9 @@ function WagonCar({ {isEmpty ? ( - Empty slot — available for allocation. + {wagon.consistOnly + ? "Empty wagon — coupled on the train, no load planned." + : "Empty slot — available for allocation."} ) : ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx index 663ab2e6f..f735e6474 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx @@ -167,9 +167,9 @@ export const WagonCard = ({ - Empty slot + {wagon.consistOnly ? "Empty wagon — coupled on the train" : "Empty slot"} - {!isDispatched ? ( + {!isDispatched && !wagon.consistOnly ? ( + {/* Mantine tooltips get no pointer events from a disabled button, + so the wrapper carries the hover target. */} + + + + @@ -1265,6 +1292,28 @@ function CargoStep({ This contract has no container sizes in scope. )} + {(() => { + const ft20 = lines + .filter((l) => l.containerSize === "20ft") + .reduce((sum, l) => sum + Number(l.quantity || 0), 0); + if (ft20 % 2 !== 1) return null; + return ( + } + title={`Odd number of 20ft containers (${ft20})`} + > + + 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. + + + ); + })()} ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx index d4a562b24..c86791268 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx @@ -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 ? ( + } + title={`Odd number of 20ft containers (${ft20Requested})`} + > + + 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}). + + + ) : null} + {capacity?.length ? ( Remaining capacity is shown on the contract — GL will validate your request. @@ -220,6 +245,7 @@ export default function NewShipmentRequestPage() { leftSection={} loading={submit.isPending} onClick={handleSubmit} + disabled={hasOdd20ft} > Submit shipment request From c19e4260c10c2403857b59345baf1789f09bf094 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 16 Jul 2026 12:04:58 +0000 Subject: [PATCH 03/10] fix: revert the testing --- .../src/modules/billing/billing.service.ts | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 4d647e3ef..0f60876d4 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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( From e0abfddf10b66cdb50ec2527f130750cc7aa63fd Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 16 Jul 2026 15:36:02 +0300 Subject: [PATCH 04/10] Update payment-events.consumer.ts --- .../payments/payment-events.consumer.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts index a80c63468..777a2704b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts @@ -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( From 0142c94bb042029142dd0bcb7c55f8638bb64d2c Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 16 Jul 2026 15:44:47 +0300 Subject: [PATCH 05/10] Check payment before generating ticket --- .../src/app/booking/confirmation/page.tsx | 19 ++++++++++++++++++- .../portal/src/app/booking/detail/page.tsx | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index b10109c0f..c48e30177 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -68,7 +68,7 @@ export default function ConfirmationPage() { import("@/lib/generate-voucher"); }, []); - const { data: _booking } = useQuery({ + const { data: _booking, refetch: refetchBooking } = useQuery({ queryKey: ["booking", bookingId], queryFn: async (): Promise => { try { @@ -88,6 +88,23 @@ export default function ConfirmationPage() { enabled: !!bookingId, }); + // Poll the payment intent every 10 s while the booking is PENDING_PAYMENT. + // 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({ + queryKey: ["payment-intent-status", bookingId], + queryFn: () => apiClient.get(`/payments/intents/${bookingId}`), + enabled: _booking?.status === "PENDING_PAYMENT" && !!bookingId, + refetchInterval: 10_000, + }); + + 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 diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index a54ce795b..82d357a28 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -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({ + 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; From b50adc4666b649768506a3c39ea08a5e58b0c3ff Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 16 Jul 2026 12:59:28 +0000 Subject: [PATCH 06/10] fix issue --- .../modules/contracts/contracts.repository.ts | 30 +++++--- .../contracts/entities/contract.entity.ts | 8 ++ .../backoffice/src/constants/apiConfig.ts | 3 + .../contracts/ContractClearanceDetailPage.tsx | 75 ++++++++++++++++++- .../contracts/ContractClearanceListPage.tsx | 50 +++++++++++++ packages/types/src/freight/contracts.ts | 6 ++ 6 files changed, 160 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 3c9c7db14..533b51b3b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -204,17 +204,27 @@ export class ContractsRepository extends BaseRepository { private async attachClearancePhases(contracts: Contract[]): Promise { 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; } } diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index a526d632f..b5e0b8fb1 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -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 diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 842276815..dc52533da 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -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; } + + + diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 76666c320..05f5098a1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -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); @@ -158,7 +178,16 @@ export default function ContractClearanceDetailPage() { { label: reference }, ]} meta={ - bookingAlreadyCreated ? ( + bookingExpired ? ( + } + > + Payment expired — rebook + + ) : bookingAlreadyCreated ? ( : null} - {bookingAlreadyCreated ? ( + {bookingExpired ? ( + } + title="Booking expired — payment not received" + > + + + 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's + behalf without re-running clearance. + {linkedBookingId ? ( + <> + {" "} + + View expired booking → + + + ) : null} + + {canRebook ? ( + + ) : null} + + + ) : bookingAlreadyCreated ? ( + } + > + Payment expired + + + ); + } if (row.bookingCreated) { return ( @@ -519,6 +548,27 @@ export default function ContractClearanceListPage() { Create booking + ) : row.original.paymentExpired && canCreateBooking ? ( + + + ) : ( diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 28d503faf..9aa36374a 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -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"; From 8790e020eed36df51594a01fe8445fd4589f840a Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 16 Jul 2026 16:16:51 +0300 Subject: [PATCH 07/10] Fix loading message --- .../src/app/booking/confirmation/page.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index c48e30177..37c110329 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -68,7 +68,11 @@ export default function ConfirmationPage() { import("@/lib/generate-voucher"); }, []); - const { data: _booking, refetch: refetchBooking } = useQuery({ + const { + data: _booking, + refetch: refetchBooking, + isLoading: isBookingLoading, + } = useQuery({ queryKey: ["booking", bookingId], queryFn: async (): Promise => { try { @@ -271,6 +275,26 @@ 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). + if (isBookingLoading) { + return ( +
+
+
+
+

+ Please wait, we are processing your booking… +

+
+
+
+ ); + } + return (
From f9b87b9911ec763c9aa5bf5259ae89cec016e566 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 16 Jul 2026 16:41:36 +0300 Subject: [PATCH 08/10] Fix waiting time loading --- .../src/app/booking/confirmation/page.tsx | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 37c110329..d9253a93e 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -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 @@ -92,15 +113,20 @@ export default function ConfirmationPage() { enabled: !!bookingId, }); - // Poll the payment intent every 10 s while the booking is PENDING_PAYMENT. - // 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. + // 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({ queryKey: ["payment-intent-status", bookingId], queryFn: () => apiClient.get(`/payments/intents/${bookingId}`), enabled: _booking?.status === "PENDING_PAYMENT" && !!bookingId, - refetchInterval: 10_000, + refetchInterval: () => + Date.now() - mountTimeRef.current < CONFIRMATION_GRACE_PERIOD_MS + ? FAST_POLL_INTERVAL_MS + : SLOW_POLL_INTERVAL_MS, }); useEffect(() => { @@ -279,8 +305,11 @@ export default function ConfirmationPage() { // 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). - if (isBookingLoading) { + // 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 (
From fe5b258fcae9d7704f40300da57fadc1c29019aa Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 16 Jul 2026 17:02:23 +0300 Subject: [PATCH 09/10] Fix confirmation page pending payment --- .../portal/src/app/booking/confirmation/page.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index d9253a93e..66a7bf9c3 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -110,6 +110,14 @@ export default function ConfirmationPage() { }; } }, + // 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, }); @@ -123,6 +131,7 @@ export default function ConfirmationPage() { 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 From 6d524ce6be5a855b6c7601987b16446b6bc3be78 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 16 Jul 2026 17:27:32 +0300 Subject: [PATCH 10/10] Fix payment fallback redirect --- .../src/app/booking/confirmation/page.tsx | 47 +++++++++++++------ .../booking/payment/dmoney/success/page.tsx | 18 ++++--- .../booking/payment/telebirr/success/page.tsx | 18 ++++--- .../booking/payment/waafi/success/page.tsx | 18 ++++--- 4 files changed, 68 insertions(+), 33 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 66a7bf9c3..7d9cd691a 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -93,23 +93,16 @@ export default function ConfirmationPage() { data: _booking, refetch: refetchBooking, isLoading: isBookingLoading, + isError: isBookingError, } = useQuery({ queryKey: ["booking", bookingId], - queryFn: async (): Promise => { - 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 => 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 @@ -333,6 +326,30 @@ export default function ConfirmationPage() { ); } + // 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 ( +
+
+
+

+ We're having trouble loading your booking status right + now. This is usually temporary — tap below to try again. +

+ +
+
+
+ ); + } + return (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx index cf132bc7f..c63cea9aa 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx @@ -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 (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx index a47fdd07c..94d66121b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx @@ -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 (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx index 89a8b3098..a33eaec41 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx @@ -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 (