mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 & 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 & book
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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'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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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'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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user