diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f0beccd20..44be07550 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -172,7 +172,7 @@ jobs:
run: |
set -euo pipefail
IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}"
- docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
+ docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}"
# Tag with git SHA for rollback capability
CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1)
docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true
diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile
index 984e622db..3c05c8a9f 100644
--- a/apps/edr-freight-api/Dockerfile
+++ b/apps/edr-freight-api/Dockerfile
@@ -1,14 +1,12 @@
# syntax=docker/dockerfile:1
# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile .
+#
+# The base image (Node + Alpine Chromium/Puppeteer + pnpm) is built and pushed
+# separately — see Dockerfile.base. Override the pinned tag at build time with
+# --build-arg BASE_IMAGE=registry.license.aafda.gov.et/edr-public/freight-api-base:
+ARG BASE_IMAGE=registry.license.aafda.gov.et/edr-public/freight-api-base:node24-alpine
-FROM node:24.15.0-alpine AS base
-RUN apk add --no-cache libc6-compat
-# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
-# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
-ENV PNPM_HOME="/pnpm"
-ENV PATH="$PNPM_HOME:$PATH"
-RUN corepack enable
-WORKDIR /app
+FROM ${BASE_IMAGE} AS base
FROM base AS pruner
COPY . .
@@ -31,8 +29,8 @@ COPY --from=builder /app/ .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
-FROM node:24.15.0-alpine AS runner
-RUN apk add --no-cache libc6-compat
+FROM base AS runner
+
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs \
diff --git a/apps/edr-freight-api/Dockerfile.base b/apps/edr-freight-api/Dockerfile.base
new file mode 100644
index 000000000..9459958a2
--- /dev/null
+++ b/apps/edr-freight-api/Dockerfile.base
@@ -0,0 +1,41 @@
+# syntax=docker/dockerfile:1
+# Base image for edr-freight-api — Node + Alpine Chromium/Puppeteer + pnpm.
+# Built and pushed separately so app builds pull it from Harbor instead of
+# reinstalling the ~system Chromium toolchain on every build.
+#
+# Build + push (from monorepo root):
+# docker build -f apps/edr-freight-api/Dockerfile.base \
+# -t registry.license.aafda.gov.et/edr/freight-api-base:node24-alpine .
+# docker push registry.license.aafda.gov.et/edr/freight-api-base:node24-alpine
+#
+# Bump the tag whenever Node, Chromium, or the apk set below changes, then
+# update BASE_IMAGE in Dockerfile to match.
+
+FROM node:24.15.0-alpine
+
+# Puppeteer ships a glibc Chrome that cannot run on Alpine; skip the ~150MB
+# download at install time. This stage installs Alpine's system Chromium.
+ENV PUPPETEER_SKIP_DOWNLOAD=true
+# Chromium + fonts for Puppeteer PDF rendering (contract/invoice/receipt docs).
+# Without these, Puppeteer fails to launch and the code degrades to an
+# unformatted plain-text PDF fallback. Use Alpine's system Chromium (musl-built);
+# the glibc Chrome that `puppeteer install` downloads cannot run on Alpine.
+RUN apk add --no-cache \
+ libc6-compat \
+ chromium \
+ nss \
+ freetype \
+ harfbuzz \
+ ca-certificates \
+ ttf-freefont \
+ font-noto-cjk
+ENV NODE_ENV=production
+# Point Puppeteer at the system Chromium and skip its bundled download.
+ENV PUPPETEER_SKIP_DOWNLOAD=true
+ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
+# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
+# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
+ENV PNPM_HOME="/pnpm"
+ENV PATH="$PNPM_HOME:$PATH"
+RUN corepack enable
+WORKDIR /app
diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
index 559415fd8..a298b8dcb 100644
--- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
+++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
@@ -1,7 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ContractsRepository } from '../modules/contracts/contracts.repository';
-import { Contract } from '../modules/contracts/entities/contract.entity';
+import {
+ Contract,
+ ContractDocumentSnapshot,
+} from '../modules/contracts/entities/contract.entity';
import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
import {
ContractSignature,
@@ -11,7 +14,10 @@ import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.
import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service';
import { ContractTemplateResolver } from './contract-template.resolver';
import { getTemplateMeta } from './contract-template.registry';
-import { ContractViewModel } from './contract-view-model.builder';
+import {
+ ContractDynamicTemplateView,
+ ContractViewModel,
+} from './contract-view-model.builder';
/**
* Signature row for the contract PDF. Mirrors the booking builder's
@@ -90,22 +96,36 @@ export class ContractDocumentViewModelBuilder {
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
let template = getTemplateMeta(templateKey);
- // Prefer the admin-editable DB template matching the contract's
- // direction/freight pair; fall back to the code-defined generic layout
- // when none is active.
- const dynamicSource = await this.contractTemplates.findActiveForContract(
- contract.tradeDirection,
- contract.freightType,
- );
- const dynamicTemplate = dynamicSource
- ? {
- code: dynamicSource.code,
- name: dynamicSource.name,
- documentTitle: dynamicSource.documentTitle,
- whereasClauses: dynamicSource.whereasClauses ?? [],
- articles: dynamicSource.articles ?? [],
- }
- : undefined;
+ // The document articles come, in order of preference, from:
+ // 1. this contract's frozen snapshot (staff accepted / edited it) — the
+ // shared six templates are never consulted for these contracts;
+ // 2. the admin-editable DB template matching the direction/freight pair;
+ // 3. the code-defined generic layout (handled below when none of the above).
+ const snapshot = contract.documentSnapshot as ContractDocumentSnapshot | null;
+ let dynamicTemplate: ContractDynamicTemplateView | undefined;
+ if (snapshot && (snapshot.articles?.length ?? 0) > 0) {
+ dynamicTemplate = {
+ code: snapshot.code ?? 'CONTRACT',
+ name: snapshot.name ?? template.title,
+ documentTitle: snapshot.documentTitle ?? '',
+ whereasClauses: snapshot.whereasClauses ?? [],
+ articles: snapshot.articles,
+ };
+ } else {
+ const dynamicSource = await this.contractTemplates.findActiveForContract(
+ contract.tradeDirection,
+ contract.freightType,
+ );
+ dynamicTemplate = dynamicSource
+ ? {
+ code: dynamicSource.code,
+ name: dynamicSource.name,
+ documentTitle: dynamicSource.documentTitle,
+ whereasClauses: dynamicSource.whereasClauses ?? [],
+ articles: dynamicSource.articles ?? [],
+ }
+ : undefined;
+ }
if (dynamicTemplate) {
template = {
...template,
diff --git a/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts b/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts
new file mode 100644
index 000000000..5a667e2e4
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts
@@ -0,0 +1,28 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Drop the unused reopen-delay knob from the global rules.
+ *
+ * The window engine never honoured `reopen_delay_minutes`: a not-yet-full train
+ * reopens as soon as its payment phase settles, so the real gap between a cycle
+ * closing and reopening is doc review + payment — nothing else. The per-schedule
+ * `rule_reopen_delay_minutes` snapshot stays: it freezes that derived gap at
+ * creation so the batch board keeps projecting the cycles the customer was shown.
+ */
+export class DropReopenDelayMinutes2190000000000 implements MigrationInterface {
+ name = "DropReopenDelayMinutes2190000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.train_scheduling_global_rules
+ DROP COLUMN IF EXISTS reopen_delay_minutes;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.train_scheduling_global_rules
+ ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts b/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts
new file mode 100644
index 000000000..65a3d3cda
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts
@@ -0,0 +1,42 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Every built train owns a fixed pair of run numbers, typed at build time:
+ * an EXPORT number (odd, e.g. 8001) and an IMPORT number (even, e.g. 8002).
+ * Scheduling copies the route-direction-matched number onto the schedule at
+ * creation; legacy trains with a null pair keep dispatch-time pool assignment.
+ *
+ * NOTE: the shared dev DB has no applied migration history, so this is also
+ * hand-applied there. IF NOT EXISTS keeps that idempotent.
+ */
+export class TrainNumberPair2200000000000 implements MigrationInterface {
+ name = 'TrainNumberPair2200000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.trains
+ ADD COLUMN IF NOT EXISTS import_train_number varchar(20),
+ ADD COLUMN IF NOT EXISTS export_train_number varchar(20);
+ `);
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_import_train_number"
+ ON freight.trains (import_train_number)
+ WHERE import_train_number IS NOT NULL;
+ `);
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_export_train_number"
+ ON freight.trains (export_train_number)
+ WHERE export_train_number IS NOT NULL;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_export_train_number";`);
+ await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_import_train_number";`);
+ await queryRunner.query(`
+ ALTER TABLE freight.trains
+ DROP COLUMN IF EXISTS export_train_number,
+ DROP COLUMN IF EXISTS import_train_number;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts b/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts
new file mode 100644
index 000000000..42e8f1dc8
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts
@@ -0,0 +1,48 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Schedule-scoped wagon pins.
+ *
+ * Wagon occupancy now lives ONLY on each schedule's own train_set_wagons slots
+ * (the per-schedule snapshot): pinning/releasing a wagon no longer mutates the
+ * Wagon entity, so the same physical wagon can serve many schedules (the July 17
+ * and July 20 runs of one train both use its 50 wagons). The Wagon columns
+ * `current_train_schedule_id` / `train_set_wagon_id` keep only their physical
+ * meaning — "out on this DISPATCHED train right now" (stamped at dispatch,
+ * cleared at arrive/unload/cancel).
+ *
+ * This migration erases the legacy pin-time stamps left by the old flow: any
+ * wagon pointing at a schedule that is not currently DISPATCHED (or that no
+ * longer exists) gets its pointers cleared, and — when the old flow had parked
+ * it in ASSIGNED — its status returns to the pool semantics (ASSIGNED only
+ * while coupled to a built train, otherwise AVAILABLE).
+ */
+export class ScheduleScopedWagonPins2210000000000 implements MigrationInterface {
+ name = "ScheduleScopedWagonPins2210000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ UPDATE freight.wagons w
+ SET current_train_schedule_id = NULL,
+ train_set_wagon_id = NULL,
+ status = CASE
+ WHEN w.status = 'ASSIGNED' AND w.train_id IS NULL THEN 'AVAILABLE'
+ ELSE w.status
+ END
+ WHERE w.deleted_at IS NULL
+ AND w.current_train_schedule_id IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1
+ FROM freight.train_schedules ts
+ WHERE ts.id = w.current_train_schedule_id
+ AND ts.deleted_at IS NULL
+ AND ts.status = 'DISPATCHED'
+ );
+ `);
+ }
+
+ public async down(_queryRunner: QueryRunner): Promise {
+ // Pin-time stamps cannot be reconstructed (the data was the bug); the
+ // slots on train_set_wagons still hold every live pin, so down is a no-op.
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts b/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts
new file mode 100644
index 000000000..5a8cdd035
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts
@@ -0,0 +1,27 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Adds freight.contracts.document_snapshot — a per-contract frozen copy of the
+ * contract-document template (articles + WHEREAS recitals) captured at staff
+ * accept. Staff can edit these articles for a single contract before generating
+ * its PDF; the edit never touches the shared six freight.contract_templates
+ * rows. Null on existing contracts → the PDF keeps rendering from the live
+ * template, so this is backward compatible.
+ */
+export class AddContractDocumentSnapshot2220000000000
+ implements MigrationInterface
+{
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.contracts
+ ADD COLUMN IF NOT EXISTS document_snapshot JSONB;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.contracts
+ DROP COLUMN IF EXISTS document_snapshot;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts b/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts
new file mode 100644
index 000000000..2fe7c726d
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts
@@ -0,0 +1,24 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Wagon status RETIRED is renamed DETAINED (wagons pulled from circulation).
+ * The column is a plain varchar, so this is a data-only rename. Vehicles keep
+ * their own RETIRED status — only freight.wagons rows are touched.
+ */
+export class RenameWagonStatusRetiredToDetained2230000000000
+ implements MigrationInterface
+{
+ name = 'RenameWagonStatusRetiredToDetained2230000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ UPDATE freight.wagons SET status = 'DETAINED' WHERE status = 'RETIRED'
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ UPDATE freight.wagons SET status = 'RETIRED' WHERE status = 'DETAINED'
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts b/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts
new file mode 100644
index 000000000..76a59b0f7
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts
@@ -0,0 +1,24 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Every new wagon-transfer request must state WHY the wagons are needed; the
+ * reason is shown on the OCC request queue. Nullable in the DB — legacy rows
+ * predate the requirement; the DTO enforces it for new requests.
+ */
+export class AddTransferRequestReason2240000000000 implements MigrationInterface {
+ name = 'AddTransferRequestReason2240000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.wagon_transfer_requests
+ ADD COLUMN IF NOT EXISTS reason text NULL
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.wagon_transfer_requests
+ DROP COLUMN IF EXISTS reason
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts b/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts
new file mode 100644
index 000000000..f93fc7c95
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts
@@ -0,0 +1,42 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Approval workflow for priority-rule changes: every create/update/delete of a
+ * priority config is filed here as a PENDING change request; an approver
+ * applies or rejects it. `payload` carries the proposed field values (null for
+ * DELETE), `priority_config_id` the target row (null for CREATE).
+ */
+export class CreatePriorityRuleChangeRequests2250000000000
+ implements MigrationInterface
+{
+ name = 'CreatePriorityRuleChangeRequests2250000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.priority_rule_change_requests (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ action varchar(10) NOT NULL,
+ priority_config_id uuid NULL REFERENCES freight.priority_configs (id),
+ payload jsonb NULL,
+ status varchar(10) NOT NULL DEFAULT 'PENDING',
+ requested_by_user_id uuid NULL,
+ decided_by_user_id uuid NULL,
+ decided_at timestamptz NULL,
+ decision_note text NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz NULL
+ )
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_prcr_status
+ ON freight.priority_rule_change_requests (status)
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `DROP TABLE IF EXISTS freight.priority_rule_change_requests`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
index 7b1522446..698759901 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -1003,18 +1003,26 @@ export class BookingTransitionService {
}
// The binding shipment day must have at least one OPEN departure on the
- // route — only schedule-backed days are selectable. The batch engine
- // assigns the specific train within that (route, day) pool later.
- const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
- booking.originYardId,
- booking.destinationYardId,
- eatDay(date),
- );
+ // route — only schedule-backed days are selectable — AND some departure
+ // that day must be able to physically carry this cargo type (wagon-TYPE
+ // gate; quantity never blocks — oversized bookings get a partial split
+ // offer). The batch engine assigns the specific train within that
+ // (route, day) pool later.
+ const { hasDeparture, hasCompatible } =
+ await this.bookingsService.checkDayCompatibilityForBooking(
+ booking,
+ eatDay(date),
+ );
if (!hasDeparture) {
throw new BadRequestException(
"No departures available on the selected day for this route",
);
}
+ if (!hasCompatible) {
+ throw new BadRequestException(
+ "No wagon on the selected day can carry this cargo type — please choose another day",
+ );
+ }
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
index f556751fb..53554b2e3 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
@@ -348,6 +348,28 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
+ @Get(':id/available-days')
+ @ApiOperation({
+ summary:
+ 'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
+ })
+ async availableDays(
+ @Param('id', ParseUUIDPipe) id: string,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ const booking = await this.bookingsService.findById(id);
+ if (
+ !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
+ !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
+ ) {
+ await this.bookingsService.assertCustomerCanAccessBooking(
+ user?.id,
+ booking,
+ );
+ }
+ return this.bookingsService.availableDaysForBooking(id);
+ }
+
@Get(':id/mile-summary')
@ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)',
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index dd5011df3..4aade3bf2 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -653,22 +653,37 @@ export class BookingsService {
} else if (dto.scheduledDate) {
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
// directly). Require that the route has at least one OPEN departure on
- // that EAT day. The booking wizard does NOT send scheduledDate at creation
- // — it captures a non-binding estimatedShipmentDate instead, and the
- // binding day is chosen later at the operation-request step. General
- // contracts also skip this (each drawdown order validates its own day).
+ // that EAT day AND that some departure that day can physically carry the
+ // cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get
+ // a partial split offer later). The booking wizard does NOT send
+ // scheduledDate at creation — it captures a non-binding
+ // estimatedShipmentDate instead, and the binding day is chosen later at
+ // the operation-request step. General contracts also skip this (each
+ // drawdown order validates its own day).
const day = eatDay(new Date(dto.scheduledDate));
- const hasDeparture =
- await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
+ const { hasDeparture, hasCompatible } =
+ await this.trainSchedulingService.checkDayCargoCompatibility(
dto.originYardId,
dto.destinationYardId,
day,
+ {
+ freightType: dto.freightType as 'CONTAINER' | 'BULK',
+ cargoTypeId: dto.cargoTypeId,
+ containerTypeIds: (dto.containers ?? [])
+ .map((c) => c.containerTypeId)
+ .filter((id): id is string => Boolean(id)),
+ },
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
+ if (!hasCompatible) {
+ throw new BadRequestException(
+ 'No wagon on the selected day can carry this cargo type — please choose another day',
+ );
+ }
}
const containers = dto.containers ?? [];
@@ -1149,6 +1164,52 @@ export class BookingsService {
);
}
+ /** Cargo identity of a booking for the wagon-TYPE compatibility gate. */
+ private cargoIdentityOf(booking: Booking): {
+ freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
+ containerTypeIds?: string[];
+ } {
+ return {
+ freightType: booking.freightType as 'CONTAINER' | 'BULK',
+ cargoTypeId: booking.cargoTypeId ?? null,
+ containerTypeIds: (booking.bookingContainers ?? [])
+ .map((line) => line.containerTypeId)
+ .filter((id): id is string => Boolean(id)),
+ };
+ }
+
+ /**
+ * Day gate for a specific booking: OPEN departure exists AND some departure
+ * that day can physically carry the booking's cargo/container type.
+ * Quantity never blocks — oversized bookings get a partial split offer.
+ */
+ async checkDayCompatibilityForBooking(
+ booking: Booking,
+ day: string,
+ ): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
+ return this.trainSchedulingService.checkDayCargoCompatibility(
+ booking.originYardId,
+ booking.destinationYardId,
+ day,
+ this.cargoIdentityOf(booking),
+ );
+ }
+
+ /**
+ * Days the customer may pick for THIS booking (operation-request step):
+ * cargo-aware — only days whose departures can carry the booking's cargo
+ * type. Returns days only, no capacity counts.
+ */
+ async availableDaysForBooking(bookingId: string): Promise<{ days: string[] }> {
+ const booking = await this.findById(bookingId);
+ return this.trainSchedulingService.getAvailableDaysForCargo({
+ originYardId: booking.originYardId,
+ destinationYardId: booking.destinationYardId,
+ ...this.cargoIdentityOf(booking),
+ });
+ }
+
/**
* Batched version of the findById flag: marks each page item whose booking
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
index 4cc15854e..79d15069b 100644
--- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
@@ -86,6 +86,7 @@ export const SCHEDULING_STATUSES = [
SchedulingStatus.Eligible,
SchedulingStatus.Scheduled,
SchedulingStatus.Dispatched,
+ SchedulingStatus.WaitingForWagon,
] as const;
export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number];
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
index 4da2677bd..049db2a94 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
@@ -4,6 +4,7 @@ import {
Injectable,
Logger,
} from '@nestjs/common';
+import { randomUUID } from 'node:crypto';
import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
@@ -21,16 +22,35 @@ import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.
import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service';
+import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
import { contractClearanceSettingCode } from './contract-clearance.util';
-import { Contract } from './entities/contract.entity';
+import {
+ Contract,
+ ContractDocumentArticle,
+ ContractDocumentSnapshot,
+ ContractDocumentSnapshotInput,
+} from './entities/contract.entity';
import { ContractSignerRole } from './entities/contract-signature.entity';
import { SignContractDto } from './dto/sign-contract.dto';
+/** The editable contract-document draft returned for the accept/edit dialog. */
+export interface ContractDocumentDraft {
+ documentTitle: string | null;
+ whereasClauses: string[];
+ articles: ContractDocumentArticle[];
+ code: string | null;
+ name: string | null;
+ /** True once the document may no longer be edited/regenerated. */
+ locked: boolean;
+ generatedAt: Date | null;
+ status: string;
+}
+
/**
* Dropdown-settings code holding the admin-configured contract validity options
* (each option's `value` is a day count). The staff accept dialog reads the same
@@ -68,6 +88,7 @@ export class ContractTransitionService {
private readonly minioService: MinioService,
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
+ private readonly contractTemplates: ContractTemplatesService,
) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -110,6 +131,7 @@ export class ContractTransitionService {
contractId: string,
actorId: string,
validityDays: number,
+ documentSnapshot?: ContractDocumentSnapshotInput | null,
): Promise {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SUBMITTED']);
@@ -128,6 +150,12 @@ export class ContractTransitionService {
await this.instantiateApprovalSteps(contract);
+ // Freeze the contract document for THIS contract only. Staff may have edited
+ // the articles in the accept dialog; otherwise the live template is captured
+ // as-is so later template edits never change an in-flight contract. The
+ // shared six templates are never written here.
+ const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
+
await this.contractsRepository.update(contractId, {
status: 'PENDING_APPROVAL',
approvedByStaffId: actorId,
@@ -135,12 +163,148 @@ export class ContractTransitionService {
contractValidityDays: validityDays,
contractValidFrom: validFrom,
contractValidUntil: validUntil,
+ documentSnapshot: snapshot,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.accepted(updated);
return updated;
}
+ // ── Per-contract document snapshot (US: edit articles for one contract) ─────
+
+ /**
+ * The editable document draft for the accept/edit dialog: the frozen snapshot
+ * if one exists, else the live active template resolved for this contract's
+ * direction/freight pair. `locked` flips true once the document may no longer
+ * be edited (an approver has acted, or the contract has left the pre-approval
+ * window).
+ */
+ async getContractDocumentDraft(
+ contractId: string,
+ ): Promise {
+ const contract = await this.contractsService.findById(contractId);
+ const snapshot =
+ (contract.documentSnapshot as ContractDocumentSnapshot | null) ??
+ (await this.resolveDocumentSnapshot(contract));
+ return {
+ documentTitle: snapshot?.documentTitle ?? null,
+ whereasClauses: snapshot?.whereasClauses ?? [],
+ articles: snapshot?.articles ?? [],
+ code: snapshot?.code ?? null,
+ name: snapshot?.name ?? null,
+ locked: !this.documentIsEditable(contract),
+ generatedAt: contract.contractGeneratedAt ?? null,
+ status: contract.status,
+ };
+ }
+
+ /**
+ * Replace this contract's document articles from the editor. Per-contract
+ * only — it writes the contract's own snapshot and never the shared templates.
+ * Allowed while the document is still editable (PENDING_APPROVAL, no approver
+ * has acted).
+ */
+ async updateContractDocument(
+ contractId: string,
+ input: ContractDocumentSnapshotInput,
+ ): Promise {
+ const contract = await this.contractsService.findById(contractId);
+ assertContractStatus(contract, ['PENDING_APPROVAL']);
+ this.assertDocumentEditable(contract);
+
+ const current =
+ (contract.documentSnapshot as ContractDocumentSnapshot | null) ??
+ (await this.resolveDocumentSnapshot(contract));
+ const merged: ContractDocumentSnapshotInput = {
+ code: current?.code ?? null,
+ name: input.name ?? current?.name ?? null,
+ documentTitle: input.documentTitle ?? current?.documentTitle ?? null,
+ whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
+ articles: input.articles ?? current?.articles ?? [],
+ };
+ await this.contractsRepository.update(contractId, {
+ documentSnapshot: this.normalizeSnapshot(merged),
+ } as never);
+ return this.contractsService.findById(contractId);
+ }
+
+ /**
+ * Build the per-contract document snapshot. Prefer the staff's edited articles
+ * from the dialog; otherwise freeze the active template matching the
+ * contract's direction/freight. Returns null when no active template exists
+ * (the renderer then falls back to the built-in generic layout at render time).
+ */
+ private async resolveDocumentSnapshot(
+ contract: Contract,
+ provided?: ContractDocumentSnapshotInput | null,
+ ): Promise {
+ if (provided && (provided.articles?.length ?? 0) > 0) {
+ return this.normalizeSnapshot(provided);
+ }
+ const active = await this.contractTemplates.findActiveForContract(
+ contract.tradeDirection,
+ contract.freightType,
+ );
+ if (!active) return null;
+ return {
+ code: active.code,
+ name: active.name,
+ documentTitle: active.documentTitle,
+ whereasClauses: active.whereasClauses ?? [],
+ articles: this.normalizeArticles(active.articles ?? []),
+ };
+ }
+
+ private normalizeSnapshot(
+ input: ContractDocumentSnapshotInput,
+ ): ContractDocumentSnapshot {
+ return {
+ code: input.code ?? null,
+ name: input.name ?? null,
+ documentTitle: input.documentTitle ?? null,
+ whereasClauses: Array.isArray(input.whereasClauses)
+ ? input.whereasClauses
+ .map((c) => String(c))
+ .filter((c) => c.trim().length > 0)
+ : [],
+ articles: this.normalizeArticles(input.articles ?? []),
+ };
+ }
+
+ /** Re-key ids and renumber order sequentially, dropping empty-title rows. */
+ private normalizeArticles(
+ articles: Array<{ id?: string; title?: string; body?: string; order?: number }>,
+ ): ContractDocumentArticle[] {
+ return articles
+ .filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0)
+ .map((a, index) => ({
+ id: a.id ?? randomUUID(),
+ title: (a.title ?? '').trim(),
+ body: a.body ?? '',
+ order: index + 1,
+ }));
+ }
+
+ /**
+ * The per-contract document may be edited/regenerated while the contract is at
+ * the accept stage (SUBMITTED) or in approval with NO approver having acted
+ * yet. The first approval action freezes it.
+ */
+ private documentIsEditable(contract: Contract): boolean {
+ if (contract.status === 'SUBMITTED') return true;
+ if (contract.status !== 'PENDING_APPROVAL') return false;
+ return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
+ }
+
+ private assertDocumentEditable(contract: Contract): void {
+ if (!this.documentIsEditable(contract)) {
+ throw new ConflictException(
+ 'The contract document is locked — an approver has already acted or the ' +
+ 'contract has advanced. It can no longer be edited or regenerated.',
+ );
+ }
+ }
+
/**
* Ensure the chosen validity (days) is one of the admin-configured options in
* the `contract_validity_periods` dropdown setting. If the setting is missing
@@ -303,6 +467,15 @@ export class ContractTransitionService {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
+ // Approvers review the generated contract document, so it must exist before
+ // the first approval can be recorded. Staff generate it (from the frozen,
+ // optionally-edited snapshot) at the accept stage.
+ if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) {
+ throw new BadRequestException(
+ 'Generate the contract document before it can be approved.',
+ );
+ }
+
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
@@ -350,15 +523,14 @@ export class ContractTransitionService {
const updated = await this.contractsService.findById(contractId);
if (allDone) {
this.notifier.approved(updated);
- // Final approval step also generates the contract document from the
- // template matching the contract's direction/freight pair. Best-effort:
- // a rendering hiccup must not roll back the approval — the document can
- // still be generated manually or lazily on view/download.
+ // Every step approved → CONTRACT_READY. The document was already generated
+ // (and reviewed) at the accept stage, so we reuse it rather than
+ // re-rendering. Best-effort: a hiccup must not roll back the approval.
try {
- return await this.generateContract(contractId);
+ return await this.finalizeApprovedContract(contractId);
} catch (err) {
this.logger.warn(
- `Auto contract generation after final approval failed for ${updated.reference}: ${err}`,
+ `Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
);
}
}
@@ -366,30 +538,66 @@ export class ContractTransitionService {
}
/**
- * Render the contract PDF from the Contract aggregate, store it via FilesService,
- * stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/
- * Chromium) is best-effort and must NOT block the contract from becoming ready —
- * the document is (re)rendered lazily on view/download once Chromium is available.
+ * Staff (re)generate the contract PDF. Two stages:
+ * - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
+ * snapshot so approvers review the real document. Status is UNCHANGED, and
+ * it is blocked once an approver has acted (the document is then locked).
+ * - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to
+ * CONTRACT_READY.
+ * PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the
+ * transition — the document re-renders lazily on view/download.
*/
async generateContract(contractId: string): Promise {
const contract = await this.contractsService.findById(contractId);
+
+ if (contract.status === 'PENDING_APPROVAL') {
+ this.assertDocumentEditable(contract);
+ await this.renderContractDocument(contract);
+ return this.contractsService.findById(contractId);
+ }
+
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
+ await this.renderContractDocument(contract);
+ await this.contractsRepository.update(contractId, {
+ status: 'CONTRACT_READY',
+ } as never);
+ return this.contractsService.findById(contractId);
+ }
- const { view } = await this.documentViewModelBuilder.build(contractId);
-
+ /**
+ * Render the contract PDF from the Contract aggregate (snapshot-driven), store
+ * it via FilesService, and stamp the template key + generated timestamp. Never
+ * changes status. Rendering is best-effort — a Chromium hiccup defers the file
+ * (it re-renders on view/download) but the timestamp is still stamped.
+ */
+ private async renderContractDocument(contract: Contract): Promise {
+ const { view } = await this.documentViewModelBuilder.build(contract.id);
try {
- await this.upsertContractPdf(contractId, contract.reference, view);
+ await this.upsertContractPdf(contract.id, contract.reference, view);
} catch (err) {
this.logger.warn(
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
);
}
-
- await this.contractsRepository.update(contractId, {
- status: 'CONTRACT_READY',
+ await this.contractsRepository.update(contract.id, {
contractTemplateKey: view.templateKey,
contractGeneratedAt: new Date(),
} as never);
+ }
+
+ /**
+ * Every approval step landed → CONTRACT_READY. The document was already
+ * generated (and reviewed) at the accept stage, so reuse it; render now only
+ * if it was somehow never generated. Never re-renders over an existing file.
+ */
+ private async finalizeApprovedContract(contractId: string): Promise {
+ const contract = await this.contractsService.findById(contractId);
+ if (!contract.contractGeneratedAt) {
+ await this.renderContractDocument(contract);
+ }
+ await this.contractsRepository.update(contractId, {
+ status: 'CONTRACT_READY',
+ } as never);
return this.contractsService.findById(contractId);
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
index 2b79d3274..2957124f8 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
@@ -8,6 +8,7 @@ import {
ParseUUIDPipe,
Patch,
Post,
+ Put,
Query,
Res,
UnauthorizedException,
@@ -57,6 +58,7 @@ import { UpdateContractDto } from './dto/update-contract.dto';
import { FilterContractDto } from './dto/filter-contract.dto';
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { AcceptContractDto } from './dto/accept-contract.dto';
+import { UpdateContractDocumentDto } from './dto/contract-document.dto';
import {
ApproveStepDto,
RejectContractDto,
@@ -340,9 +342,33 @@ export class ContractsController {
id,
resolveAuthUserId(user),
dto.validityDays,
+ dto.documentSnapshot,
);
}
+ @Get(':id/document/draft')
+ @BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
+ @ApiOperation({
+ summary:
+ 'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
+ })
+ getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) {
+ return this.transitionService.getContractDocumentDraft(id);
+ }
+
+ @Put(':id/document/articles')
+ @BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
+ @ApiOperation({
+ summary:
+ 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
+ })
+ updateContractDocument(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: UpdateContractDocumentDto,
+ ) {
+ return this.transitionService.updateContractDocument(id, dto);
+ }
+
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
@ApiOperation({ summary: 'Staff return contract for customer updates' })
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts
index 86e1260c4..d3eaa73a3 100644
--- a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts
+++ b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts
@@ -1,5 +1,8 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { IsInt, Max, Min } from 'class-validator';
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator';
+
+import { UpdateContractDocumentDto } from './contract-document.dto';
export class AcceptContractDto {
@ApiProperty({
@@ -14,4 +17,16 @@ export class AcceptContractDto {
@Min(1)
@Max(3650)
validityDays!: number;
+
+ /**
+ * Optional per-contract document override edited by staff in the accept
+ * dialog. When present its articles are frozen onto THIS contract; when
+ * omitted the live template is snapshotted as-is. Never edits the shared
+ * six templates.
+ */
+ @ApiPropertyOptional({ type: UpdateContractDocumentDto })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => UpdateContractDocumentDto)
+ documentSnapshot?: UpdateContractDocumentDto;
}
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts
new file mode 100644
index 000000000..7fdb8477e
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts
@@ -0,0 +1,64 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import {
+ IsArray,
+ IsInt,
+ IsOptional,
+ IsString,
+ ValidateNested,
+} from 'class-validator';
+
+/** One article of a per-contract document override sent from the editor. */
+export class ContractDocumentArticleDto {
+ @ApiPropertyOptional({ description: 'Stable id; omitted for a new article.' })
+ @IsOptional()
+ @IsString()
+ id?: string;
+
+ @ApiProperty()
+ @IsString()
+ title!: string;
+
+ @ApiProperty({ description: 'Plain multiline body; each line becomes a clause.' })
+ @IsString()
+ body!: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsInt()
+ order?: number;
+}
+
+/**
+ * The per-contract document override sent from the accept/edit editor. It edits
+ * ONLY this contract's frozen snapshot — it is never written back to the shared
+ * six {@link ContractTemplate} rows.
+ */
+export class UpdateContractDocumentDto {
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ code?: string | null;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ name?: string | null;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ documentTitle?: string | null;
+
+ @ApiPropertyOptional({ type: [String] })
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ whereasClauses?: string[];
+
+ @ApiProperty({ type: [ContractDocumentArticleDto] })
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => ContractDocumentArticleDto)
+ articles!: ContractDocumentArticleDto[];
+}
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 0b0fab41b..f29b9093b 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
@@ -42,6 +42,43 @@ export const CONTRACT_STATUSES = [
export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
+/** One article on a per-contract document snapshot (mirrors the template shape). */
+export interface ContractDocumentArticle {
+ id: string;
+ title: string;
+ body: string;
+ order: number;
+}
+
+/**
+ * A per-contract copy of the resolved contract-document template, frozen when
+ * staff accept the contract for approval. Staff may edit these articles for a
+ * single contract in the accept/edit dialog — editing NEVER writes back to the
+ * shared six {@link ContractTemplate} rows. The PDF is rendered from this
+ * snapshot when present; a null snapshot renders from the live template.
+ */
+export interface ContractDocumentSnapshot {
+ code?: string | null;
+ name?: string | null;
+ documentTitle?: string | null;
+ whereasClauses: string[];
+ articles: ContractDocumentArticle[];
+}
+
+/** Loose inbound shape (article ids/order optional) — normalized before store. */
+export interface ContractDocumentSnapshotInput {
+ code?: string | null;
+ name?: string | null;
+ documentTitle?: string | null;
+ whereasClauses?: string[];
+ articles?: Array<{
+ id?: string;
+ title?: string;
+ body?: string;
+ order?: number;
+ }>;
+}
+
export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const;
export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
@@ -193,6 +230,14 @@ export class Contract extends BaseEntity {
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
contractGeneratedAt?: Date | null;
+ /**
+ * Per-contract frozen copy of the document template (articles + WHEREAS),
+ * captured at staff accept. Editing it affects only this contract, never the
+ * shared six templates. Null → the PDF renders from the live template.
+ */
+ @Column({ name: 'document_snapshot', type: 'jsonb', nullable: true })
+ documentSnapshot?: ContractDocumentSnapshot | null;
+
@Column({ name: 'contract_summary', type: 'text', nullable: true })
contractSummary?: string | null;
diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts
new file mode 100644
index 000000000..73ff94d31
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts
@@ -0,0 +1,72 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Param,
+ ParseUUIDPipe,
+ Post,
+ Query,
+} from '@nestjs/common';
+import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
+import { CurrentUser } from '@edr/api-common';
+import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
+
+import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
+import {
+ DecidePriorityRuleChangeDto,
+ SubmitPriorityRuleChangeDto,
+} from '../dto/priority-rule-change-request.dto';
+import { PriorityRuleChangeStatus } from '../entities/priority-rule-change-request.entity';
+import { PriorityRuleChangeRequestsService } from '../services/priority-rule-change-requests.service';
+
+/**
+ * Approval workflow for priority-rule changes. Anyone with the manage
+ * permission SUBMITS a change; an approver (same permission — the team decides
+ * who reviews) approves or rejects it. The team is notified at each step.
+ */
+@ApiTags('priority-rule-change-requests')
+@Controller('priority-rule-change-requests')
+@ApiBearerAuth()
+export class PriorityRuleChangeRequestsController {
+ constructor(private readonly service: PriorityRuleChangeRequestsService) {}
+
+ @Post()
+ @RuleEngineManage('priority-configs')
+ @ApiOperation({ summary: 'Submit a priority-rule change for approval' })
+ submit(
+ @Body() dto: SubmitPriorityRuleChangeDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.service.submit(dto, user?.id);
+ }
+
+ @Get()
+ @RuleEngineView('priority-configs')
+ @ApiQuery({ name: 'status', required: false, enum: ['PENDING', 'APPROVED', 'REJECTED'] })
+ @ApiOperation({ summary: 'List priority-rule change requests' })
+ list(@Query('status') status?: PriorityRuleChangeStatus) {
+ return this.service.list(status);
+ }
+
+ @Post(':id/approve')
+ @RuleEngineManage('priority-configs')
+ @ApiOperation({ summary: 'Approve and apply a pending change' })
+ approve(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: DecidePriorityRuleChangeDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.service.approve(id, user?.id, dto.decisionNote);
+ }
+
+ @Post(':id/reject')
+ @RuleEngineManage('priority-configs')
+ @ApiOperation({ summary: 'Reject a pending change' })
+ reject(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: DecidePriorityRuleChangeDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.service.reject(id, user?.id, dto.decisionNote);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts
new file mode 100644
index 000000000..31295b050
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts
@@ -0,0 +1,49 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import {
+ IsIn,
+ IsOptional,
+ IsString,
+ IsUUID,
+ MaxLength,
+ ValidateNested,
+} from 'class-validator';
+
+import { CreatePriorityConfigDto } from './create-priority-config.dto';
+import { UpdatePriorityConfigDto } from './update-priority-config.dto';
+
+/**
+ * File a priority-rule change for approval. CREATE carries a full `create`
+ * payload; UPDATE carries the target id + an `update` patch; DELETE carries
+ * only the target id.
+ */
+export class SubmitPriorityRuleChangeDto {
+ @ApiProperty({ enum: ['CREATE', 'UPDATE', 'DELETE'] })
+ @IsIn(['CREATE', 'UPDATE', 'DELETE'])
+ action!: 'CREATE' | 'UPDATE' | 'DELETE';
+
+ @ApiPropertyOptional({ description: 'Target rule id (UPDATE / DELETE)' })
+ @IsOptional()
+ @IsUUID()
+ priorityConfigId?: string;
+
+ @ApiPropertyOptional({ description: 'Proposed new rule (CREATE)' })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => CreatePriorityConfigDto)
+ create?: CreatePriorityConfigDto;
+
+ @ApiPropertyOptional({ description: 'Proposed field changes (UPDATE)' })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => UpdatePriorityConfigDto)
+ update?: UpdatePriorityConfigDto;
+}
+
+export class DecidePriorityRuleChangeDto {
+ @ApiPropertyOptional({ description: 'Optional note shown to the requester' })
+ @IsOptional()
+ @IsString()
+ @MaxLength(1000)
+ decisionNote?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts
new file mode 100644
index 000000000..ec2425745
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts
@@ -0,0 +1,46 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
+
+import { PriorityConfig } from './priority-config.entity';
+
+export type PriorityRuleChangeAction = 'CREATE' | 'UPDATE' | 'DELETE';
+export type PriorityRuleChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
+
+/**
+ * One proposed change to a priority rule, awaiting approval. Every
+ * create/update/delete of a priority config is filed here first; an approver
+ * applies (which runs the real mutation, including range-collision checks) or
+ * rejects it. `payload` holds the proposed field values (null for DELETE);
+ * `priorityConfigId` the target rule (null for CREATE).
+ */
+@Entity({ schema: 'freight', name: 'priority_rule_change_requests' })
+@Index(['status'])
+export class PriorityRuleChangeRequest extends BaseEntity {
+ @Column({ name: 'action', type: 'varchar', length: 10 })
+ action!: PriorityRuleChangeAction;
+
+ @Column({ name: 'priority_config_id', type: 'uuid', nullable: true })
+ priorityConfigId?: string | null;
+
+ @ManyToOne(() => PriorityConfig, { nullable: true })
+ @JoinColumn({ name: 'priority_config_id' })
+ priorityConfig?: PriorityConfig | null;
+
+ @Column({ name: 'payload', type: 'jsonb', nullable: true })
+ payload?: Record | null;
+
+ @Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' })
+ status!: PriorityRuleChangeStatus;
+
+ @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
+ requestedByUserId?: string | null;
+
+ @Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true })
+ decidedByUserId?: string | null;
+
+ @Column({ name: 'decided_at', type: 'timestamptz', nullable: true })
+ decidedAt?: Date | null;
+
+ @Column({ name: 'decision_note', type: 'text', nullable: true })
+ decisionNote?: string | null;
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts
index 34dc9f982..7edcf0bbf 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts
@@ -5,6 +5,7 @@ import { ApprovalRulesController } from './controllers/approval-rules.controller
import { CargoTypesController } from './controllers/cargo-types.controller';
import { ContainerTypesController } from './controllers/container-types.controller';
import { PriorityConfigsController } from './controllers/priority-configs.controller';
+import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller';
import { RatesController } from './controllers/rates.controller';
import { ServiceTypesController } from './controllers/service-types.controller';
import { ShippingLinesController } from './controllers/shipping-lines.controller';
@@ -15,6 +16,7 @@ import { ApprovalRule } from './entities/approval-rule.entity';
import { CargoType } from './entities/cargo-type.entity';
import { ContainerType } from './entities/container-type.entity';
import { PriorityConfig } from './entities/priority-config.entity';
+import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity';
import { Rate } from './entities/rate.entity';
import { ServiceType } from './entities/service-type.entity';
import { ShippingLine } from './entities/shipping-line.entity';
@@ -46,6 +48,7 @@ import { DisplayOrderService } from './services/display-order.service';
import { CargoTypesService } from './services/cargo-types.service';
import { ContainerTypesService } from './services/container-types.service';
import { PriorityConfigsService } from './services/priority-configs.service';
+import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service';
import { RatesService } from './services/rates.service';
import { ServiceTypesService } from './services/service-types.service';
import { ShippingLinesService } from './services/shipping-lines.service';
@@ -54,6 +57,8 @@ import { YardsService } from './services/yards.service';
import { RuleEngineService } from './rule-engine.service';
+import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
+
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -66,6 +71,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoType,
ContainerType,
PriorityConfig,
+ PriorityRuleChangeRequest,
ServiceType,
WeightLimitRule,
Yard,
@@ -77,11 +83,14 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
BookingApprovalStep,
BookingRateSnapshot,
]),
+ // Team notifications for the priority-rule approval workflow.
+ NotificationInboxModule,
],
controllers: [
CargoTypesController,
ContainerTypesController,
PriorityConfigsController,
+ PriorityRuleChangeRequestsController,
ServiceTypesController,
WeightLimitRulesController,
YardsController,
@@ -111,6 +120,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoTypesService,
ContainerTypesService,
PriorityConfigsService,
+ PriorityRuleChangeRequestsService,
ServiceTypesService,
WeightLimitRulesService,
YardsService,
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts
index 560a550b2..9aaf06985 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts
@@ -31,6 +31,12 @@ export class PriorityConfigsService {
async create(dto: CreatePriorityConfigDto): Promise {
this.validateCurrencyField(dto.type, dto.currency);
+ await this.assertNoRangeCollision({
+ type: dto.type,
+ currency: dto.currency ?? null,
+ minWagonCount: dto.minWagonCount,
+ maxWagonCount: dto.maxWagonCount,
+ });
const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {});
@@ -52,6 +58,13 @@ export class PriorityConfigsService {
const type = dto.type ?? existing.type;
const currency = dto.currency !== undefined ? dto.currency : existing.currency;
this.validateCurrencyField(type, currency);
+ await this.assertNoRangeCollision({
+ type,
+ currency: currency ?? null,
+ minWagonCount: dto.minWagonCount ?? existing.minWagonCount,
+ maxWagonCount: dto.maxWagonCount ?? existing.maxWagonCount,
+ excludeId: id,
+ });
const { ...patch } = dto;
const updated = await this.repository.update(id, patch);
@@ -59,6 +72,43 @@ export class PriorityConfigsService {
return updated;
}
+ /**
+ * No two rules of the same type (and, for CURRENCY rules, the same currency)
+ * may cover overlapping wagon-count ranges — a booking must match at most one
+ * rule per type. Rejects an exact duplicate (1–5 vs 1–5) and any partial
+ * overlap (1–5 vs 4–7). Ranges are inclusive on both ends.
+ */
+ async assertNoRangeCollision(input: {
+ type: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
+ currency?: string | null;
+ minWagonCount: number;
+ maxWagonCount: number;
+ excludeId?: string;
+ }): Promise {
+ if (input.minWagonCount > input.maxWagonCount) {
+ throw new BadRequestException(
+ 'Min wagon count cannot be greater than max wagon count',
+ );
+ }
+ const siblings = await this.repository.findAll({
+ where: { type: input.type },
+ });
+ const clash = siblings.find(
+ (s) =>
+ s.id !== input.excludeId &&
+ (input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) &&
+ input.minWagonCount <= s.maxWagonCount &&
+ input.maxWagonCount >= s.minWagonCount,
+ );
+ if (clash) {
+ throw new BadRequestException(
+ `Wagon range ${input.minWagonCount}–${input.maxWagonCount} overlaps existing rule ` +
+ `"${clash.label}" (${clash.minWagonCount}–${clash.maxWagonCount}). ` +
+ 'Adjust the range so rules do not collide.',
+ );
+ }
+ }
+
async remove(id: string): Promise {
await this.findById(id);
await this.repository.softDelete(id);
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts
new file mode 100644
index 000000000..bdc6a2e8f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts
@@ -0,0 +1,226 @@
+import {
+ NotificationAudience,
+ NotificationType,
+} from '@edr/types';
+import {
+ BadRequestException,
+ ConflictException,
+ Injectable,
+ Logger,
+ NotFoundException,
+} from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service';
+import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
+import { SubmitPriorityRuleChangeDto } from '../dto/priority-rule-change-request.dto';
+import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
+import {
+ PriorityRuleChangeRequest,
+ PriorityRuleChangeStatus,
+} from '../entities/priority-rule-change-request.entity';
+import { PriorityConfigsService } from './priority-configs.service';
+
+/** Backoffice rule-engine page — where both queue and rules live. */
+const RULES_LINK = '/dashboard/rules/priority-configs';
+
+/**
+ * Approval workflow for priority-rule changes. Nobody mutates priority configs
+ * directly any more: a change is SUBMITTED here (validated up front so the
+ * requester gets immediate feedback on range collisions), the team is
+ * notified, and an approver later applies or rejects it. Applying re-runs the
+ * full validation — the winning state is whatever is true at approval time.
+ */
+@Injectable()
+export class PriorityRuleChangeRequestsService {
+ private readonly logger = new Logger(PriorityRuleChangeRequestsService.name);
+
+ constructor(
+ @InjectRepository(PriorityRuleChangeRequest)
+ private readonly repo: Repository,
+ private readonly configs: PriorityConfigsService,
+ private readonly inbox: NotificationInboxService,
+ ) {}
+
+ async submit(
+ dto: SubmitPriorityRuleChangeDto,
+ userId?: string | null,
+ ): Promise {
+ const payload = await this.validateSubmission(dto);
+
+ const request = await this.repo.save(
+ this.repo.create({
+ action: dto.action,
+ priorityConfigId: dto.priorityConfigId ?? null,
+ payload,
+ status: 'PENDING',
+ requestedByUserId: userId ?? null,
+ }),
+ );
+
+ this.notifyTeam(
+ 'Priority rule change submitted',
+ `A ${dto.action.toLowerCase()} of a priority rule was submitted and awaits approval.`,
+ request,
+ );
+ return request;
+ }
+
+ async list(status?: PriorityRuleChangeStatus): Promise {
+ return this.repo.find({
+ where: status ? { status } : {},
+ relations: { priorityConfig: true },
+ order: { createdAt: 'DESC' },
+ });
+ }
+
+ async approve(
+ id: string,
+ userId?: string | null,
+ decisionNote?: string,
+ ): Promise {
+ const request = await this.findPending(id);
+
+ // Apply the change through the normal service so currency + range-collision
+ // validation runs against the CURRENT rules; a stale request that now
+ // collides fails here and stays PENDING for the approver to see the error.
+ if (request.action === 'CREATE') {
+ await this.configs.create(request.payload as unknown as CreatePriorityConfigDto);
+ } else if (request.action === 'UPDATE') {
+ await this.configs.update(
+ this.requireTarget(request),
+ request.payload as unknown as UpdatePriorityConfigDto,
+ );
+ } else {
+ await this.configs.remove(this.requireTarget(request));
+ }
+
+ request.status = 'APPROVED';
+ request.decidedByUserId = userId ?? null;
+ request.decidedAt = new Date();
+ request.decisionNote = decisionNote ?? null;
+ const saved = await this.repo.save(request);
+
+ this.notifyTeam(
+ 'Priority rule change approved',
+ `The ${request.action.toLowerCase()} priority-rule change was approved and applied.` +
+ (decisionNote ? ` Note: ${decisionNote}` : ''),
+ saved,
+ );
+ return saved;
+ }
+
+ async reject(
+ id: string,
+ userId?: string | null,
+ decisionNote?: string,
+ ): Promise {
+ const request = await this.findPending(id);
+ request.status = 'REJECTED';
+ request.decidedByUserId = userId ?? null;
+ request.decidedAt = new Date();
+ request.decisionNote = decisionNote ?? null;
+ const saved = await this.repo.save(request);
+
+ this.notifyTeam(
+ 'Priority rule change rejected',
+ `The ${request.action.toLowerCase()} priority-rule change was rejected.` +
+ (decisionNote ? ` Note: ${decisionNote}` : ''),
+ saved,
+ );
+ return saved;
+ }
+
+ /**
+ * Validate a submission the way applying it would, so bad requests are
+ * refused at the door — most importantly the wagon-range collision rule.
+ * Returns the payload to persist.
+ */
+ private async validateSubmission(
+ dto: SubmitPriorityRuleChangeDto,
+ ): Promise | null> {
+ if (dto.action === 'CREATE') {
+ if (!dto.create) {
+ throw new BadRequestException('CREATE requires the proposed rule in `create`');
+ }
+ await this.configs.assertNoRangeCollision({
+ type: dto.create.type,
+ currency: dto.create.currency ?? null,
+ minWagonCount: dto.create.minWagonCount,
+ maxWagonCount: dto.create.maxWagonCount,
+ });
+ return { ...dto.create };
+ }
+
+ if (!dto.priorityConfigId) {
+ throw new BadRequestException(`${dto.action} requires priorityConfigId`);
+ }
+ const existing = await this.configs.findById(dto.priorityConfigId);
+
+ if (dto.action === 'DELETE') return null;
+
+ if (!dto.update || Object.keys(dto.update).length === 0) {
+ throw new BadRequestException('UPDATE requires the field changes in `update`');
+ }
+ await this.configs.assertNoRangeCollision({
+ type: dto.update.type ?? existing.type,
+ currency:
+ dto.update.currency !== undefined ? dto.update.currency : existing.currency,
+ minWagonCount: dto.update.minWagonCount ?? existing.minWagonCount,
+ maxWagonCount: dto.update.maxWagonCount ?? existing.maxWagonCount,
+ excludeId: existing.id,
+ });
+ return { ...dto.update };
+ }
+
+ private async findPending(id: string): Promise {
+ const request = await this.repo.findOne({
+ where: { id },
+ relations: { priorityConfig: true },
+ });
+ if (!request) throw new NotFoundException(`Change request ${id} not found`);
+ if (request.status !== 'PENDING') {
+ throw new ConflictException(
+ `Change request is already ${request.status.toLowerCase()}`,
+ );
+ }
+ return request;
+ }
+
+ private requireTarget(request: PriorityRuleChangeRequest): string {
+ if (!request.priorityConfigId) {
+ throw new BadRequestException(
+ `${request.action} change request has no target rule`,
+ );
+ }
+ return request.priorityConfigId;
+ }
+
+ /**
+ * In-app notification to the whole backoffice team (submission AND decision
+ * both notify the team; the requester is staff, so they are included).
+ * Fire-and-forget — a notification failure never blocks the workflow.
+ */
+ private notifyTeam(
+ title: string,
+ body: string,
+ request: PriorityRuleChangeRequest,
+ ): void {
+ void this.inbox
+ .notify({
+ recipients: { allBackoffice: true },
+ audience: NotificationAudience.BACKOFFICE,
+ type: NotificationType.REQUEST_SUBMITTED,
+ title,
+ body,
+ link: RULES_LINK,
+ data: { priorityRuleChangeRequestId: request.id, action: request.action },
+ })
+ .catch((err) =>
+ this.logger.warn(
+ `Priority-rule notification failed: ${(err as Error).message}`,
+ ),
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts
index ba0995679..e913af6b7 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts
@@ -109,17 +109,26 @@ describe('computeImportWindowTimes — first-window open respects office hours',
});
it('caps the close at departure', () => {
- // Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT,
- // past the 06 Jul 08:00 departure → clamped to departure.
+ // Round-the-clock desk (no desk-close cap in play). Opens now (05 Jul 12:00
+ // EAT); a 24h duration would close 06 Jul 12:00 EAT, past the 06 Jul 08:00
+ // departure → clamped to departure.
const now = new Date('2026-07-05T09:00:00.000Z');
const { windowClosesAt } = computeImportWindowTimes(
departure,
- { ...bounded, windowDurationHours: 24 },
+ { ...bounded, windowOpenHour: 8, windowCloseHour: 8, windowDurationHours: 24 },
now,
);
expect(windowClosesAt.toISOString()).toBe(departure.toISOString());
});
+ it('desk close hour cuts the window short (duration never outlives the desk)', () => {
+ // Opens now (05 Jul 12:00 EAT); the 15h duration would run to 03:00 next
+ // day, but the desk shuts 17:00 EAT (14:00 UTC) → the window closes with it.
+ const now = new Date('2026-07-05T09:00:00.000Z');
+ const { windowClosesAt } = computeImportWindowTimes(departure, bounded, now);
+ expect(windowClosesAt.toISOString()).toBe('2026-07-05T14:00:00.000Z');
+ });
+
describe('overnight desk (open > close, wraps past midnight)', () => {
// Desk open 08:00, closes 05:00 next morning — open across midnight.
const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 };
@@ -189,13 +198,13 @@ describe('computeImportWindowTimes — overnight desk (open > close, wraps midni
describe('batch-window board windows (config-driven booking cycles)', () => {
// Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure,
- // 3h long, reopen 90m later.
+ // 3h long, reopen gap (doc review + payment) 90m.
const cfg: BoardWindowConfig = {
importWindowLeadDays: 3,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
- reopenDelayMinutes: 90,
+ reopenGapMinutes: 90,
exportBookingLeadHours: 24,
};
@@ -211,7 +220,7 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
});
- it('import: reopens reopenDelayMinutes after close while inside office hours', () => {
+ it('import: reopens after the doc-review + payment gap while inside office hours', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT, same day
@@ -250,6 +259,17 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3);
});
+ it('import: desk close hour cuts a cycle short (duration past 17:00 clamps)', () => {
+ const longCfg: BoardWindowConfig = { ...cfg, windowDurationHours: 10 };
+ const departure = new Date('2026-06-08T11:00:00.000Z');
+ const windows = listConfigBookingWindows('IMPORT', departure, longCfg);
+ // Cycle 1 opens 08:00 EAT; 10h would close 18:00 — desk shuts 17:00 (14:00 UTC).
+ expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z');
+ expect(windows[0].end.toISOString()).toBe('2026-06-05T14:00:00.000Z');
+ // Reopen 90m after the clamped close lands past 17:00 → next morning 08:00 EAT.
+ expect(windows[1].start.toISOString()).toBe('2026-06-06T05:00:00.000Z');
+ });
+
it('export: single FCFS window exportBookingLeadHours before departure', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('EXPORT', departure, cfg);
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts
index 0fdacc572..a1dfe61f4 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts
@@ -223,6 +223,49 @@ export function nextCycleOpensAt(
return opensAt.getTime() < departure.getTime() ? opensAt : null;
}
+/**
+ * The desk-close instant of the office window containing `opensAt`; null for a
+ * round-the-clock desk. Same-day desk (open < close): closeHour on `opensAt`'s
+ * EAT day. Overnight desk (open > close): closeHour on the NEXT EAT day when
+ * `opensAt` sits in the evening half, closeHour the same day when it sits in the
+ * after-midnight half.
+ */
+export function officeCloseAfter(opensAt: Date, hours: OfficeHours): Date | null {
+ if (isRoundTheClock(hours)) return null;
+ const { hour, minute } = eatParts(opensAt);
+ const openMinutes = hour * 60 + minute;
+ if (
+ hours.windowOpenHour > hours.windowCloseHour &&
+ openMinutes >= hours.windowOpenHour * 60
+ ) {
+ return eatDayToUtc(shiftEatDay(eatDay(opensAt), 1), hours.windowCloseHour);
+ }
+ return eatDayToUtc(eatDay(opensAt), hours.windowCloseHour);
+}
+
+/**
+ * Cap a window close at the desk-close hour that follows its open: the office
+ * hours end a running window early rather than letting the duration outlive the
+ * desk (open 16:00, 3h duration, desk 8–17 → closes 17:00, not 19:00). A
+ * round-the-clock desk never caps; a desk-close at/before the open (degenerate
+ * config) is ignored so the window is never clamped to zero length here.
+ */
+export function clampCloseToOfficeHours(
+ opensAt: Date,
+ closesAt: Date,
+ hours: OfficeHours,
+): Date {
+ const deskClose = officeCloseAfter(opensAt, hours);
+ if (
+ deskClose != null &&
+ deskClose.getTime() > opensAt.getTime() &&
+ closesAt.getTime() > deskClose.getTime()
+ ) {
+ return deskClose;
+ }
+ return closesAt;
+}
+
export interface InitialWindowTimes {
windowOpensAt: Date;
windowClosesAt: Date;
@@ -243,7 +286,8 @@ export interface InitialWindowTimes {
* • `now` before openHour that EAT day → opens at openHour that morning
* • `now` at/after closeHour → desk shut; opens openHour next morning
*
- * `windowDurationHours` extends from that open, capped at departure.
+ * `windowDurationHours` extends from that open, capped at the desk close hour
+ * and at departure.
*/
export function computeImportWindowTimes(
departure: Date,
@@ -276,6 +320,10 @@ export function computeImportWindowTimes(
}
let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
+ closesAt = clampCloseToOfficeHours(opensAt, closesAt, {
+ windowOpenHour: cfg.windowOpenHour,
+ windowCloseHour: cfg.windowCloseHour,
+ });
if (closesAt.getTime() > departure.getTime()) {
closesAt = departure;
}
@@ -381,10 +429,11 @@ export function listBatchWindowsForBookings(
// ---------------------------------------------------------------------------
// Board-display windows: the REAL booking-window cycles derived from the
-// train_scheduling_global_rules config (window open hour, lead days, duration,
-// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle
-// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after
-// reopenDelayMinutes until departure). Export shows the single FCFS lead window.
+// schedule's frozen window rule (open/close hour, lead days, duration, reopen
+// gap = doc review + payment) — NOT a fixed clock grid. Import shows each
+// booking-window cycle (opens at windowOpenHour EAT, lasts windowDurationHours
+// capped at the desk close, reopens after the gap until departure). Export shows
+// the single FCFS lead window.
// ---------------------------------------------------------------------------
/** A board window carries an EAT calendar date in addition to the slot times. */
@@ -402,8 +451,11 @@ export interface BoardWindowConfig {
/** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */
windowCloseHour: number;
windowDurationHours: number;
- /** Gap between a cycle's close and its reopen (doc review + payment minutes). */
- reopenDelayMinutes: number;
+ /**
+ * Gap between a cycle's close and its reopen — always doc review + payment
+ * minutes (the schedule's frozen snapshot, or the live sum for legacy rows).
+ */
+ reopenGapMinutes: number;
exportBookingLeadHours: number;
}
@@ -435,10 +487,11 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
* The real booking-window cycles for a schedule, straight from config.
*
* IMPORT: first window opens at `windowOpenHour` EAT on `departure − importWindowLeadDays`
- * for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes`
- * after each close, on the same booking day, until departure. This mirrors
- * `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
- * exact windows the engine runs.
+ * for `windowDurationHours` (cut short by the desk close hour); if the train isn't
+ * full it reopens `reopenGapMinutes` (doc review + payment) after each close,
+ * honouring office hours, until departure. This mirrors `computeImportWindowTimes`
+ * + `concludeCycle`'s reopen math so the board shows the exact windows the engine
+ * runs.
* EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure,
* with the open shifted to the next desk opening when it lands outside office hours
* (same math as `computeExportWindowTimes`).
@@ -464,7 +517,7 @@ export function listConfigBookingWindows(
const durationMs = cfg.windowDurationHours * 3_600_000;
// Post-close gap before the next cycle opens (doc review + payment), subject
// to office hours below.
- const reopenMs = cfg.reopenDelayMinutes * 60_000;
+ const reopenMs = cfg.reopenGapMinutes * 60_000;
const officeHours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
@@ -484,6 +537,7 @@ export function listConfigBookingWindows(
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
+ closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
windows.push(boardWindowFromInterval(opensAt, closesAt));
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
index c8e254141..c37430121 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
@@ -37,6 +37,7 @@ describe('BookingBatchService — PAID reconcile', () => {
};
let trainSchedulingService: {
tryAutoWagonAllocation: jest.Mock;
+ previewPaidBookingWagonShortage: jest.Mock;
getBookableSchedules: jest.Mock;
getWindowConfig: jest.Mock;
};
@@ -87,6 +88,8 @@ describe('BookingBatchService — PAID reconcile', () => {
issues: [],
violations: [],
}),
+ // No shortage by default — paid bookings link as before.
+ previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
getBookableSchedules: jest.fn().mockResolvedValue([]),
getWindowConfig: jest.fn().mockResolvedValue({
importWindowLeadDays: 3,
@@ -96,7 +99,6 @@ describe('BookingBatchService — PAID reconcile', () => {
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
- reopenDelayMinutes: 90,
}),
};
@@ -169,6 +171,38 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2);
});
+ it('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => {
+ trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({
+ wagonTypeCodes: 'NW6',
+ wagonsNeeded: 1,
+ wagonsAvailable: 0,
+ wagonsShort: 1,
+ });
+
+ await service.ensurePaidBookingAllocated(bookingId);
+
+ // Not linked, no wagon run — held PAID + unlinked, flagged for manual placement.
+ expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
+ expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
+ expect(dataSource.getRepository().update).toHaveBeenCalledWith(
+ bookingId,
+ expect.objectContaining({ schedulingStatus: 'WAITING_FOR_WAGON' }),
+ );
+ });
+
+ it('reconcilePaidUnlinked leaves WAITING_FOR_WAGON bookings held', async () => {
+ bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([
+ { ...paidBooking, schedulingStatus: 'WAITING_FOR_WAGON' },
+ ]);
+
+ await service.reconcilePaidUnlinked(scheduleId);
+
+ expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
+ expect(
+ trainSchedulingService.previewPaidBookingWagonShortage,
+ ).not.toHaveBeenCalled();
+ });
+
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
index e51d4ad23..82782446a 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
@@ -497,6 +497,7 @@ export class BookingBatchService implements OnModuleInit {
const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
if (!linked) {
+ if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
await this.allocate(booking.trainScheduleId, booking, "paid");
this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
@@ -783,6 +784,9 @@ export class BookingBatchService implements OnModuleInit {
const unlinked =
await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
for (const booking of unlinked) {
+ // Held on purpose (paid, no wagon free) — the cron must not undo it.
+ if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue;
+ if (await this.holdIfWagonShort(scheduleId, booking)) continue;
await this.allocate(scheduleId, booking, "paid");
this.logger.log(
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
@@ -1050,7 +1054,11 @@ export class BookingBatchService implements OnModuleInit {
s.ruleWindowDurationHours,
liveCfg.windowDurationHours,
),
- reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes),
+ // Frozen doc-review + payment sum; legacy rows fall back to the live sum.
+ reopenGapMinutes: num(
+ s.ruleReopenDelayMinutes,
+ liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes,
+ ),
importWindowLeadDays: num(
s.ruleImportWindowLeadDays,
liveCfg.importWindowLeadDays,
@@ -1894,7 +1902,9 @@ export class BookingBatchService implements OnModuleInit {
done.add(booking.id);
if (isPaid(booking)) {
- await this.allocate(scheduleId, booking, "paid");
+ if (!(await this.holdIfWagonShort(scheduleId, booking))) {
+ await this.allocate(scheduleId, booking, "paid");
+ }
anySettled = true;
} else if (isExpired(booking)) {
await this.expire(booking);
@@ -1920,6 +1930,29 @@ export class BookingBatchService implements OnModuleInit {
);
}
+ /**
+ * Conclude-time retry: promote whatever still fits from the route-day waiting
+ * list, opening fresh pay windows. Returns how many commercial units got
+ * reserved — corridor-wide, since the fill is day-level and may reserve onto a
+ * sibling train; the caller must check `hasLiveReservations` for its OWN
+ * schedule before deciding to stay in PAYMENT.
+ */
+ async fillFromWaitingList(scheduleId: string): Promise {
+ return this.withScheduleLock(scheduleId, async () => {
+ let promoted = 0;
+ for (let round = 0; round < 10; round += 1) {
+ const reservedThisRound = await this.topUpFill(scheduleId);
+ if (reservedThisRound <= 0) break;
+ promoted += reservedThisRound;
+ await this.extendPaymentPhaseForTopUp(scheduleId);
+ }
+ if (promoted > 0) {
+ this.notifyBoardChanged(scheduleId, "conclude_waiting_list_fill");
+ }
+ return promoted;
+ });
+ }
+
/**
* Settle, then keep promoting the waiting list until the train can take no more.
* Returns whether anything settled.
@@ -2050,7 +2083,9 @@ export class BookingBatchService implements OnModuleInit {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: "PAID" });
- await this.allocate(booking.trainScheduleId, booking, "paid");
+ if (!(await this.holdIfWagonShort(booking.trainScheduleId, booking))) {
+ await this.allocate(booking.trainScheduleId, booking, "paid");
+ }
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
@@ -2112,7 +2147,12 @@ export class BookingBatchService implements OnModuleInit {
await manager.getRepository(Booking).update(bookingId, {
trainScheduleId: newScheduleId,
status: restoredStatus,
- schedulingStatus: "ELIGIBLE",
+ // A paid booking still hunting for a wagon keeps its flag through the
+ // move — it only clears when wagons are actually assigned.
+ schedulingStatus:
+ booking.schedulingStatus === "WAITING_FOR_WAGON"
+ ? "WAITING_FOR_WAGON"
+ : "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
@@ -2254,6 +2294,50 @@ export class BookingBatchService implements OnModuleInit {
}
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
+ /**
+ * Fleet preflight shared by every single-booking paid-allocation path: when
+ * no wagon of the booking's required type is free, hold it OUT of the train
+ * instead of linking — it stays PAID + unlinked in the (route, day) pool,
+ * flagged WAITING_FOR_WAGON, and staff place it on any same-day schedule from
+ * the workspace "Paid · unassigned" panel once a wagon frees up. Returns true
+ * when the booking was held. Consolidated pairs are exempt (the shared wagon
+ * is both-or-neither and settles atomically in settleReserved).
+ */
+ private async holdIfWagonShort(
+ scheduleId: string,
+ booking: Booking,
+ ): Promise {
+ if (booking.consolidationPartnerId) return false;
+ const shortage =
+ await this.trainSchedulingService.previewPaidBookingWagonShortage(
+ scheduleId,
+ booking.id,
+ );
+ if (!shortage) return false;
+
+ await this.dataSource.getRepository(Booking).update(booking.id, {
+ status: "PAID",
+ paymentStatus: "PAID",
+ schedulingStatus: "WAITING_FOR_WAGON",
+ paymentDeadline: null,
+ selectedForBatchAt: null,
+ } as never);
+ // Payment landed — record it even though nothing boards yet. The wagon
+ // milestone stays pending until staff assign one.
+ void this.completeTrackingMilestones(booking.id, [
+ "FREIGHT_PAYMENT_PENDING",
+ "FREIGHT_PAYMENT_SETTLED",
+ ]);
+ this.logger.warn(
+ `PAID booking ${booking.reference ?? booking.id} is WAITING FOR WAGON: ` +
+ `needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
+ `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}). ` +
+ `Held in the day pool for manual placement.`,
+ );
+ this.notifyBoardChanged(scheduleId, "booking_waiting_wagon");
+ return true;
+ }
+
private async allocate(
scheduleId: string,
booking: Booking,
@@ -2358,7 +2442,9 @@ export class BookingBatchService implements OnModuleInit {
`[BATCH] expire skipped for ${booking.reference} — payment already ` +
`landed; allocating on schedule ${paidScheduleId} instead`,
);
- await this.allocate(paidScheduleId, fresh, "paid");
+ if (!(await this.holdIfWagonShort(paidScheduleId, fresh))) {
+ await this.allocate(paidScheduleId, fresh, "paid");
+ }
return;
}
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts
index 25d569171..7128bd705 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts
@@ -19,8 +19,6 @@ export interface BookingWindowConfig {
/** Max staff document-review time after the window closes. */
docReviewMinutes: number;
paymentWindowMinutes: number;
- /** Delay after window close before reopening when the train is not full. */
- reopenDelayMinutes: number;
}
/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts
index 72229cfd6..9393c520f 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts
@@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => {
hasLiveReservations: jest.Mock;
refreshWindowStatus: jest.Mock;
expireLeftoverDayPool: jest.Mock;
+ fillFromWaitingList: jest.Mock;
};
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
@@ -33,7 +34,6 @@ describe('BookingWindowService — window state machine', () => {
windowDurationHours: 1,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
- reopenDelayMinutes: 0,
};
const baseSchedule = (over: Partial): TrainSchedule =>
@@ -75,6 +75,8 @@ describe('BookingWindowService — window state machine', () => {
hasLiveReservations: jest.fn().mockResolvedValue(false),
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
+ // No waiting booking fits by default, so conclude proceeds to reopen/DONE.
+ fillFromWaitingList: jest.fn().mockResolvedValue(0),
};
trainSchedulesRepository = {
findById: jest.fn().mockResolvedValue(null),
@@ -123,6 +125,8 @@ describe('BookingWindowService — window state machine', () => {
});
it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => {
+ // The batch reserved someone (live reservations exist) → real PAYMENT phase.
+ batch.hasLiveReservations.mockResolvedValue(true);
const s = baseSchedule({
windowPhase: 'DOC_REVIEW',
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
@@ -140,6 +144,7 @@ describe('BookingWindowService — window state machine', () => {
});
it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => {
+ batch.hasLiveReservations.mockResolvedValue(true);
const s = baseSchedule({
windowPhase: 'DOC_REVIEW',
docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future
@@ -150,6 +155,21 @@ describe('BookingWindowService — window state machine', () => {
expect(s.windowPhase).toBe('PAYMENT');
});
+ it('DOC_REVIEW → batch reserves nothing → skips the empty PAYMENT phase and reopens', async () => {
+ // Default hasLiveReservations=false: the batch reserved nobody. Waiting a
+ // full payment window with the desk shut would serve no one — the cycle
+ // concludes immediately (24h desk + far departure → straight to PRE_WINDOW).
+ const s = baseSchedule({
+ windowPhase: 'DOC_REVIEW',
+ docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
+ });
+ const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z'));
+ expect(advanced).toBe(true);
+ expect(batch.processRouteDay).toHaveBeenCalledTimes(1);
+ expect(s.windowPhase).toBe('PRE_WINDOW');
+ expect(s.windowOpensAt).not.toBeNull();
+ });
+
it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => {
const s = baseSchedule({
windowPhase: 'PAYMENT',
@@ -205,6 +225,22 @@ describe('BookingWindowService — window state machine', () => {
expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled();
});
+ it('conclude: waiting booking still fits → fresh pay window, back to PAYMENT, no reopen', async () => {
+ batch.isScheduleFull.mockResolvedValue(false);
+ batch.fillFromWaitingList.mockResolvedValue(2);
+ batch.hasLiveReservations.mockResolvedValue(true);
+ const s = baseSchedule({
+ windowPhase: 'PAYMENT',
+ scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
+ });
+ const now = new Date('2026-07-01T02:30:05.000Z');
+ await concludeCycle(s, now);
+ expect(batch.fillFromWaitingList).toHaveBeenCalledWith(scheduleId);
+ expect(s.windowPhase).toBe('PAYMENT');
+ // Fresh pay window from `now`, not a reopen.
+ expect(s.paymentPhaseEndsAt).toEqual(new Date(now.getTime() + 60 * 60_000));
+ });
+
it('conclude: NOT full but NO cycle fits before departure → DONE', async () => {
batch.isScheduleFull.mockResolvedValue(false);
const s = baseSchedule({
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
index d3405c951..f728afe6a 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
@@ -17,7 +17,12 @@ import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
-import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
+import {
+ clampCloseToOfficeHours,
+ eatDay,
+ nextCycleOpensAt,
+ type OfficeHours,
+} from './batch-window.util';
import { type BookingWindowConfig } from './booking-window.config';
/**
@@ -297,6 +302,17 @@ export class BookingWindowService implements OnModuleInit {
// (or allocating government) — skipped automatically for everyone who fits
// is handled inside the fill (all fit → all reserved → all notified).
await this.bookingBatchService.processRouteDay(routeDay);
+ // Batch reserved nobody (empty pool, or it allocated without pay windows):
+ // a PAYMENT phase with nobody to pay is a dead hour with the window shut.
+ // Conclude straight away — full → DONE, otherwise reopen per office hours.
+ if (!(await this.bookingBatchService.hasLiveReservations(schedule.id))) {
+ this.logger.log(
+ `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch reserved nothing; ` +
+ `skipping the empty payment phase and concluding the cycle`,
+ );
+ await this.concludeCycle(schedule, cfg, now);
+ return true;
+ }
this.logger.log(
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` +
`until ${paymentPhaseEndsAt.toISOString()}`,
@@ -353,7 +369,10 @@ export class BookingWindowService implements OnModuleInit {
return false;
}
- /** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */
+ /**
+ * After settle: full → finalize + DONE; waiting bookings still fit → fresh pay
+ * window, back to PAYMENT; otherwise reopen (office hours decide when) or DONE.
+ */
private async concludeCycle(
schedule: TrainSchedule,
cfg: BookingWindowConfig,
@@ -386,6 +405,31 @@ export class BookingWindowService implements OnModuleInit {
if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus;
}
+ // The window reopens only once the waiting list is exhausted: a booking can
+ // still reach the pool mid-payment (late doc accept, consolidation partner),
+ // so retry the batch before reopening. Anything that fits gets a fresh pay
+ // window and the cycle stays in PAYMENT; check live reservations on THIS
+ // schedule because the day-level fill may have reserved onto a sibling.
+ // Waiting bookings that fit no train stay pooled and the window reopens.
+ const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id);
+ if (
+ promoted > 0 &&
+ (await this.bookingBatchService.hasLiveReservations(schedule.id))
+ ) {
+ let paymentPhaseEndsAt = new Date(
+ now.getTime() + cfg.paymentWindowMinutes * 60_000,
+ );
+ if (paymentPhaseEndsAt > schedule.scheduledDepartureDate) {
+ paymentPhaseEndsAt = schedule.scheduledDepartureDate;
+ }
+ await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
+ this.logger.log(
+ `[WINDOW] ${schedule.id} conclude → waiting list still had bookings that ` +
+ `fit — back in PAYMENT until ${paymentPhaseEndsAt.toISOString()}, no reopen yet`,
+ );
+ return;
+ }
+
// Doc review + payment have already run, so the desk is ready to reopen NOW —
// office hours decide whether that is this afternoon or tomorrow morning. Past
// the last cycle before departure, nextCycleOpensAt returns null and we finish.
@@ -413,6 +457,9 @@ export class BookingWindowService implements OnModuleInit {
let nextClosesAt = new Date(
nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000,
);
+ // Office hours end a running window early: never let the duration outlive
+ // the desk close (open 16:00, 3h, desk 8–17 → closes 17:00).
+ nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours);
if (nextClosesAt > schedule.scheduledDepartureDate) {
nextClosesAt = schedule.scheduledDepartureDate;
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts
index a3af6f572..165a0db20 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts
@@ -41,6 +41,28 @@ export class AvailableDaysForCargoQueryDto {
@IsString()
cargoTypeCode?: string;
+ @ApiPropertyOptional({ format: 'uuid', description: 'Bulk cargo type id (preferred over code).' })
+ @IsOptional()
+ @IsUUID()
+ cargoTypeId?: string;
+
+ @ApiPropertyOptional({
+ description:
+ 'Container type ids as a JSON string array — enables the exact wagon-type compatibility gate (falls back to containerSize matching when absent).',
+ })
+ @IsOptional()
+ @Transform(({ value }) => {
+ if (value == null || value === '') return undefined;
+ if (typeof value !== 'string') return value;
+ try {
+ return JSON.parse(value);
+ } catch {
+ return undefined;
+ }
+ })
+ @IsArray()
+ containerTypeIds?: string[];
+
@ApiPropertyOptional({ description: 'Total bulk weight in tons.' })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts
index 0e252240b..2948b874d 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts
@@ -95,11 +95,4 @@ export class UpdateTrainSchedulingGlobalRulesDto {
@IsInt()
@Min(1)
paymentWindowMinutes?: number;
-
- @ApiPropertyOptional({ example: 90 })
- @IsOptional()
- @Type(() => Number)
- @IsInt()
- @Min(1)
- reopenDelayMinutes?: number;
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts
index ffa42fd7e..729063599 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts
@@ -79,8 +79,4 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
@Column({ name: 'payment_window_minutes', type: 'int', default: 60 })
paymentWindowMinutes!: number;
-
- /** Delay after window close before the window reopens when the train is not yet full. */
- @Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 })
- reopenDelayMinutes!: number;
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts
index 54bf7a181..ff685d30c 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts
@@ -114,6 +114,35 @@ describe('fleet-plan.util', () => {
expect(warnings.some((w) => w.includes('deferred'))).toBe(true);
});
+ it('names the booking and its per-type shortfall when the deferral carries a shortage', () => {
+ const warnings = summarizeFleetWarnings(
+ [],
+ [
+ {
+ id: 'b1',
+ reference: 'BKG-1',
+ reason: 'No available NW6 wagon at the yard',
+ shortage: {
+ wagonTypeCodes: 'NW6',
+ wagonsNeeded: 2,
+ wagonsAvailable: 1,
+ wagonsShort: 1,
+ },
+ },
+ ],
+ );
+
+ expect(
+ warnings.some(
+ (w) =>
+ w.includes('BKG-1') &&
+ w.includes('2 × NW6') &&
+ w.includes('only 1 available') &&
+ w.includes('short 1'),
+ ),
+ ).toBe(true);
+ });
+
it('counts wagons required per booking from container lines', () => {
const booking = makeBooking('b1', {
bookingContainers: [
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts
index 6173da6b1..9cca4d213 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts
@@ -17,10 +17,21 @@ export type FleetAvailabilityRow = {
shortfall: number;
};
+/** Per-booking wagon shortage: how many wagons of which type this booking still lacks. */
+export type BookingWagonShortage = {
+ /** Candidate wagon-type codes usable by the booking, joined ("NW6" or "NW6/CW3"). */
+ wagonTypeCodes: string;
+ wagonsNeeded: number;
+ wagonsAvailable: number;
+ wagonsShort: number;
+};
+
export type DeferredBookingRow = {
id: string;
reference: string;
reason: string;
+ /** Set when the deferral is a fleet-stock shortage (absent for config issues). */
+ shortage?: BookingWagonShortage | null;
};
export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
@@ -156,6 +167,16 @@ export function summarizeFleetWarnings(
);
}
+ // Name the bookings the shortage actually hits, with their own per-type counts,
+ // so staff know WHAT is held out — not just that the pool is short overall.
+ for (const row of deferred) {
+ if (!row.shortage) continue;
+ warnings.push(
+ `Booking ${row.reference} held out: needs ${row.shortage.wagonsNeeded} × ${row.shortage.wagonTypeCodes}, ` +
+ `only ${row.shortage.wagonsAvailable} available (short ${row.shortage.wagonsShort})`,
+ );
+ }
+
if (deferred.length) {
warnings.push(
`${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`,
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 c6e22589f..e4efaab9a 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
@@ -234,9 +234,11 @@ export class TrainSchedulingController {
originYardId: query.originYardId,
destinationYardId: query.destinationYardId,
freightType: query.freightType,
+ cargoTypeId: query.cargoTypeId,
cargoTypeCode: query.cargoTypeCode,
totalWeightTons: query.totalWeightTons,
containers: query.containers,
+ containerTypeIds: query.containerTypeIds,
});
}
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 04583c777..8c2742b64 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
@@ -99,6 +99,7 @@ import {
summarizeFleetWarnings,
totalAssignedWeight,
wagonsRequiredForBooking,
+ type BookingWagonShortage,
type DeferredBookingRow,
type FleetAvailabilityRow,
} from './fleet-plan.util';
@@ -214,8 +215,6 @@ export function effectiveWindowConfig(
: liveCfg.windowDurationHours,
docReviewMinutes: liveCfg.docReviewMinutes,
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
- reopenDelayMinutes:
- schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes,
};
}
@@ -251,6 +250,8 @@ export interface CompositionUnassignedBookingRow {
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
+ /** Structured fleet shortage when the block is missing wagons (null otherwise). */
+ shortage: BookingWagonShortage | null;
}
export interface UnassignedBookingsResponse {
@@ -435,7 +436,12 @@ export class TrainSchedulingService {
.where('s.originStationId = :originStationId', { originStationId })
.andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
- .andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart });
+ .andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
+ // A cancelled train is not a sibling: cancel retires its window as DONE,
+ // and a newborn anchoring to it would inherit that dead window verbatim.
+ .andWhere('s.status != :cancelledStatus', {
+ cancelledStatus: TrainScheduleStatusEnum.Cancelled,
+ });
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
@@ -471,7 +477,12 @@ export class TrainSchedulingService {
departure,
);
if (siblings.length === 0) return null;
- const withWindow = siblings.filter((s) => s.windowOpensAt != null);
+ // A DONE window is retired (the day's last cycle already ran) — anchoring
+ // to it would hand the newborn a dead window no tick ever advances. With no
+ // live or pending sibling left, fall back to fresh times (return null).
+ const withWindow = siblings.filter(
+ (s) => s.windowOpensAt != null && s.windowPhase !== 'DONE',
+ );
if (withWindow.length === 0) return null;
// A group whose window is live (some sibling has moved past PRE_WINDOW but is
@@ -603,7 +614,6 @@ export class TrainSchedulingService {
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
- if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
// The booking desk supports three shapes: a same-day range
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
@@ -692,7 +702,6 @@ export class TrainSchedulingService {
// override changes them, so the derived snapshot delay stays consistent.
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
- reopenDelayMinutes: liveCfg.reopenDelayMinutes,
};
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
@@ -919,7 +928,6 @@ export class TrainSchedulingService {
windowDurationHours: num(row?.windowDurationHours, 3),
docReviewMinutes: num(row?.docReviewMinutes, 30),
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
- reopenDelayMinutes: num(row?.reopenDelayMinutes, 90),
};
}
@@ -1069,6 +1077,20 @@ export class TrainSchedulingService {
// getSchedulableRoute already rejected DOMESTIC (intercity).
const direction = this.resolveRouteDirection(route);
+ // Direction-matched fixed number from the built train's typed pair.
+ // Legacy locomotive-picked schedules keep dispatch-time pool assignment
+ // (assignTrainNumber is idempotent, so both paths compose).
+ const pairTrainNumber = builtTrain
+ ? (direction === 'IMPORT'
+ ? builtTrain.importTrainNumber
+ : builtTrain.exportTrainNumber) ?? null
+ : null;
+ if (builtTrain && !pairTrainNumber) {
+ scheduleWarnings.push(
+ `Train ${builtTrain.code} has no ${direction === 'IMPORT' ? 'import' : 'export'} train number; a pool number will be assigned at dispatch`,
+ );
+ }
+
const trainSet = await this.buildEmptyTrainSet(
manager,
lockedLocomotives,
@@ -1164,6 +1186,7 @@ export class TrainSchedulingService {
scheduledDepartureDate: departure,
status: TrainScheduleStatusEnum.Draft,
direction,
+ trainNumber: pairTrainNumber ?? undefined,
maxWagons,
...windowFields,
}),
@@ -1368,8 +1391,6 @@ export class TrainSchedulingService {
await this.dataSource.transaction(async (manager) => {
const trainSetId = schedule.trainSetId;
- await this.releasePinnedWagonsForTrainSet(manager, trainSetId);
-
const deletedAllocationIds =
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
@@ -1505,7 +1526,6 @@ export class TrainSchedulingService {
(sb) => sb.bookingId !== bookingId,
);
if (remainingBookings.length === 0) {
- await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
schedule.trainSetId,
manager,
@@ -1745,7 +1765,18 @@ export class TrainSchedulingService {
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
}
- const slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => w.id));
+ const slots = schedule.trainSet?.wagons ?? [];
+ const slotIds = new Set(slots.map((w) => w.id));
+ const slotById = new Map(slots.map((w) => [w.id, w]));
+ const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId);
+ // Occupancy is judged against THIS schedule's own slots only — a wagon
+ // pinned on another schedule (e.g. the same train's July 17 run) stays
+ // pinnable here.
+ const slotIdByPhysicalId = new Map(
+ slots
+ .filter((w) => w.physicalWagonId)
+ .map((w) => [w.physicalWagonId as string, w.id]),
+ );
await this.dataSource.transaction(async (manager) => {
for (const assignment of dto.assignments) {
@@ -1761,29 +1792,61 @@ export class TrainSchedulingService {
if (!physicalWagon) {
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
}
- if (
- physicalWagon.status !== WagonStatus.Available &&
- physicalWagon.currentTrainScheduleId !== scheduleId
- ) {
+ const occupyingSlotId = slotIdByPhysicalId.get(assignment.physicalWagonId);
+ if (occupyingSlotId && occupyingSlotId !== assignment.trainSetWagonId) {
+ const occupyingSlot = slotById.get(occupyingSlotId);
throw new ConflictException(
- `Wagon ${physicalWagon.wagonNumber} is not available`,
+ `Wagon ${physicalWagon.wagonNumber} is already pinned to slot #${occupyingSlot?.sequenceNo ?? '?'} of this schedule`,
);
}
- if (physicalWagon.currentYardId !== schedule.originStationId) {
- throw new ConflictException(
- `Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
- );
+ if (builtTrainId) {
+ // Train-bound schedule: only the built train's own consist may be
+ // pinned — wherever the wagons currently sit, they travel with the
+ // train, so no yard/status gate applies.
+ if (physicalWagon.trainId !== builtTrainId) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is not part of this schedule's train`,
+ );
+ }
+ } else {
+ if (physicalWagon.trainId) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is coupled to a built train and cannot be pinned as a loose wagon`,
+ );
+ }
+ if (!this.isWagonPhysicallyUsable(physicalWagon)) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is not available (${physicalWagon.status})`,
+ );
+ }
+ if (
+ physicalWagon.currentTrainScheduleId &&
+ physicalWagon.currentTrainScheduleId !== scheduleId
+ ) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is out on a dispatched train`,
+ );
+ }
+ if (physicalWagon.currentYardId !== schedule.originStationId) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
+ );
+ }
}
+ // The pin lives ONLY on the schedule's slot — the Wagon entity keeps
+ // its status untouched so other schedules can still use the wagon.
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
physicalWagonId: assignment.physicalWagonId,
status: 'RESERVED',
});
- await manager.getRepository(Wagon).update(assignment.physicalWagonId, {
- trainSetWagonId: assignment.trainSetWagonId,
- currentTrainScheduleId: scheduleId,
- status: WagonStatus.Assigned,
- });
+ for (const [physicalId, slotId] of slotIdByPhysicalId) {
+ if (slotId === assignment.trainSetWagonId) {
+ slotIdByPhysicalId.delete(physicalId);
+ break;
+ }
+ }
+ slotIdByPhysicalId.set(assignment.physicalWagonId, assignment.trainSetWagonId);
}
});
@@ -1837,6 +1900,22 @@ export class TrainSchedulingService {
// at a time — block dispatch while any set locomotive is out on a dispatched train.
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
+ // Same rule for wagons: many schedules may pin the same wagon, but it can
+ // only be OUT on one dispatched train at a time.
+ const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? [])
+ .map((slot) => slot.physicalWagonId)
+ .filter((id): id is string => Boolean(id));
+ if (pinnedPhysicalIds.length) {
+ const rolling = await this.dataSource.getRepository(Wagon).find({
+ where: { id: In(pinnedPhysicalIds), currentTrainScheduleId: Not(IsNull()) },
+ });
+ const busy = rolling.filter((w) => w.currentTrainScheduleId !== scheduleId);
+ if (busy.length) {
+ throw new ConflictException(
+ `Cannot dispatch: wagon(s) ${busy.map((w) => w.wagonNumber).join(', ')} are still out on another dispatched train`,
+ );
+ }
+ }
const now = new Date();
await this.dataSource.transaction(async (manager) => {
@@ -2674,7 +2753,23 @@ export class TrainSchedulingService {
manager: EntityManager,
schedule: TrainSchedule,
): Promise {
- if (schedule.trainNumber) return schedule.trainNumber;
+ if (schedule.trainNumber) {
+ // Creation-assigned pair number: two live runs may never share a number,
+ // so block dispatch while another DISPATCHED schedule still carries it.
+ const clash = await manager
+ .getRepository(TrainSchedule)
+ .createQueryBuilder('s')
+ .where('s.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
+ .andWhere('s.train_number = :trainNumber', { trainNumber: schedule.trainNumber })
+ .andWhere('s.id != :id', { id: schedule.id })
+ .getOne();
+ if (clash) {
+ throw new ConflictException(
+ `Train number ${schedule.trainNumber} is already out on ${clash.reference ?? clash.id}; it must arrive before this train dispatches`,
+ );
+ }
+ return schedule.trainNumber;
+ }
// Count container vs bulk wagons from the planned allocations.
let containerWagons = 0;
@@ -2695,17 +2790,38 @@ export class TrainSchedulingService {
// Lock the set of currently-active numbered schedules so two concurrent
// dispatches serialize and can't both claim the same lowest-free number.
+ // DRAFT/SCHEDULED are included because pair numbers are now assigned at
+ // creation and must be invisible to pool picks.
const activeNumbered = await manager
.getRepository(TrainSchedule)
.createQueryBuilder('schedule')
.setLock('pessimistic_write')
- .where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
+ .where('schedule.status IN (:...statuses)', {
+ statuses: [
+ TrainScheduleStatusEnum.Draft,
+ TrainScheduleStatusEnum.Scheduled,
+ TrainScheduleStatusEnum.Dispatched,
+ ],
+ })
.andWhere('schedule.train_number IS NOT NULL')
.getMany();
- const usedNumbers = activeNumbered
- .map((s) => s.trainNumber)
- .filter((n): n is string => Boolean(n));
+ // Every typed train pair is reserved for its train — the pool may never
+ // hand one out, even when that train has no active schedule right now.
+ const pairRows: { n: string }[] = await manager.query(
+ `SELECT import_train_number AS n FROM freight.trains
+ WHERE deleted_at IS NULL AND import_train_number IS NOT NULL
+ UNION
+ SELECT export_train_number FROM freight.trains
+ WHERE deleted_at IS NULL AND export_train_number IS NOT NULL`,
+ );
+
+ const usedNumbers = [
+ ...activeNumbered
+ .map((s) => s.trainNumber)
+ .filter((n): n is string => Boolean(n)),
+ ...pairRows.map((row) => row.n),
+ ];
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
if (!number) {
@@ -3675,10 +3791,11 @@ export class TrainSchedulingService {
originYardId: string,
targetScheduleId?: string,
): Promise> {
- const [wagons, wagonTypes, builtTrainId] = await Promise.all([
+ const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.dataSource.getRepository(WagonType).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
+ this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]);
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map();
@@ -3690,10 +3807,20 @@ export class TrainSchedulingService {
if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue;
} else {
- const pinnedOnTarget = targetScheduleId
- ? wagon.currentTrainScheduleId === targetScheduleId
- : false;
- if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue;
+ // Schedule-scoped availability: pins held by OTHER schedules never
+ // consume a wagon here — the same physical wagon may serve the July 17
+ // and the July 20 run. A wagon is unusable only when it is coupled to a
+ // built train's consist, physically blocked, or out on a dispatched
+ // train right now.
+ const pinnedOnTarget = pinnedToTargetIds.has(wagon.id);
+ if (wagon.trainId) continue;
+ if (!this.isWagonPhysicallyUsable(wagon) && !pinnedOnTarget) continue;
+ if (
+ wagon.currentTrainScheduleId &&
+ wagon.currentTrainScheduleId !== targetScheduleId
+ ) {
+ continue;
+ }
if (wagon.currentYardId !== originYardId) continue;
}
@@ -3752,22 +3879,59 @@ export class TrainSchedulingService {
};
}
- private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
- const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
- const physicalIds = slots
- .map((slot) => slot.physicalWagonId)
- .filter((id): id is string => Boolean(id));
- if (!physicalIds.length) return;
- const wagons = await manager.getRepository(Wagon).find({ where: { id: In(physicalIds) } });
- for (const wagon of wagons) {
- await manager.getRepository(Wagon).update(wagon.id, {
- // Built-train wagons stay coupled to their train (ASSIGNED); loose
- // wagons return to the open AVAILABLE pool.
- status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
- trainSetWagonId: null,
- currentTrainScheduleId: null,
- });
- }
+ /**
+ * A wagon in a blocked physical state can never be planned or pinned.
+ * ASSIGNED no longer blocks: it only means the wagon is coupled to a built
+ * train or stamped by a live run — schedule-level occupancy is tracked on
+ * the schedule's own TrainSetWagon slots, never on the Wagon entity.
+ */
+ private isWagonPhysicallyUsable(wagon: Wagon): boolean {
+ return (
+ wagon.status === WagonStatus.Available || wagon.status === WagonStatus.Assigned
+ );
+ }
+
+ /**
+ * Physical wagons already pinned to THIS schedule's slots. Availability is
+ * schedule-scoped: only a duplicate pin within the same schedule conflicts;
+ * pins held by other schedules of the same train are irrelevant.
+ */
+ private async pinnedPhysicalWagonIdsForSchedule(
+ scheduleId: string | undefined,
+ manager?: EntityManager,
+ ): Promise> {
+ if (!scheduleId) return new Set();
+ const runner = manager ?? this.dataSource;
+ const rows: { physical_wagon_id: string }[] = await runner.query(
+ `SELECT tsw.physical_wagon_id
+ FROM freight.train_set_wagons tsw
+ JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
+ WHERE ts.id = $1
+ AND ts.deleted_at IS NULL
+ AND tsw.deleted_at IS NULL
+ AND tsw.physical_wagon_id IS NOT NULL`,
+ [scheduleId],
+ );
+ return new Set(rows.map((row) => row.physical_wagon_id));
+ }
+
+ /**
+ * Physical wagons pinned to any slot of a live (DRAFT/SCHEDULED/DISPATCHED)
+ * schedule. Used to guard consist trims — the Wagon entity itself carries no
+ * schedule-occupancy state anymore.
+ */
+ private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise> {
+ const runner = manager ?? this.dataSource;
+ const rows: { physical_wagon_id: string }[] = await runner.query(
+ `SELECT DISTINCT tsw.physical_wagon_id
+ FROM freight.train_set_wagons tsw
+ JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
+ WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
+ AND ts.deleted_at IS NULL
+ AND tsw.deleted_at IS NULL
+ AND tsw.physical_wagon_id IS NOT NULL`,
+ );
+ return new Set(rows.map((row) => row.physical_wagon_id));
}
private async autoPinWagonsForSchedule(
@@ -3779,6 +3943,10 @@ export class TrainSchedulingService {
const wagons = await manager.getRepository(Wagon).find();
const wagonTypes = await manager.getRepository(WagonType).find();
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
+ const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
+ scheduleId,
+ manager,
+ );
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
const planSlots = [...slots]
@@ -3797,6 +3965,7 @@ export class TrainSchedulingService {
scheduleId,
originYardId,
builtTrainId,
+ pinnedToScheduleIds,
);
if (unpinnable.length) {
throw new BadRequestException({
@@ -3814,18 +3983,17 @@ export class TrainSchedulingService {
originYardId,
assignedPhysicalIds,
builtTrainId,
+ pinnedToScheduleIds,
);
if (!physical) continue;
+ // Pin lives ONLY on the schedule's own slot — the Wagon entity is never
+ // touched here, so the same physical wagon stays free for every other
+ // schedule (it gets stamped at dispatch, when it physically leaves).
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
physicalWagonId: physical.id,
status: 'RESERVED',
});
- await manager.getRepository(Wagon).update(physical.id, {
- trainSetWagonId: slot.trainSetWagonId,
- currentTrainScheduleId: scheduleId,
- status: WagonStatus.Assigned,
- });
assignedPhysicalIds.add(physical.id);
}
}
@@ -3838,9 +4006,10 @@ export class TrainSchedulingService {
): Promise {
if (!wagonPlan.length) return [];
- const [wagons, builtTrainId] = await Promise.all([
+ const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
+ this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]);
return this.findUnpinnableWagonSlots(
wagonPlan.map((slot) => ({
@@ -3853,6 +4022,7 @@ export class TrainSchedulingService {
targetScheduleId,
originYardId,
builtTrainId,
+ pinnedToScheduleIds,
);
}
@@ -3867,6 +4037,7 @@ export class TrainSchedulingService {
scheduleId: string | undefined,
originYardId: string,
builtTrainId: string | null = null,
+ pinnedToScheduleIds: Set = new Set(),
): string[] {
const violations: string[] = [];
const assignedPhysicalIds = new Set();
@@ -3879,6 +4050,7 @@ export class TrainSchedulingService {
originYardId,
assignedPhysicalIds,
builtTrainId,
+ pinnedToScheduleIds,
);
if (!physical) {
violations.push(
@@ -3904,14 +4076,22 @@ export class TrainSchedulingService {
originYardId: string,
assignedPhysicalIds: Set,
builtTrainId: string | null = null,
+ pinnedToScheduleIds: Set = new Set(),
): Wagon | undefined {
const usable = (wagon: Wagon): boolean => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
if (assignedPhysicalIds.has(wagon.id)) return false;
- const pinnedOnSchedule = scheduleId
- ? wagon.currentTrainScheduleId === scheduleId
- : false;
- return wagon.status === WagonStatus.Available || pinnedOnSchedule;
+ // Loose pool never lends a wagon coupled to a built train's consist.
+ if (wagon.trainId) return false;
+ // Out on a dispatched train right now — physically gone.
+ if (
+ wagon.currentTrainScheduleId &&
+ wagon.currentTrainScheduleId !== scheduleId
+ ) {
+ return false;
+ }
+ const pinnedOnSchedule = pinnedToScheduleIds.has(wagon.id);
+ return this.isWagonPhysicallyUsable(wagon) || pinnedOnSchedule;
};
// Train-bound schedule: ONLY the built train's own wagons may be pinned —
// wherever they currently sit (they travel with the train), never a loose
@@ -4614,6 +4794,8 @@ export class TrainSchedulingService {
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
+ importTrainNumber: train.importTrainNumber ?? null,
+ exportTrainNumber: train.exportTrainNumber ?? null,
currentYardId: train.currentYardId ?? null,
currentYard: train.currentYard
? {
@@ -4684,6 +4866,7 @@ export class TrainSchedulingService {
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
+ const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
@@ -4740,8 +4923,8 @@ export class TrainSchedulingService {
wagons: wagons.map((wagon) => ({
...mapWagon(wagon),
loaded: loadedWagonIds.has(wagon.id),
- // Free = not pinned to any run; only free wagons can be trimmed.
- removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id),
+ // Free = not pinned to any live run's slot; only free wagons can be trimmed.
+ removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id),
})),
addableWagons: addableWagons.map(mapWagon),
adjustments: adjustments.map((log) => ({
@@ -4820,13 +5003,14 @@ export class TrainSchedulingService {
const consistById = new Map(consist.map((w) => [w.id, w]));
// --- validate removals: must be coupled and free (no cargo, no pin) ---
+ const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager);
const removed: Wagon[] = [];
for (const wagonId of removeWagonIds) {
const wagon = consistById.get(wagonId);
if (!wagon) {
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
}
- if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) {
+ if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
);
@@ -5307,10 +5491,10 @@ export class TrainSchedulingService {
/**
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
- * train capacity (not fully allocated). Wagon availability is deliberately NOT
- * checked here: whether a matching wagon currently sits in the right yard is an
- * operational question staff resolve when they approve or reject the booking,
- * not something the customer can act on while choosing a date. Same
+ * train capacity (not fully allocated) AND its wagon stock can physically carry
+ * the selected cargo/container type (wagon-TYPE gate). Quantity is deliberately
+ * NOT gated — a booking bigger than the free capacity is accepted and the batch
+ * engine offers a partial split later. No counts are exposed: same
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
* not a train.
*/
@@ -5318,9 +5502,11 @@ export class TrainSchedulingService {
originYardId?: string;
destinationYardId?: string;
freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
cargoTypeCode?: string | null;
totalWeightTons?: number;
containers?: Array<{ containerSize: string; quantity: number }>;
+ containerTypeIds?: string[];
}): Promise<{ days: string[] }> {
const schedules = await this.getBookableScheduleEntities(
input.originYardId,
@@ -5328,17 +5514,233 @@ export class TrainSchedulingService {
);
if (schedules.length === 0) return { days: [] };
+ const withCapacity = schedules.filter(
+ (s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0,
+ );
+ const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input);
+
const days = new Set();
- for (const s of schedules) {
- const hasCapacity =
- Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
- if (!hasCapacity) continue;
+ for (const s of compatible) {
if (s.scheduledDepartureDate)
days.add(eatDay(new Date(s.scheduledDepartureDate)));
}
return { days: [...days].sort() };
}
+ /**
+ * Wagon-TYPE compatibility gate (customer booking): keep only the schedules
+ * whose wagon stock can physically carry the selected cargo — every container
+ * line (or the bulk cargo type) must map to at least one wagon type the
+ * schedule's stock actually has. Stock = the built train's own consist, or the
+ * origin yard's loose pool for schedules assembled from loose locomotives.
+ * QUANTITY is deliberately ignored: an over-sized booking is allowed and gets
+ * a partial split offer from the batch engine later.
+ */
+ private async filterCargoCompatibleSchedules(
+ schedules: TrainSchedule[],
+ cargo: {
+ freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
+ cargoTypeCode?: string | null;
+ containers?: Array<{ containerSize: string; quantity: number }>;
+ containerTypeIds?: string[];
+ },
+ ): Promise {
+ if (!schedules.length) return schedules;
+ const required = await this.requiredWagonTypeSets(cargo);
+ // No cargo identity supplied — nothing to gate on (legacy callers).
+ if (required === null) return schedules;
+
+ const stockByScheduleId = await this.scheduleWagonTypeStock(schedules);
+ return schedules.filter((s) => {
+ const stock = stockByScheduleId.get(s.id) ?? new Set();
+ return required.every((set) => {
+ for (const typeId of set) if (stock.has(typeId)) return true;
+ return false;
+ });
+ });
+ }
+
+ /**
+ * One Set of allowed wagon-type ids per required cargo dimension: per
+ * container line's type (or per container size when only sizes are known),
+ * or a single set for the bulk cargo type. `null` = no cargo identity given,
+ * skip gating. An EMPTY set means "nothing can carry this" (no wagon types
+ * configured) — the gate then blocks every schedule, mirroring the hard
+ * config violation scheduling raises for the same state.
+ */
+ private async requiredWagonTypeSets(cargo: {
+ freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
+ cargoTypeCode?: string | null;
+ containers?: Array<{ containerSize: string; quantity: number }>;
+ containerTypeIds?: string[];
+ }): Promise[] | null> {
+ if (cargo.freightType === 'CONTAINER') {
+ const typeIds = [...new Set((cargo.containerTypeIds ?? []).filter(Boolean))];
+ if (typeIds.length) {
+ const rows: { container_type_id: string; wagon_type_id: string | null }[] =
+ await this.dataSource.query(
+ `SELECT ct.id AS container_type_id, wt.id AS wagon_type_id
+ FROM freight.container_types ct
+ LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
+ LEFT JOIN freight.wagon_types wt
+ ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
+ WHERE ct.id = ANY($1::uuid[]) AND ct.deleted_at IS NULL`,
+ [typeIds],
+ );
+ const byType = new Map>(typeIds.map((id) => [id, new Set()]));
+ for (const row of rows) {
+ if (row.wagon_type_id) byType.get(row.container_type_id)?.add(row.wagon_type_id);
+ }
+ return [...byType.values()];
+ }
+ // Legacy callers only know sizes ("20ft"/"40ft"): a size is carriable when
+ // ANY active container type of that size has a matching wagon type.
+ const sizes = [
+ ...new Set(
+ (cargo.containers ?? [])
+ .map((line) => parseInt(String(line.containerSize), 10))
+ .filter((n) => Number.isFinite(n) && n > 0),
+ ),
+ ];
+ if (!sizes.length) return null;
+ const rows: { size_ft: number; wagon_type_id: string | null }[] =
+ await this.dataSource.query(
+ `SELECT ct.size_ft, wt.id AS wagon_type_id
+ FROM freight.container_types ct
+ LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
+ LEFT JOIN freight.wagon_types wt
+ ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
+ WHERE ct.size_ft = ANY($1::int[]) AND ct.deleted_at IS NULL
+ AND (ct.is_active IS DISTINCT FROM false)`,
+ [sizes],
+ );
+ const bySize = new Map>(sizes.map((s) => [s, new Set()]));
+ for (const row of rows) {
+ if (row.wagon_type_id) bySize.get(Number(row.size_ft))?.add(row.wagon_type_id);
+ }
+ return [...bySize.values()];
+ }
+
+ if (!cargo.cargoTypeId && !cargo.cargoTypeCode) return null;
+ const rows: { wagon_type_id: string | null }[] = await this.dataSource.query(
+ `SELECT wt.id AS wagon_type_id
+ FROM freight.cargo_types c
+ LEFT JOIN freight.cargo_type_wagon_types ctwt ON ctwt.cargo_type_id = c.id
+ LEFT JOIN freight.wagon_types wt
+ ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
+ WHERE c.deleted_at IS NULL
+ AND (($1::uuid IS NOT NULL AND c.id = $1::uuid) OR ($1::uuid IS NULL AND c.code = $2))`,
+ [cargo.cargoTypeId ?? null, cargo.cargoTypeCode ?? null],
+ );
+ const set = new Set();
+ for (const row of rows) if (row.wagon_type_id) set.add(row.wagon_type_id);
+ return [set];
+ }
+
+ /**
+ * Wagon-type ids each schedule's stock can offer: the built train's own
+ * consist for train-bound schedules, the origin yard's loose usable pool
+ * otherwise. Batched — two queries for the whole schedule list.
+ */
+ private async scheduleWagonTypeStock(
+ schedules: TrainSchedule[],
+ ): Promise
+ ) : noWagonForSelectedDay ? (
+
+
+
+ No wagon available for the selected day
+
+
+ No train departing that day has a wagon type that can carry
+ your cargo. Go back to the route step and pick one of the
+ available days.
+
+
+
) : (
Ready to submit. You'll review the unit rates before final
@@ -567,7 +583,7 @@ export function Step8Review({
leftSection={}
onClick={onSubmit}
loading={submitPending}
- disabled={submitPending || hasOdd20ft}
+ disabled={submitPending || hasOdd20ft || noWagonForSelectedDay}
>
Submit
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx
index 3b7ef132b..a8d9d0830 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx
@@ -18,7 +18,8 @@ import {
ChevronRight,
Clock,
} from "lucide-react";
-import { CountdownTimer } from "@edr/ui-common";
+import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common";
+import type { BookingWindowUiKind } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import {
@@ -90,50 +91,29 @@ function windowLabel(w: MyBookingWindow): string {
}
/**
- * The countdown for whichever phase the window is currently in, mirroring the
- * home dashboard's Booking Windows card. `expiredText` names the NEXT step so a
- * deadline that lapses between refetches announces what comes next rather than
- * the bare "Expired".
+ * The countdown for the window's UI state, mirroring the home dashboard's
+ * Booking Windows card. Derived from the SAME state as the badge
+ * (`bookingWindowUiState`) so they can never contradict — a full train shows
+ * no ticking countdown. `expiredText` names the NEXT step so a deadline that
+ * lapses between refetches announces what comes next rather than the bare
+ * "Expired".
*/
+const COUNTDOWN_TEXT: Partial<
+ Record
+> = {
+ PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" },
+ OPEN: { label: "Window closes in", expiredText: "Document review starting…" },
+ DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" },
+ PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" },
+};
+
function phaseCountdown(
w: MyBookingWindow,
): { label: string; deadline: string; expiredText: string } | null {
- switch (w.windowPhase) {
- case "PRE_WINDOW":
- return w.windowOpensAt
- ? {
- label: "Booking opens in",
- deadline: w.windowOpensAt,
- expiredText: "Booking opening now…",
- }
- : null;
- case "OPEN":
- return w.windowClosesAt
- ? {
- label: "Window closes in",
- deadline: w.windowClosesAt,
- expiredText: "Document review starting…",
- }
- : null;
- case "DOC_REVIEW":
- return w.docReviewEndsAt
- ? {
- label: "Document review ends in",
- deadline: w.docReviewEndsAt,
- expiredText: "Payment starting…",
- }
- : null;
- case "PAYMENT":
- return w.paymentPhaseEndsAt
- ? {
- label: "Payment due in",
- deadline: w.paymentPhaseEndsAt,
- expiredText: "Payment window closing…",
- }
- : null;
- default:
- return null;
- }
+ const state = bookingWindowUiState(w);
+ const text = COUNTDOWN_TEXT[state.kind];
+ if (!state.countdownTo || !text) return null;
+ return { ...text, deadline: state.countdownTo };
}
/**
@@ -148,9 +128,21 @@ function isPast(w: MyBookingWindow): boolean {
return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY";
}
+/** Badge label + Mantine color per UI state — same state the countdown uses. */
+const KIND_BADGE: Record = {
+ OPEN: { label: "Open now", color: "edr-green" },
+ FULL: { label: "Train full", color: "red" },
+ PRE_WINDOW: { label: "Opens soon", color: "yellow" },
+ DOC_REVIEW: { label: "Document review", color: "gray" },
+ PAYMENT: { label: "Payment due", color: "gray" },
+ CLOSED: { label: "Closed", color: "gray" },
+};
+
function WindowCard({ w }: { w: MyBookingWindow }) {
const cd = phaseCountdown(w);
- const open = w.isOpenNow;
+ const state = bookingWindowUiState(w);
+ const badge = KIND_BADGE[state.kind];
+ const open = state.isBookable;
const isImport = w.direction === "IMPORT";
return (
@@ -183,13 +175,11 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
)}
- {open
- ? "Open now"
- : windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus)}
+ {badge.label}
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index 6f4527c0b..a82945ecf 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -423,6 +423,12 @@ export const api = {
bookingsService.getAvailableDaysForCargo(input),
),
+ getAvailableDaysForBooking: endpoint<{ bookingId: string }, string[]>(
+ "train-scheduling",
+ "availableDaysForBooking",
+ ({ bookingId }) => bookingsService.getAvailableDaysForBooking(bookingId),
+ ),
+
getMyBookingWindows: endpoint(
"train-scheduling",
"myBookingWindows",
diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts
index 88113c451..910ba89a0 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -414,25 +414,38 @@ export const bookingsService = {
return (data.data as Freight.AvailableDaysResponse).days;
},
- // Cargo-aware day pool: only days where a train has remaining capacity AND
- // enough matching-type wagons for this cargo. `containers` is serialized as a
- // JSON string param (the server parses it).
+ // Cargo-aware day pool: only days where a train has remaining capacity AND a
+ // wagon TYPE that can carry this cargo. `containers`/`containerTypeIds` are
+ // serialized as JSON string params (the server parses them). Days only — no
+ // capacity counts are ever returned.
getAvailableDaysForCargo: async (
query: Freight.AvailableDaysForCargoQuery,
): Promise => {
- const { containers, ...rest } = query;
+ const { containers, containerTypeIds, ...rest } = query;
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
{
params: {
...rest,
...(containers ? { containers: JSON.stringify(containers) } : {}),
+ ...(containerTypeIds?.length
+ ? { containerTypeIds: JSON.stringify(containerTypeIds) }
+ : {}),
},
},
);
return (data.data as Freight.AvailableDaysResponse).days;
},
+ // Days bookable for an EXISTING booking (operation-request step): the server
+ // derives the cargo from the booking and applies the wagon-type gate.
+ getAvailableDaysForBooking: async (bookingId: string): Promise => {
+ const { data } = await client.get(
+ `/api/bookings/${bookingId}/available-days`,
+ );
+ return (data.data as Freight.AvailableDaysResponse).days;
+ },
+
/**
* Upcoming/open booking windows on the signed-in customer's active-contract
* lanes (import booking-day windows + export 24h pre-departure windows).
diff --git a/apps/edr-passenger-api/src/common/utils/journey-direction.utils.ts b/apps/edr-passenger-api/src/common/utils/journey-direction.utils.ts
new file mode 100644
index 000000000..9901eed36
--- /dev/null
+++ b/apps/edr-passenger-api/src/common/utils/journey-direction.utils.ts
@@ -0,0 +1,37 @@
+import { JourneyDirection } from '../../modules/seats/seats.dto';
+
+/**
+ * Shared by SeatsService (seatmap display, hold-creation conflict checks) and
+ * SegmentsService (search results' availability counts, EnhancedSeatsService) — the
+ * single source of truth for whether two journey directions on the same schedule
+ * should be treated as conflicting. Without this, a round-trip's OUTBOUND and RETURN
+ * legs on the same schedule would wrongly block each other's seats.
+ *
+ * Check if two journey directions conflict (should not be allowed simultaneously).
+ * For round-trip bookings: OUTBOUND and RETURN should NOT conflict on the same schedule.
+ */
+export function checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean {
+ // OUTBOUND and RETURN are allowed simultaneously (round-trip on the same schedule)
+ if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) ||
+ (current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) {
+ return false;
+ }
+
+ // Same directions conflict (e.g., two OUTBOUND or two RETURN bookings)
+ if (current === existing) {
+ return true;
+ }
+
+ // ONE_WAY conflicts with other ONE_WAY bookings only
+ if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) {
+ return true;
+ }
+
+ // ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings)
+ if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) {
+ return true;
+ }
+
+ // Default: no conflict
+ return false;
+}
diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts
new file mode 100644
index 000000000..1d4c286be
--- /dev/null
+++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts
@@ -0,0 +1,22 @@
+/**
+ * Single source of truth for how long a PENDING_PAYMENT booking has to be paid for,
+ * shared by TasksService (which auto-cancels bookings past this deadline) and
+ * SeatsService (which extends the seat hold to cover exactly this window when a
+ * booking/PNR is created — without this, the seat hold reverted to its original
+ * short seat-selection TTL and could expire mid-payment, letting a second customer
+ * grab the same seat).
+ */
+
+/** Maximum time (hours) a passenger has to pay after booking. */
+export const MAX_PAYMENT_HOURS = 2;
+/** Minutes before departure: cutoff for new bookings and payment deadline. */
+export const CUTOFF_MINUTES = 30;
+
+/**
+ * payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
+ */
+export function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
+ const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
+ const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
+ return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
+}
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
index 73887454c..42d4af0d9 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
@@ -1,4 +1,4 @@
-import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDate, MaxDate } from 'class-validator';
+import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsNumber, IsEnum, IsDate, MaxDate } from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
@@ -21,8 +21,8 @@ export class PassengerInputDto {
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
- @ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). When provided, overrides the fare engine calculation — use for berth-specific pricing (Upper/Middle/Lower).' }) @IsOptional() @IsInt() seatFareMinor?: number;
- @ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) @IsOptional() @IsInt() returnSeatFareMinor?: number;
+ @ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). When provided, overrides the fare engine calculation — use for berth-specific pricing (Upper/Middle/Lower).' }) @IsOptional() @IsNumber() seatFareMinor?: number;
+ @ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) @IsOptional() @IsNumber() returnSeatFareMinor?: number;
}
export class RoundTripPassengerDto {
@@ -146,7 +146,7 @@ export class CreateBookingDto {
@IsOptional() @IsString() priceTierId?: string;
@ApiPropertyOptional({ description: 'Total amount in display-currency minor units as computed and displayed on the review page. When displayCurrency is ETB this equals ETB minor units; for DJF/USD it is the converted display amount. The backend uses this directly as displayTotalMinor and back-converts to ETB for storage.' })
- @IsOptional() @IsInt() reviewedTotalMinor?: number;
+ @IsOptional() @IsNumber() reviewedTotalMinor?: number;
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
@IsOptional() @IsString() promoCode?: string;
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
index dea2517c0..a6703f55e 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -321,7 +321,7 @@ export class BookingsService {
bookingRef: b.bookingRef,
status: b.status,
totalMinor: b.totalMinor,
- currency: b.currency || 'ETB',
+ currency: b.currency || null,
displayCurrency: b.displayCurrency ?? null,
displayTotalMinor: b.displayTotalMinor ?? null,
adultCount: b.adultCount,
@@ -554,7 +554,8 @@ export class BookingsService {
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
- totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
+ totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
+ currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true,
@@ -577,7 +578,7 @@ export class BookingsService {
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status,
- totalMinor: b.totalMinor, currency: b.currency || 'ETB',
+ totalMinor: b.totalMinor, currency: b.currency || b.displayCurrency,
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
@@ -723,7 +724,7 @@ export class BookingsService {
bookingRef: b.bookingRef,
status: b.status,
totalMinor: b.totalMinor,
- currency: b.currency || 'ETB',
+ currency: b.currency || b.displayCurrency,
displayCurrency: b.displayCurrency,
displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail,
@@ -846,23 +847,22 @@ export class BookingsService {
// Free children have no seatId and no seatFareMinor — exclude them from the check.
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
- // reviewedTotalMinor is now sent in display-currency minor units from the review page.
- // When displayCurrency != ETB, use it directly as displayTotalMinor and back-convert to ETB.
+ // seatFareMinor values from the client are in display-currency minor units (matching
+ // displayAmountMinor from search results). reviewedTotalMinor is also display-currency minor.
+ // In both cases: store as displayTotalMinor as-is, back-convert to ETB for totalMinor.
let resolvedTotalMinor: number;
let displayTotalMinor: number;
if (dto.reviewedTotalMinor != null) {
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = dto.reviewedTotalMinor;
- resolvedTotalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
- } else {
- resolvedTotalMinor = dto.reviewedTotalMinor;
- displayTotalMinor = dto.reviewedTotalMinor;
- }
+ displayTotalMinor = dto.reviewedTotalMinor;
+ resolvedTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
+ : dto.reviewedTotalMinor;
} else if (allFaresProvided) {
- resolvedTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
- displayTotalMinor = displayCurrency !== Currency.ETB
- ? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
- : resolvedTotalMinor;
+ // seatFareMinor is in display currency — sum is already the display total
+ displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
+ resolvedTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
+ : displayTotalMinor;
} else {
resolvedTotalMinor = fareCalculation.totalMinor;
displayTotalMinor = displayCurrency !== Currency.ETB
@@ -880,7 +880,8 @@ export class BookingsService {
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
- totalMinor: resolvedTotalMinor / 100,
+ totalMinor: resolvedTotalMinor,
+ currency: displayCurrency,
adultCount,
childCount,
displayCurrency,
@@ -1001,10 +1002,10 @@ export class BookingsService {
const taxesMinor = 0;
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
- let displayTotalMinor = totalMinor;
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
- }
+ // displayTotalMinor will be overridden below when reviewedTotalMinor is provided.
+ let displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
// present (berth-specific pricing). Fall back to fare engine values.
@@ -1036,20 +1037,16 @@ export class BookingsService {
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
if (dto.reviewedTotalMinor != null) {
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = dto.reviewedTotalMinor;
- totalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
- } else {
- totalMinor = dto.reviewedTotalMinor;
- displayTotalMinor = dto.reviewedTotalMinor;
- }
+ displayTotalMinor = dto.reviewedTotalMinor;
+ totalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
+ : dto.reviewedTotalMinor;
} else if (allRTFaresProvided && !dto.packageId) {
- totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
- } else {
- displayTotalMinor = totalMinor;
- }
+ // seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
+ displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
+ totalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
+ : displayTotalMinor;
}
const booking = await this.prisma.booking.create({
@@ -1062,6 +1059,7 @@ export class BookingsService {
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP',
totalMinor,
+ currency: displayCurrency,
adultCount,
childCount,
displayCurrency,
@@ -1254,6 +1252,7 @@ export class BookingsService {
status: 'PENDING_PAYMENT',
bookingType: 'TRANSIT',
totalMinor,
+ currency: displayCurrency,
adultCount,
childCount,
displayCurrency,
@@ -1319,7 +1318,7 @@ export class BookingsService {
combinedBaseFareMinor: combinedBase,
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor, totalMinor,
- currency: 'ETB', displayCurrency, displayTotalMinor,
+ currency: displayCurrency, displayTotalMinor,
},
};
}
@@ -1463,7 +1462,7 @@ export class BookingsService {
destinationStationId: dto.leg2DestinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP_TRANSIT',
- totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
+ totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor,
// Outbound transit leg-2
leg2ScheduleId: dto.leg2ScheduleId,
leg2OriginStationId: dto.transitStationId,
@@ -1518,7 +1517,7 @@ export class BookingsService {
combinedBaseFareMinor: combinedBase,
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor, totalMinor,
- currency: 'ETB', displayCurrency, displayTotalMinor,
+ currency: displayCurrency, displayTotalMinor,
},
};
}
@@ -1815,7 +1814,7 @@ export class BookingsService {
bookingRef: pkgBooking.bookingRef,
status: pkgBooking.status,
totalMinor: pkgBooking.totalMinor,
- currency: pkgBooking.currency || 'ETB',
+ currency: pkgBooking.currency || pkgBooking.displayCurrency,
adultCount: pkgBooking.passengerCount,
childCount: 0,
displayCurrency: pkgBooking.displayCurrency,
@@ -1875,10 +1874,13 @@ export class BookingsService {
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
- totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
+ totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
+ currency: booking.displayCurrency,
adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
bookingType: booking.bookingType,
+ packageId: (booking as any).packageId ?? null,
+ isPackageBooking: !!(booking as any).packageId,
returnLegStatus: (booking as any).returnLegStatus ?? null,
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (booking as any).returnBoardedAt ?? null,
@@ -1978,7 +1980,7 @@ export class BookingsService {
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
this.eventEmitter.emit('booking.cancelled', { booking, refundAmount });
await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'DELETE', entityType: 'Booking', entityId: booking.id, oldData: { bookingRef, status: booking.status }, newData: { status: 'CANCELLED', reason, refundAmount } });
- return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
+ return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency};
}
async update(id: string, dto: any) {
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
index f5a5559bb..f89b5228d 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
@@ -1,4 +1,4 @@
-import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt } from 'class-validator';
+import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
@@ -158,7 +158,7 @@ export class CreateGuestBookingDto {
@IsOptional() @IsString() priceTierId?: string;
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
- @IsOptional() @IsInt() reviewedTotalMinor?: number;
+ @IsOptional() @IsNumber() reviewedTotalMinor?: number;
}
export class SavedPassengerProfileDto {
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
index 321e2269b..821db5177 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
@@ -18,7 +18,7 @@ function generateRef(): string {
}
// Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx)
-const ETH_MOBILE_PREFIXES = ['911','912','913','914','915','916','917','921','922','923','924','930','931','932','933','934','935','936','937','938','939','961','962','963','964'];
+const ETH_MOBILE_PREFIXES = ['911', '912', '913', '914', '915', '916', '917', '921', '922', '923', '924', '930', '931', '932', '933', '934', '935', '936', '937', '938', '939', '961', '962', '963', '964'];
function generateEthiopianPhone(): string {
const prefix = ETH_MOBILE_PREFIXES[Math.floor(Math.random() * ETH_MOBILE_PREFIXES.length)];
@@ -50,7 +50,7 @@ export class GuestBookingService {
private passengerAuthService: PassengerAuthService,
private fareEngine: FareEngineService,
private eventEmitter: EventEmitter2,
- ) {}
+ ) { }
async createGuestBooking(dto: CreateGuestBookingDto, req?: any) {
// Enrich passengers with phone/email from SavedPassengerProfile when not supplied inline.
@@ -70,8 +70,8 @@ export class GuestBookingService {
});
}
}
- if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req);
- if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req);
+ if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req);
+ if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req);
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto, req);
return this.createGuestOneWayBooking(dto, req);
}
@@ -123,8 +123,8 @@ export class GuestBookingService {
let nationality = passenger.nationality;
const isEthiopian = passenger.nationality === 'Ethiopian' ||
- passenger.nationality === 'ETHIOPIAN' ||
- passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
+ passenger.nationality === 'ETHIOPIAN' ||
+ passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) {
@@ -221,20 +221,28 @@ export class GuestBookingService {
return { ...p, fareMinor };
});
- // Use reviewedTotalMinor from frontend as authoritative total when provided.
- // Fall back to per-seat sum when all seated passengers supplied seatFareMinor.
+ // reviewedTotalMinor and seatFareMinor are both in display-currency minor units.
+ // Store as displayTotalMinor as-is; back-convert to ETB for totalMinor.
+ const displayCurrency = dto.displayCurrency || Currency.ETB;
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
- const resolvedTotalMinor = dto.reviewedTotalMinor ??
- (allFaresProvided
- ? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
- : Math.max(0, totalBaseFareMinor - discountMinor));
- const displayCurrency = dto.displayCurrency || Currency.ETB;
- let displayTotalMinor = resolvedTotalMinor;
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
+ let displayTotalMinor: number;
+ let resolvedTotalMinor: number;
+ if (dto.reviewedTotalMinor != null) {
+ displayTotalMinor = dto.reviewedTotalMinor;
+ } else if (allFaresProvided) {
+ displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
+ } else {
+ // fare engine returns ETB — convert forward to display currency
+ const etbTotal = Math.max(0, totalBaseFareMinor - discountMinor);
+ displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(etbTotal, Currency.ETB, displayCurrency)
+ : etbTotal;
}
+ resolvedTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
+ : displayTotalMinor;
// Resolve or create the guest Passenger record
const firstPassenger = passengersData[0];
@@ -270,13 +278,14 @@ export class GuestBookingService {
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
totalMinor: resolvedTotalMinor,
+ currency: displayCurrency,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
bookingType: 'ONE_WAY',
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
- userAgent: dto.deviceId,
+ userAgent: dto.deviceId,
contactEmail: firstPassenger.email || null,
contactPhone: firstPassenger.phone || null,
seats: {
@@ -342,7 +351,7 @@ export class GuestBookingService {
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
]);
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired or not found');
- if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found');
+ if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found');
// Validate passengers have returnSeatId
for (const p of dto.passengers) {
@@ -361,7 +370,7 @@ export class GuestBookingService {
}),
]);
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
- if (!returnSchedule) throw new NotFoundException('Return schedule not found');
+ if (!returnSchedule) throw new NotFoundException('Return schedule not found');
if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
@@ -371,19 +380,19 @@ export class GuestBookingService {
const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation;
return { stationId, sequence: seq, station };
};
- const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)];
- const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)];
- const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0];
- const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1];
- const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0];
- const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1];
+ const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)];
+ const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)];
+ const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0];
+ const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1];
+ const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0];
+ const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1];
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
- if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
+ if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`;
- const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
- const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
- const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`;
+ const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
+ const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
+ const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`;
// Process passengers (verify identity once — same person travels both legs)
const passengersData: any[] = [];
@@ -401,16 +410,16 @@ export class GuestBookingService {
let nationality = passenger.nationality;
const isEthiopian = passenger.nationality === 'Ethiopian' ||
- passenger.nationality === 'ETHIOPIAN' ||
- passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
+ passenger.nationality === 'ETHIOPIAN' ||
+ passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
- passengerName = verification.passengerData?.fullName || passengerName;
+ passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
- verifaydaData = verification.passengerData?.profileData;
+ verifaydaData = verification.passengerData?.profileData;
}
nationality = 'Ethiopian';
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
@@ -442,7 +451,7 @@ export class GuestBookingService {
returnBaseFare = tier.priceMinor - halfMinor;
paidChildrenCount = childCount;
outboundChildUnitFare = Math.round(outboundBaseFare * 0.1);
- returnChildUnitFare = Math.round(returnBaseFare * 0.1);
+ returnChildUnitFare = Math.round(returnBaseFare * 0.1);
} else {
const primaryNationality = passengersData[0]?.nationality;
[outboundBaseFare, returnBaseFare] = await Promise.all([
@@ -451,11 +460,11 @@ export class GuestBookingService {
]);
paidChildrenCount = Math.max(0, childCount - 1);
outboundChildUnitFare = outboundBaseFare;
- returnChildUnitFare = returnBaseFare;
+ returnChildUnitFare = returnBaseFare;
}
- const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount;
- const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount;
- const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
+ const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount;
+ const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount;
+ const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
let discountMinor = 0;
if (dto.promoCode) {
@@ -482,16 +491,16 @@ export class GuestBookingService {
let outboundFareMinor: number;
let returnFareMinor: number;
if (p.category === PassengerCategory.ADULT) {
- outboundFareMinor = p.seatFareMinor ?? outboundBaseFare;
- returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare;
+ outboundFareMinor = p.seatFareMinor ?? outboundBaseFare;
+ returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare;
} else if (isPackageRoundTrip) {
outboundFareMinor = 0;
- returnFareMinor = 0;
+ returnFareMinor = 0;
} else {
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare;
- if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
- else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare;
+ if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
+ else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare;
}
return { ...p, outboundFareMinor, returnFareMinor };
});
@@ -501,16 +510,17 @@ export class GuestBookingService {
const rtSeatedPassengers = passengersData.filter(p => p.seatId);
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
- if (dto.reviewedTotalMinor) {
- totalMinor = dto.reviewedTotalMinor;
- displayTotalMinor = displayCurrency !== Currency.ETB
- ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
- : totalMinor;
+ if (dto.reviewedTotalMinor != null) {
+ displayTotalMinor = dto.reviewedTotalMinor;
+ totalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
+ : displayTotalMinor;
} else if (allRTFaresProvided && !isPackageRoundTrip) {
- totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
- displayTotalMinor = displayCurrency !== Currency.ETB
- ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
- : totalMinor;
+ // seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
+ displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
+ totalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
+ : displayTotalMinor;
}
// Create or resolve guest passenger (same as one-way)
@@ -518,69 +528,70 @@ export class GuestBookingService {
// Create booking with outbound seats; return seats confirmed separately
const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id);
- const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
+ const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
const booking = await this.prisma.booking.create({
data: {
- bookingRef: generateRef(),
- passengerId: guestPassengerId,
- scheduleId: dto.scheduleId,
- originStationId: dto.originStationId,
- destinationStationId: dto.destinationStationId,
- status: 'PENDING_PAYMENT',
- bookingType: 'ROUND_TRIP',
+ bookingRef: generateRef(),
+ passengerId: guestPassengerId,
+ scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.destinationStationId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'ROUND_TRIP',
totalMinor,
+ currency: displayCurrency,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
- returnScheduleId: dto.returnScheduleId,
- returnOriginStationId: dto.returnOriginStationId,
- returnDestinationStationId: dto.returnDestinationStationId,
- returnHoldId: dto.returnHoldId,
+ returnScheduleId: dto.returnScheduleId,
+ returnOriginStationId: dto.returnOriginStationId,
+ returnDestinationStationId: dto.returnDestinationStationId,
+ returnHoldId: dto.returnHoldId,
returnSeatClassId,
- returnLegStatus: 'NEITHER_USED',
+ returnLegStatus: 'NEITHER_USED',
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
- userAgent: dto.deviceId,
- contactEmail: passengersData[0]?.email || null,
- contactPhone: passengersData[0]?.phone || null,
+ userAgent: dto.deviceId,
+ contactEmail: passengersData[0]?.email || null,
+ contactPhone: passengersData[0]?.phone || null,
seats: {
create: [
...passengersWithFares.map((p) => ({
- seat: { connect: { id: p.seatId } },
- leg: 1,
- scheduleId: dto.scheduleId,
- passengerName: p.passengerName,
- dateOfBirth: p.dateOfBirth,
+ seat: { connect: { id: p.seatId } },
+ leg: 1,
+ scheduleId: dto.scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
- idDocumentType: p.idDocumentType,
- passportNumber: p.passportNumber,
- passportCountry: p.passportCountry,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
- verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.outboundFareMinor,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.outboundFareMinor,
displayCurrency,
})),
...passengersWithFares.map((p) => ({
- seat: { connect: { id: p.returnSeatId } },
- leg: 2,
- scheduleId: dto.returnScheduleId,
- passengerName: p.passengerName,
- dateOfBirth: p.dateOfBirth,
+ seat: { connect: { id: p.returnSeatId } },
+ leg: 2,
+ scheduleId: dto.returnScheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
- idDocumentType: p.idDocumentType,
- passportNumber: p.passportNumber,
- passportCountry: p.passportCountry,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
- verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.returnFareMinor,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.returnFareMinor,
displayCurrency,
})),
],
},
} as any,
include: {
- seats: { include: { seat: { include: { coach: true } } } },
+ seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
@@ -599,16 +610,16 @@ export class GuestBookingService {
iamUserId,
fareBreakdown: {
outboundBaseFareMinor: outboundBaseFare,
- returnBaseFareMinor: returnBaseFare,
+ returnBaseFareMinor: returnBaseFare,
adultCount,
childCount,
- freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1),
+ freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1),
paidChildrenCount,
combinedBaseFareMinor,
discountMinor,
- taxesFeesMinor: taxesMinor,
+ taxesFeesMinor: taxesMinor,
totalMinor,
- currency: 'ETB',
+ currency: displayCurrency,
displayCurrency,
displayTotalMinor,
},
@@ -649,9 +660,9 @@ export class GuestBookingService {
}
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
- const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
- const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
+ const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination not found on leg-2 schedule');
@@ -686,9 +697,9 @@ export class GuestBookingService {
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
- const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId;
+ const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId;
const primaryNationality = passengersData[0]?.nationality;
- const paidChildrenCount = Math.max(0, childCount - 1);
+ const paidChildrenCount = Math.max(0, childCount - 1);
const [leg1BaseFare, leg2BaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId,
@@ -701,9 +712,9 @@ export class GuestBookingService {
primaryNationality, dto.transitStationId, dto.leg2DestinationStationId),
]);
- const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
- const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount;
- const combinedBase = leg1Total + leg2Total;
+ const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
+ const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount;
+ const combinedBase = leg1Total + leg2Total;
let discountMinor = 0;
if (dto.promoCode) {
@@ -725,62 +736,63 @@ export class GuestBookingService {
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
const booking = await this.prisma.booking.create({
data: {
- bookingRef: generateRef(),
- passengerId: guestPassengerId,
- scheduleId: dto.scheduleId,
- originStationId: dto.originStationId,
- destinationStationId: dto.leg2DestinationStationId,
- status: 'PENDING_PAYMENT',
- bookingType: 'TRANSIT',
+ bookingRef: generateRef(),
+ passengerId: guestPassengerId,
+ scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.leg2DestinationStationId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'TRANSIT',
totalMinor,
+ currency: displayCurrency,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
- leg2ScheduleId: dto.leg2ScheduleId,
- leg2OriginStationId: dto.transitStationId,
+ leg2ScheduleId: dto.leg2ScheduleId,
+ leg2OriginStationId: dto.transitStationId,
leg2DestinationStationId: dto.leg2DestinationStationId,
- leg2SeatClassId: leg2SeatClassId,
- userAgent: dto.deviceId,
- contactEmail: passengersData[0]?.email || null,
- contactPhone: passengersData[0]?.phone || null,
+ leg2SeatClassId: leg2SeatClassId,
+ userAgent: dto.deviceId,
+ contactEmail: passengersData[0]?.email || null,
+ contactPhone: passengersData[0]?.phone || null,
seats: {
create: [
...passengersData.map(p => ({
- seat: { connect: { id: p.seatId } },
- leg: 1,
- scheduleId: dto.scheduleId,
- passengerName: p.passengerName,
- dateOfBirth: p.dateOfBirth,
+ seat: { connect: { id: p.seatId } },
+ leg: 1,
+ scheduleId: dto.scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
- idDocumentType: p.idDocumentType,
- passportNumber: p.passportNumber,
- passportCountry: p.passportCountry,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
- verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0),
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0),
displayCurrency,
})),
...passengersData.map(p => ({
- seat: { connect: { id: p.leg2SeatId! } },
- leg: 2,
- scheduleId: dto.leg2ScheduleId,
- passengerName: p.passengerName,
- dateOfBirth: p.dateOfBirth,
+ seat: { connect: { id: p.leg2SeatId! } },
+ leg: 2,
+ scheduleId: dto.leg2ScheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
- idDocumentType: p.idDocumentType,
- passportNumber: p.passportNumber,
- passportCountry: p.passportCountry,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
- verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0),
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0),
displayCurrency,
})),
],
},
} as any,
include: {
- seats: { include: { seat: { include: { coach: true } } } },
+ seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
@@ -812,15 +824,15 @@ export class GuestBookingService {
private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) {
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
- !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
- !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
+ !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
+ !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
throw new BadRequestException(
'ROUND_TRIP_TRANSIT requires all 4 holds and all transit/return station fields',
);
}
for (const p of dto.passengers) {
- if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`);
- if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`);
+ if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`);
+ if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`);
if (!p.returnLeg2SeatId) throw new BadRequestException(`returnLeg2SeatId required for ${p.passengerName}`);
}
@@ -831,19 +843,19 @@ export class GuestBookingService {
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }),
]);
- if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired');
- if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired');
+ if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired');
+ if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired');
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 hold expired');
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired');
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
- this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
- this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
- this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
- this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId },include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
]);
- if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found');
- if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found');
+ if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found');
+ if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found');
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
@@ -851,16 +863,16 @@ export class GuestBookingService {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
- const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
- const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
- const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
- const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
+ const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
+ const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
- const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
+ const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
- const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId);
- if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit stop not found');
- if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found');
+ const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId);
+ if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit stop not found');
+ if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found');
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found');
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found');
@@ -892,21 +904,21 @@ export class GuestBookingService {
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
- const nat = passengersData[0]?.nationality;
- const paidChildren = Math.max(0, childCount - 1);
- const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId;
- const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId;
- const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
+ const nat = passengersData[0]?.nationality;
+ const paidChildren = Math.max(0, childCount - 1);
+ const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId;
+ const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId;
+ const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
- this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId),
- this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId),
- this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId),
- this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId),
+ this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId),
+ this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId),
+ this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId),
+ this.getBaseFare(dto.returnLeg2ScheduleId!, retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId),
]);
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
- (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren;
+ (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
@@ -914,8 +926,8 @@ export class GuestBookingService {
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
- const taxesMinor = Math.round(combinedBase * 0.05);
- const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
+ const taxesMinor = Math.round(combinedBase * 0.05);
+ const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -924,58 +936,58 @@ export class GuestBookingService {
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({
- seat: { connect: { id: seatId } },
+ seat: { connect: { id: seatId } },
leg,
scheduleId,
- passengerName: p.passengerName,
- dateOfBirth: p.dateOfBirth,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
- idDocumentType: p.idDocumentType,
- passportNumber: p.passportNumber,
- passportCountry: p.passportCountry,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
- verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0),
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0),
displayCurrency,
});
const booking = await this.prisma.booking.create({
data: {
- bookingRef: generateRef(),
- passengerId: guestPassengerId,
- scheduleId: dto.scheduleId,
- originStationId: dto.originStationId,
- destinationStationId: dto.returnLeg2DestinationStationId,
- status: 'PENDING_PAYMENT',
- bookingType: 'ROUND_TRIP_TRANSIT',
- totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
- leg2ScheduleId: dto.leg2ScheduleId,
- leg2OriginStationId: dto.transitStationId,
- leg2DestinationStationId: dto.leg2DestinationStationId,
- leg2SeatClassId: obL2ClassId,
- returnScheduleId: dto.returnScheduleId,
- returnOriginStationId: dto.returnOriginStationId,
- returnDestinationStationId: dto.returnDestinationStationId,
- returnSeatClassId: retL1ClassId,
- returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
- returnLeg2OriginStationId: dto.returnTransitStationId,
- returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
- returnLeg2SeatClassId: retL2ClassId,
- returnLegStatus: 'NEITHER_USED',
- userAgent: dto.deviceId,
- contactEmail: passengersData[0]?.email || null,
- contactPhone: passengersData[0]?.phone || null,
+ bookingRef: generateRef(),
+ passengerId: guestPassengerId,
+ scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.returnLeg2DestinationStationId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'ROUND_TRIP_TRANSIT',
+ totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor,
+ leg2ScheduleId: dto.leg2ScheduleId,
+ leg2OriginStationId: dto.transitStationId,
+ leg2DestinationStationId: dto.leg2DestinationStationId,
+ leg2SeatClassId: obL2ClassId,
+ returnScheduleId: dto.returnScheduleId,
+ returnOriginStationId: dto.returnOriginStationId,
+ returnDestinationStationId: dto.returnDestinationStationId,
+ returnSeatClassId: retL1ClassId,
+ returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
+ returnLeg2OriginStationId: dto.returnTransitStationId,
+ returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
+ returnLeg2SeatClassId: retL2ClassId,
+ returnLegStatus: 'NEITHER_USED',
+ userAgent: dto.deviceId,
+ contactEmail: passengersData[0]?.email || null,
+ contactPhone: passengersData[0]?.phone || null,
seats: {
create: [
- ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
- ...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)),
- ...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)),
- ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!,4, dto.returnLeg2ScheduleId!,retL2Fare)),
+ ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
+ ...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)),
+ ...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)),
+ ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!, 4, dto.returnLeg2ScheduleId!, retL2Fare)),
],
},
} as any,
include: {
- seats: { include: { seat: { include: { coach: true } } } },
+ seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
@@ -997,14 +1009,14 @@ export class GuestBookingService {
fareBreakdown: {
outboundLeg1FareMinor: obL1Fare,
outboundLeg2FareMinor: obL2Fare,
- returnLeg1FareMinor: retL1Fare,
- returnLeg2FareMinor: retL2Fare,
+ returnLeg1FareMinor: retL1Fare,
+ returnLeg2FareMinor: retL2Fare,
adultCount, childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount: paidChildren,
combinedBaseFareMinor: combinedBase,
discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
- currency: 'ETB', displayCurrency, displayTotalMinor,
+ currency: displayCurrency, displayCurrency, displayTotalMinor,
},
};
}
@@ -1033,7 +1045,7 @@ export class GuestBookingService {
const guestPassenger = await this.prisma.passenger.create({ data: {} });
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
-
+
return { guestPassengerId: guestPassenger.id, iamUserId: null, createdAccount: false };
}
@@ -1043,7 +1055,7 @@ export class GuestBookingService {
if (passenger.verifaydaData && typeof passenger.verifaydaData === 'object') {
gender = passenger.verifaydaData.gender || passenger.verifaydaData.Gender || null;
}
-
+
await this.prisma.travelerProfile.create({
data: {
passengerId,
@@ -1121,8 +1133,8 @@ export class GuestBookingService {
}),
]);
- const premiumMinor = seatClass?.premiumMinor ?? 0;
- const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0;
+ const premiumMinor = seatClass?.premiumMinor ?? 0;
+ const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0;
const priorities = [
{ tripId: scheduleId, route: segmentRoute, nationality },
diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts
index 57276b116..95ecc2cd7 100644
--- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts
+++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts
@@ -83,16 +83,31 @@ export class CurrencyService {
toCurrency: Currency,
): Promise {
if (fromCurrency === toCurrency) return 1;
- const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
+
+ // Direct rate
+ const direct = await this.prisma.currencyExchangeRate.findFirst({
where: { fromCurrency, toCurrency },
orderBy: { effectiveDate: 'desc' },
});
- if (!exchangeRate) {
- throw new BadRequestException(
- `No exchange rate configured for ${fromCurrency}->${toCurrency}`,
- );
+ if (direct) return Number(direct.rate);
+
+ // Inverse rate
+ const inverse = await this.prisma.currencyExchangeRate.findFirst({
+ where: { fromCurrency: toCurrency, toCurrency: fromCurrency },
+ orderBy: { effectiveDate: 'desc' },
+ });
+ if (inverse) return 1 / Number(inverse.rate);
+
+ // Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD))
+ if (fromCurrency !== Currency.ETB && toCurrency !== Currency.ETB) {
+ const toEtb = await this.getRateOrThrow(fromCurrency, Currency.ETB);
+ const etbToTarget = await this.getRateOrThrow(Currency.ETB, toCurrency);
+ return toEtb * etbToTarget;
}
- return Number(exchangeRate.rate);
+
+ throw new BadRequestException(
+ `No exchange rate configured for ${fromCurrency}->${toCurrency}`,
+ );
}
private roundTo(value: number, decimals: number): number {
@@ -110,7 +125,7 @@ export class CurrencyService {
}
const rate = await this.getExchangeRate(fromCurrency, toCurrency);
- return Math.round(amountMinor * rate);
+ return amountMinor * rate;
}
async getExchangeRate(
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
index f917a9590..50cd99ec4 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
@@ -230,7 +230,7 @@ export class PaymentsController {
@ApiOperation({
summary: "Get booking amount in a specific currency",
description:
- "Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " +
+ "Returns the booking total converted from the booking's stored currency to the requested currency using the latest exchange rate. " +
"If currency is ETB the stored amount is returned as-is (no conversion). " +
"Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).",
})
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts
index bc5faee95..5271acef4 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts
@@ -38,6 +38,9 @@ describe("PaymentsService", () => {
paymentMethod: {
findUnique: jest.fn(),
},
+ currencyExchangeRate: {
+ findFirst: jest.fn(),
+ },
walletAccount: {
findUnique: jest.fn(),
update: jest.fn(),
@@ -338,6 +341,38 @@ describe("PaymentsService", () => {
});
});
+ describe("getBookingAmountByCurrency", () => {
+ it("should convert from the booking currency to the requested currency", async () => {
+ mockPrisma.booking.findUnique.mockResolvedValue({
+ id: "booking-1",
+ totalMinor: 100000,
+ bookingType: "ONE_WAY",
+ packageId: null,
+ priceTierId: null,
+ currency: "USD",
+ displayCurrency: "USD",
+ displayTotalMinor: 125000,
+ });
+ mockPrisma.currencyExchangeRate.findFirst.mockResolvedValue({ rate: 2.5 });
+
+ const result = await service.getBookingAmountByCurrency("booking-1", "DJF");
+
+ expect(result).toEqual({
+ booking_id: "booking-1",
+ currency: "DJF",
+ amount: 3125,
+ });
+ expect(mockPrisma.currencyExchangeRate.findFirst).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ fromCurrency: "USD",
+ toCurrency: "DJF",
+ }),
+ }),
+ );
+ });
+ });
+
describe("getIntentByBookingId", () => {
it("should return the cached local intent when the payment service has none", async () => {
const mockIntent = {
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 fb70b2d76..36d3fbd1d 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
@@ -234,19 +234,37 @@ export class PaymentsService {
);
// The selected method's settlement currency lives in the PaymentMethod table (WAAFI/DMONEY
- // settle in DJF, CARD in USD, Ethiopian wallets in ETB). Convert the ETB booking total into
- // that currency here so the payment microservice stays currency-agnostic and charges it as-is.
+ // settle in DJF, CARD in USD, Ethiopian wallets in ETB). When the booking's displayCurrency
+ // already matches the charge currency, use displayTotalMinor directly — the rate is already
+ // baked in at booking creation time. Only fall back to ETB→target conversion when they differ.
const paymentMethod = await this.prisma.paymentMethod.findUnique({
where: { type: method },
});
const chargeCurrency = (
paymentMethod?.currency ?? booking.currency
).toUpperCase();
- const chargeAmount = await this.currencyService.convertMinorToChargeMajor(
- booking.totalMinor,
- booking.currency,
- chargeCurrency,
- );
+
+ const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
+ const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;
+
+ let chargeAmount: number;
+ if (
+ chargeCurrency === bookingDisplayCurrency &&
+ chargeCurrency !== 'ETB' &&
+ bookingDisplayTotalMinor != null
+ ) {
+ // Display currency matches charge currency — use the pre-converted amount directly.
+ chargeAmount = this.currencyService.displayMinorToChargeMajor(bookingDisplayTotalMinor, chargeCurrency);
+ } else if (chargeCurrency === 'ETB') {
+ chargeAmount = this.currencyService.displayMinorToChargeMajor(booking.totalMinor, 'ETB');
+ } else {
+ // Booking is in ETB — convert to the provider's settlement currency.
+ chargeAmount = await this.currencyService.convertMinorToChargeMajor(
+ booking.totalMinor,
+ 'ETB',
+ chargeCurrency,
+ );
+ }
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
@@ -658,28 +676,54 @@ export class PaymentsService {
): Promise<{ booking_id: string; currency: string; amount: number }> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
- select: { id: true, totalMinor: true, bookingType: true, packageId: true, priceTierId: true },
+ select: {
+ id: true,
+ totalMinor: true,
+ bookingType: true,
+ packageId: true,
+ priceTierId: true,
+ currency: true,
+ displayCurrency: true,
+ displayTotalMinor: true,
+ },
});
if (!booking) throw new NotFoundException('Booking not found');
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
const requestedCurrency = currency.toUpperCase();
- const amountInETB = correctTotalMinor / 100;
- if (requestedCurrency === 'ETB') {
- return { booking_id: bookingId, currency: 'ETB', amount: amountInETB };
+ // Source of truth: displayTotalMinor in displayCurrency when available,
+ // otherwise totalMinor in ETB (bookings with no display currency override).
+ const sourceCurrency = (booking.displayCurrency ?? 'ETB').toUpperCase();
+ const sourceMinor = booking.displayTotalMinor ?? correctTotalMinor;
+
+ // Same currency — return directly, no conversion needed.
+ if (requestedCurrency === sourceCurrency) {
+ return { booking_id: bookingId, currency: requestedCurrency, amount: sourceMinor / 100 };
}
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
- where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any },
+ where: { fromCurrency: sourceCurrency as any, toCurrency: requestedCurrency as any },
orderBy: { effectiveDate: 'desc' },
});
- if (!exchangeRate) {
- throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`);
- }
- const rate = Number(exchangeRate.rate);
- const converted = parseFloat((amountInETB * rate).toFixed(2));
+ let rate: number;
+ if (exchangeRate) {
+ rate = Number(exchangeRate.rate);
+ } else {
+ // Try inverse rate
+ const inverseRate = await this.prisma.currencyExchangeRate.findFirst({
+ where: { fromCurrency: requestedCurrency as any, toCurrency: sourceCurrency as any },
+ orderBy: { effectiveDate: 'desc' },
+ });
+ if (inverseRate) {
+ rate = 1 / Number(inverseRate.rate);
+ } else {
+ // Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD))
+ rate = await this.currencyService.getRateOrThrow(sourceCurrency as any, requestedCurrency as any);
+ }
+ }
+ const converted = (sourceMinor / 100) * rate;
return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
}
diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts
index 9f9fb7bdc..340d9fac9 100644
--- a/apps/edr-passenger-api/src/modules/search/search.service.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.service.ts
@@ -704,7 +704,12 @@ export class SearchService {
scheduleId: schedule.id,
});
return {
- seatClassName: fare.seatClassName,
+ // Use the input seat class name (sc.name) so it always matches what
+ // buildCoachTypeDetails looks up via coachType.seatClasses. The fare
+ // engine may resolve a nationality-specific variant (nationalitySeatClass)
+ // whose name differs from sc.name, which would cause the class to be
+ // silently dropped from coachTypes and show N/A on the results page.
+ seatClassName: sc.name,
baseFareMinor: fare.totalMinor,
displayCurrency: fare.billingCurrency as Currency,
displayAmountMinor: fare.totalInBillingCurrency,
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
index ad92660a4..340900fdd 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
@@ -1,13 +1,17 @@
-import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
+import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto, JourneyDirection } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import { AuditService } from '../../common/audit.service';
+import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
+import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
@Injectable()
export class SeatsService {
+ private readonly logger = new Logger(SeatsService.name);
+
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
@@ -134,10 +138,10 @@ export class SeatsService {
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
const n = coachTypeName.toLowerCase();
- // Explicit VIP name check first
- if (n.includes('vip')) return 'VIP_BED';
- // Fall back to actual beds-per-room count: 4 = VIP, 6 = Economy
- if (bedsPerRoom === 4) return 'VIP_BED';
+ // Name-based: VIP / Soft Berth Coach → VIP_BED
+ if (n.includes('vip') || n.includes('soft')) return 'VIP_BED';
+ // Beds-per-room fallback: 2 or 4 beds per room = VIP, more = Economy
+ if (bedsPerRoom != null && bedsPerRoom <= 4) return 'VIP_BED';
return 'ECONOMY_BED';
}
@@ -184,6 +188,11 @@ export class SeatsService {
return legacyMap[col?.toUpperCase()] ?? null;
}
+ // Delegates the actual "is this seat held/booked for this leg" determination to
+ // SegmentsService.getSeatAvailabilityMap — the same canonical check search results
+ // (availabilityByClass) use — so the seatmap and search results can never disagree
+ // about seat availability again. Previously this method carried its own
+ // separately-written copy of the same hold/JourneySegment-overlap logic.
async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],
@@ -194,138 +203,53 @@ export class SeatsService {
const statusMap = new Map();
if (seatIds.length === 0) return statusMap;
- // Resolve the requested leg's sequence range once
- let reqFrom: number | undefined;
- let reqTo: number | undefined;
- let allStopTimes: { stationId: string; sequence: number }[] | null = null;
-
- const getStopTimes = async () => {
- if (!allStopTimes) {
- allStopTimes = await this.prisma.tripStopTime.findMany({
- where: { scheduleId },
- select: { stationId: true, sequence: true },
- });
- }
- return allStopTimes;
- };
+ const stopTimes = await this.prisma.tripStopTime.findMany({
+ where: { scheduleId },
+ select: { stationId: true, sequence: true },
+ });
+ // No specific leg requested (or it doesn't resolve to real stops on this
+ // schedule) — conservatively treat the whole schedule as one big leg, so any
+ // resolvable hold/booking anywhere on it blocks these seats. Matches this
+ // method's previous behavior when called without origin/destination.
+ let reqFrom = -Infinity;
+ let reqTo = Infinity;
if (originStationId && destinationStationId) {
- const stops = await getStopTimes();
- const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
- reqFrom = seqOf(originStationId);
- reqTo = seqOf(destinationStationId);
- }
-
- // ── Active holds ──────────────────────────────────────────────────────────
- const activeHolds = await this.prisma.seatHold.findMany({
- where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } },
- select: { seatIds: true, createdBy: true },
- });
-
- const reqDirection = journeyDirection || JourneyDirection.ONE_WAY;
-
- for (const hold of activeHolds) {
- let holdFrom: number | undefined;
- let holdTo: number | undefined;
- let holdDirection = JourneyDirection.ONE_WAY;
-
- try {
- if (hold.createdBy?.trimStart().startsWith('{')) {
- const meta = JSON.parse(hold.createdBy);
- const stops = await getStopTimes();
- const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
- holdFrom = seqOf(meta.originStationId);
- holdTo = seqOf(meta.destinationStationId);
- holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
- }
- } catch { /* ignore */ }
-
- for (const seatId of hold.seatIds) {
- if (!seatIds.includes(seatId)) continue;
-
- // Check leg overlap
- const legsOverlap =
- reqFrom === undefined || reqTo === undefined ||
- holdFrom === undefined || holdTo === undefined ||
- (holdFrom < reqTo && reqFrom < holdTo);
-
- // Check direction conflict
- const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
-
- if (!legsOverlap || !directionsConflict) {
- // This hold does not conflict with the requested leg/direction.
- // Explicitly mark AVAILABLE so the DB's HELD status (set by the
- // opposing-direction hold) does not bleed through via the fallback.
- if (!statusMap.has(seatId)) statusMap.set(seatId, 'AVAILABLE');
- continue;
- }
-
- statusMap.set(seatId, 'HELD');
+ const seqOf = (id: string) => stopTimes.find(s => s.stationId === id)?.sequence;
+ const resolvedFrom = seqOf(originStationId);
+ const resolvedTo = seqOf(destinationStationId);
+ if (resolvedFrom !== undefined && resolvedTo !== undefined) {
+ reqFrom = resolvedFrom;
+ reqTo = resolvedTo;
}
}
- // ── Confirmed bookings via JourneySegment ─────────────────────────────────
- const bookedSegments = await this.prisma.journeySegment.findMany({
- where: {
- scheduleId,
- seatId: { in: seatIds },
- journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
- },
- select: { seatId: true, departureStationId: true, arrivalStationId: true },
- });
+ const [availability, persistedSeats] = await Promise.all([
+ this.segmentsService.getSeatAvailabilityMap(
+ scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY,
+ ),
+ this.prisma.seat.findMany({
+ where: { id: { in: seatIds } },
+ select: { id: true, status: true },
+ }),
+ ]);
- if (reqFrom !== undefined && reqTo !== undefined) {
- const stops = await getStopTimes();
- const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
- for (const seg of bookedSegments) {
- if (!seg.seatId) continue;
- const segFrom = seqOf(seg.departureStationId);
- const segTo = seqOf(seg.arrivalStationId);
- if (segFrom !== undefined && segTo !== undefined) {
- if (segFrom < reqTo && reqFrom < segTo) statusMap.set(seg.seatId, 'BOOKED');
- } else {
- statusMap.set(seg.seatId, 'BOOKED');
- }
- }
- } else {
- for (const seg of bookedSegments) {
- if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
+ const persistedStatus = new Map(persistedSeats.map(s => [s.id, s.status]));
+
+ for (const seatId of seatIds) {
+ const persisted = persistedStatus.get(seatId);
+ // BLOCKED and UNDER_MAINTENANCE are cross-schedule flags set by admins —
+ // always honour them regardless of hold/booking state.
+ if ((persisted as string) === 'BLOCKED' || (persisted as string) === 'UNDER_MAINTENANCE') {
+ statusMap.set(seatId, persisted!);
+ } else {
+ statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE');
}
}
return statusMap;
}
- /**
- * Check if two journey directions conflict (should not be allowed simultaneously)
- * For round-trip bookings: OUTBOUND and RETURN should NOT conflict on same schedule
- */
- private checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean {
- // OUTBOUND and RETURN are allowed simultaneously (round-trip on different schedules)
- if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) ||
- (current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) {
- return false;
- }
-
- // Same directions conflict (e.g., two OUTBOUND or two RETURN bookings)
- if (current === existing) {
- return true;
- }
-
- // ONE_WAY conflicts with other ONE_WAY bookings only
- if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) {
- return true;
- }
-
- // ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings)
- if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) {
- return true;
- }
-
- // Default: no conflict
- return false;
- }
-
async holdSeats(dto: HoldSeatsDto) {
const passengerIds = dto.passengers.map(p => p.passengerId);
const seatIds = dto.passengers.map(p => p.seatId);
@@ -367,7 +291,15 @@ export class SeatsService {
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
}
- const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED');
+ // Only the raw BLOCKED status (seat pulled out of service — a genuine
+ // cross-schedule flag) is trusted here. BOOKED is intentionally NOT checked
+ // against this raw column: the same physical Seat row is reused across every
+ // recurring date a coach runs, and Seat.status only resets to AVAILABLE via a
+ // trip-completion event that isn't guaranteed to fire, so a stale BOOKED value
+ // here would wrongly block a seat that's actually free for this schedule/leg.
+ // The schedule- and leg-scoped SeatHold/JourneySegment checks below are the
+ // authoritative source for whether a seat is actually taken.
+ const blocked = seats.filter(s => s.status === 'BLOCKED');
if (blocked.length > 0)
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
@@ -429,7 +361,7 @@ export class SeatsService {
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
if (!legsOverlap) continue;
- const directionsConflict = this.checkDirectionConflict(currentDirection, holdDirection);
+ const directionsConflict = checkDirectionConflict(currentDirection, holdDirection);
if (!directionsConflict) continue;
for (const { passengerId, seatId } of dto.passengers) {
@@ -623,7 +555,50 @@ export class SeatsService {
return { released: true, holdId };
}
- async confirmSeats(_seatIds: string[]) {}
+ // Called right after a booking (PNR) is created, and again on successful payment.
+ // Extends the SeatHold(s) covering these seats to the booking's actual payment
+ // deadline — the same MIN(createdAt + 2h, departureAt - 30min) window TasksService
+ // uses to auto-cancel unpaid bookings — instead of leaving them on the original
+ // short seat-selection hold (5 min by default). Without this, the hold could expire
+ // while the customer was still on the payment page, and a second customer could
+ // hold/book the exact same seat out from under them.
+ async confirmSeats(seatIds: string[], now: Date = new Date()): Promise {
+ if (seatIds.length === 0) return;
+
+ const holds = await this.prisma.seatHold.findMany({
+ where: { seatIds: { hasSome: seatIds } },
+ select: { id: true, scheduleId: true, expiresAt: true },
+ });
+ if (holds.length === 0) return;
+
+ const scheduleIds = Array.from(new Set(holds.map(h => h.scheduleId)));
+ const schedules = await this.prisma.trainSchedule.findMany({
+ where: { id: { in: scheduleIds } },
+ select: { id: true, departureAt: true },
+ });
+ const departureById = new Map(schedules.map(s => [s.id, s.departureAt]));
+
+ let extended = 0;
+ await Promise.all(
+ holds.map(async (hold) => {
+ const departureAt = departureById.get(hold.scheduleId);
+ if (!departureAt) return;
+ const deadline = computePaymentDeadline(now, departureAt);
+ // Only ever extend forward — never shorten a hold that's already valid longer
+ // than the payment deadline would give it (e.g. a second confirmSeats call on
+ // the same booking, or a hold that was already extended).
+ if (deadline <= hold.expiresAt) return;
+ await this.prisma.seatHold.update({ where: { id: hold.id }, data: { expiresAt: deadline } });
+ extended++;
+ }),
+ );
+
+ if (extended > 0) {
+ this.logger.log(
+ `Extended ${extended} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
+ );
+ }
+ }
// Delete the Journey (and its JourneySegments) scoped to this booking.
async releaseSeats(bookingId: string) {
@@ -893,30 +868,83 @@ export class SeatsService {
);
}
+ // Runs every minute, but is also safe to call on-demand (e.g. right after a hold's
+ // TTL is read back to the client) — expiresAt/now are both absolute UTC instants
+ // (Date objects, not wall-clock strings), so this is correct regardless of the
+ // server's or a client's local timezone; there's no wall-clock parsing involved.
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
+ try {
+ const result = await this.expireHoldsCore();
+ if (result.expiredHolds > 0) {
+ this.logger.log(
+ `Expired ${result.expiredHolds} hold(s): released ${result.releasedSeatIds.length} seat(s), ` +
+ `skipped ${result.skippedSeatIds.length} still held by another active hold on the same schedule`,
+ );
+ }
+ } catch (error) {
+ // A failed run must not crash the process or silently go unnoticed — the next
+ // scheduled run one minute later will retry the same (still-expired) holds,
+ // since nothing here is deleted/updated until the queries above succeed.
+ this.logger.error('Failed to expire seat holds', error instanceof Error ? error.stack : error);
+ }
+ }
+
+ async expireHoldsCore(now: Date = new Date()): Promise<{
+ expiredHolds: number;
+ releasedSeatIds: string[];
+ skippedSeatIds: string[];
+ }> {
const expired = await this.prisma.seatHold.findMany({
- where: { expiresAt: { lt: new Date() } },
- select: { id: true, seatIds: true },
+ where: { expiresAt: { lt: now } },
+ select: { id: true, scheduleId: true, seatIds: true },
});
- if (expired.length === 0) return;
+ if (expired.length === 0) {
+ return { expiredHolds: 0, releasedSeatIds: [], skippedSeatIds: [] };
+ }
- const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]);
-
- // Only reset seats that have no remaining active holds
- const stillHeld = await this.prisma.seatHold.findMany({
- where: { expiresAt: { gte: new Date() }, seatIds: { hasSome: expiredSeatIds } },
- select: { seatIds: true },
+ // Still-active holds — scoped per (scheduleId, seatId), not just seatId. The same
+ // physical Seat row is reused across every recurring date a coach runs, so the
+ // same seatId legitimately appears in unrelated holds for other schedules; without
+ // this scoping, an unrelated active hold on a DIFFERENT schedule would wrongly
+ // block release of a seat whose hold expired on THIS schedule, leaving it stuck at
+ // status 'HELD' indefinitely.
+ const activeHolds = await this.prisma.seatHold.findMany({
+ where: { expiresAt: { gte: now } },
+ select: { scheduleId: true, seatIds: true },
});
- const stillHeldIds = new Set(stillHeld.flatMap(h => h.seatIds as string[]));
- const toRelease = expiredSeatIds.filter(id => !stillHeldIds.has(id));
+ const stillHeldKeys = new Set(
+ activeHolds.flatMap(h => (h.seatIds as string[]).map(seatId => `${h.scheduleId}:${seatId}`)),
+ );
- if (toRelease.length > 0) {
+ const releasedSeatIds = new Set();
+ const skippedSeatIds = new Set();
+ for (const hold of expired) {
+ for (const seatId of hold.seatIds as string[]) {
+ if (stillHeldKeys.has(`${hold.scheduleId}:${seatId}`)) {
+ skippedSeatIds.add(seatId);
+ } else {
+ releasedSeatIds.add(seatId);
+ }
+ }
+ }
+
+ if (releasedSeatIds.size > 0) {
await this.prisma.seat.updateMany({
- where: { id: { in: toRelease }, status: 'HELD' },
- data: { status: 'AVAILABLE' },
+ where: { id: { in: Array.from(releasedSeatIds) }, status: 'HELD' },
+ // heldUntil is cleared alongside status — leaving a stale (past) heldUntil on an
+ // AVAILABLE seat is stale data that any future code reading heldUntil directly
+ // (instead of re-deriving availability live) would misinterpret.
+ data: { status: 'AVAILABLE', heldUntil: null },
});
}
- await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
+
+ await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: now } } });
+
+ return {
+ expiredHolds: expired.length,
+ releasedSeatIds: Array.from(releasedSeatIds),
+ skippedSeatIds: Array.from(skippedSeatIds),
+ };
}
}
diff --git a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts
index 406c9e61b..bd99f1ceb 100644
--- a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
+import { Injectable, BadRequestException, ConflictException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SegmentsService, Segment } from '../segments/segments.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
@@ -18,6 +18,8 @@ export interface BookingConfirmRequest {
@Injectable()
export class EnhancedSeatsService {
+ private readonly logger = new Logger(EnhancedSeatsService.name);
+
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
@@ -57,6 +59,15 @@ export class EnhancedSeatsService {
},
});
+ // Mirrors SeatsService.holdSeats() — without this, a seat held through this path
+ // reads back as status 'AVAILABLE' in the DB despite being actively held, which is
+ // wrong for any consumer that trusts `status` directly instead of re-deriving
+ // availability live from SeatHold.
+ await tx.seat.updateMany({
+ where: { id: { in: request.seatIds } },
+ data: { status: 'HELD', heldUntil: expiresAt },
+ });
+
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
});
@@ -157,19 +168,58 @@ export class EnhancedSeatsService {
});
}
- async expireHolds() {
- return this.prisma.$transaction(async (tx) => {
- const expiredHolds = await tx.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
- const expiredSeatIds = expiredHolds.flatMap(h => h.seatIds);
+ // now/expiresAt are absolute UTC instants (Date objects), not wall-clock strings, so
+ // this comparison is correct regardless of the server's local timezone.
+ async expireHolds(now: Date = new Date()) {
+ try {
+ const result = await this.prisma.$transaction(async (tx) => {
+ const expiredHolds = await tx.seatHold.findMany({ where: { expiresAt: { lt: now } } });
+ if (expiredHolds.length === 0) {
+ return { expiredHolds: 0, releasedSeats: [] as string[] };
+ }
- if (expiredSeatIds.length > 0) {
- await tx.seat.updateMany({ where: { id: { in: expiredSeatIds } }, data: { status: 'AVAILABLE', heldUntil: null } });
- await tx.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
- this.eventEmitter.emit('holds.expired', { expiredHolds: expiredHolds.length, releasedSeats: expiredSeatIds });
+ // Still-active holds — scoped per (scheduleId, seatId). The same physical Seat
+ // row is reused across every recurring date a coach runs, so the same seatId can
+ // legitimately appear in an unrelated hold for a different schedule; without this
+ // scoping, that unrelated hold would wrongly be treated as covering THIS
+ // schedule's seat too, and a seat still genuinely held (same schedule, a newer
+ // non-expired hold) could be released out from under it.
+ const activeHolds = await tx.seatHold.findMany({ where: { expiresAt: { gte: now } } });
+ const stillHeldKeys = new Set(
+ activeHolds.flatMap(h => h.seatIds.map(seatId => `${h.scheduleId}:${seatId}`)),
+ );
+
+ const releasedSeatIds = new Set();
+ for (const hold of expiredHolds) {
+ for (const seatId of hold.seatIds) {
+ if (!stillHeldKeys.has(`${hold.scheduleId}:${seatId}`)) releasedSeatIds.add(seatId);
+ }
+ }
+
+ if (releasedSeatIds.size > 0) {
+ await tx.seat.updateMany({
+ where: { id: { in: Array.from(releasedSeatIds) } },
+ data: { status: 'AVAILABLE', heldUntil: null },
+ });
+ }
+ await tx.seatHold.deleteMany({ where: { expiresAt: { lt: now } } });
+
+ return { expiredHolds: expiredHolds.length, releasedSeats: Array.from(releasedSeatIds) };
+ });
+
+ if (result.expiredHolds > 0) {
+ this.logger.log(`Expired ${result.expiredHolds} hold(s), released ${result.releasedSeats.length} seat(s)`);
+ this.eventEmitter.emit('holds.expired', { expiredHolds: result.expiredHolds, releasedSeats: result.releasedSeats });
}
- return { expiredHolds: expiredHolds.length, releasedSeats: expiredSeatIds };
- });
+ return result;
+ } catch (error) {
+ // A failed run must not go unnoticed — nothing is deleted/updated until the
+ // transaction commits, so the next caller/scheduled run simply retries the same
+ // still-expired holds.
+ this.logger.error('Failed to expire seat holds', error instanceof Error ? error.stack : error);
+ return { expiredHolds: 0, releasedSeats: [] as string[] };
+ }
}
async getSeatAvailability(scheduleId: string, originStationId: string, destinationStationId: string) {
diff --git a/apps/edr-passenger-api/src/modules/segments/segments.service.ts b/apps/edr-passenger-api/src/modules/segments/segments.service.ts
index 16b486bbe..aaeb51e03 100644
--- a/apps/edr-passenger-api/src/modules/segments/segments.service.ts
+++ b/apps/edr-passenger-api/src/modules/segments/segments.service.ts
@@ -1,5 +1,7 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
+import { JourneyDirection } from '../seats/seats.dto';
+import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
export interface Segment {
fromStationId: string;
@@ -54,7 +56,12 @@ export class SegmentsService {
}
/**
- * Checks whether a seat is free for the requested leg [reqFrom, reqTo).
+ * Canonical per-seat availability check for a leg [reqFrom, reqTo) — the single
+ * source of truth used by search results (availabilityByClass), the interactive
+ * seatmap (SeatsService.resolveEffectiveStatuses), and hold-conflict checking, so
+ * they can never disagree about whether a given seat is free. Previously
+ * SeatsService maintained its own separately-written copy of this same
+ * hold/booking-overlap logic, which could (and did) drift out of sync with this one.
*
* Overlap rule (strict): existingFrom < reqTo AND reqFrom < existingTo
*
@@ -66,99 +73,30 @@ export class SegmentsService {
* P3: A(1) → D(4) reqFrom=1, reqTo=4
* Check P3 vs P2: 1 < 4 AND 2 < 4 → true AND true → CONFLICT ✓
*
+ * journeyDirection lets a round-trip's OUTBOUND and RETURN holds coexist on the
+ * same schedule without blocking each other (see checkDirectionConflict) — omit it
+ * for one-way contexts, where it defaults to ONE_WAY (conflicts with anything).
+ *
* Sources checked:
- * 1. Active SeatHolds — leg decoded from createdBy JSON ({ originStationId, destinationStationId })
+ * 1. Active SeatHolds — leg + direction decoded from createdBy JSON
+ * ({ originStationId, destinationStationId, journeyDirection })
* 2. Active JourneySegments — per-leg rows for CONFIRMED / PENDING_PAYMENT journeys
+ * (JourneySegment carries no direction — a confirmed booking always blocks,
+ * regardless of the requester's own direction)
+ *
+ * Returns a map from seatId to 'HELD' | 'BOOKED' — seats with no entry are free.
+ * BOOKED takes priority when a seat is somehow reported as both.
*/
- async isSeatFreeForLeg(
- scheduleId: string,
- seatId: string,
- reqFrom: number,
- reqTo: number,
- ): Promise {
- // ── Load stop-time sequences once ────────────────────────────────────────
- const stopTimes = await this.prisma.tripStopTime.findMany({
- where: { scheduleId },
- select: { stationId: true, sequence: true },
- });
- const seqOf = (stationId: string) =>
- stopTimes.find(s => s.stationId === stationId)?.sequence;
-
- // ── 1. Active holds ───────────────────────────────────────────────────────
- const activeHolds = await this.prisma.seatHold.findMany({
- where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
- });
-
- for (const hold of activeHolds) {
- // Decode leg from createdBy JSON: { originStationId, destinationStationId, passengers }
- let holdFrom: number | undefined;
- let holdTo: number | undefined;
- try {
- if (hold.createdBy) {
- const meta = JSON.parse(hold.createdBy);
- holdFrom = seqOf(meta.originStationId);
- holdTo = seqOf(meta.destinationStationId);
- }
- } catch { /* ignore */ }
-
- if (holdFrom !== undefined && holdTo !== undefined) {
- if (holdFrom < reqTo && reqFrom < holdTo) return false;
- } else {
- // Cannot resolve leg — conservative block
- return false;
- }
- }
-
- // ── 2. Active JourneySegments ─────────────────────────────────────────────
- // Each row is one leg (e.g. A→B, B→C). We group by journeyId to get the
- // full range [min(depSeq), max(arrSeq)] per journey for this seat.
- const bookedLegs = await this.prisma.journeySegment.findMany({
- where: {
- scheduleId,
- seatId,
- journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
- },
- });
-
- // Group legs by journeyId → find the full range each journey occupies
- const journeyRanges = new Map();
- for (const leg of bookedLegs) {
- const depSeq = seqOf(leg.departureStationId);
- const arrSeq = seqOf(leg.arrivalStationId);
- if (depSeq === undefined || arrSeq === undefined) continue;
-
- const existing = journeyRanges.get(leg.journeyId);
- if (!existing) {
- journeyRanges.set(leg.journeyId, { from: depSeq, to: arrSeq });
- } else {
- journeyRanges.set(leg.journeyId, {
- from: Math.min(existing.from, depSeq),
- to: Math.max(existing.to, arrSeq),
- });
- }
- }
-
- for (const { from, to } of journeyRanges.values()) {
- // Strict overlap: existingFrom < reqTo AND reqFrom < existingTo
- if (from < reqTo && reqFrom < to) return false;
- }
-
- return true;
- }
-
- /**
- * Batch availability check for multiple seats on a single schedule.
- * Replaces N×isSeatFreeForLeg calls with 2 queries total.
- * Returns a Set of seat IDs that are free for [reqFrom, reqTo).
- */
- async getFreeSeatIds(
+ async getSeatAvailabilityMap(
scheduleId: string,
seatIds: string[],
stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>,
reqFrom: number,
reqTo: number,
- ): Promise> {
- if (seatIds.length === 0) return new Set();
+ journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY,
+ ): Promise> {
+ const result = new Map();
+ if (seatIds.length === 0) return result;
const seqOf = (stationId: string) =>
stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence;
@@ -181,29 +119,31 @@ export class SegmentsService {
}),
]);
- // Determine which seats are blocked by active holds
- const holdBlockedSeats = new Set();
+ // ── 1. Active holds ────────────────────────────────────────────────────────
for (const hold of allHolds) {
let holdFrom: number | undefined;
let holdTo: number | undefined;
+ let holdDirection = JourneyDirection.ONE_WAY;
try {
if (hold.createdBy) {
const meta = JSON.parse(hold.createdBy as string);
holdFrom = seqOf(meta.originStationId);
holdTo = seqOf(meta.destinationStationId);
+ holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
}
} catch { /* ignore */ }
for (const sid of hold.seatIds) {
if (!seatIdSet.has(sid)) continue;
- // Conservative block if leg can't be resolved; otherwise check overlap
- if (holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo)) {
- holdBlockedSeats.add(sid);
- }
+ // Conservative block if leg can't be resolved; otherwise check overlap.
+ const legsOverlap = holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo);
+ if (!legsOverlap) continue;
+ if (!checkDirectionConflict(journeyDirection, holdDirection)) continue;
+ result.set(sid, 'HELD');
}
}
- // Build full journey ranges per seat (group multi-leg journeys)
+ // ── 2. Active JourneySegments — per-seat, per-journey leg ranges ──────────
const journeyRangesBySeat = new Map>();
for (const leg of bookedLegs) {
if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue;
@@ -220,20 +160,52 @@ export class SegmentsService {
: { from: depSeq, to: arrSeq });
}
- const freeSeats = new Set();
for (const seatId of seatIds) {
- if (holdBlockedSeats.has(seatId)) continue;
- let blocked = false;
const rangeMap = journeyRangesBySeat.get(seatId);
- if (rangeMap) {
- for (const { from, to } of rangeMap.values()) {
- if (from < reqTo && reqFrom < to) { blocked = true; break; }
- }
+ if (!rangeMap) continue;
+ for (const { from, to } of rangeMap.values()) {
+ if (from < reqTo && reqFrom < to) { result.set(seatId, 'BOOKED'); break; }
}
- if (!blocked) freeSeats.add(seatId);
}
- return freeSeats;
+ return result;
+ }
+
+ /**
+ * Batch availability check for multiple seats on a single schedule.
+ * Thin wrapper around getSeatAvailabilityMap — returns just the free-seat set.
+ */
+ async getFreeSeatIds(
+ scheduleId: string,
+ seatIds: string[],
+ stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>,
+ reqFrom: number,
+ reqTo: number,
+ journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY,
+ ): Promise> {
+ if (seatIds.length === 0) return new Set();
+ const statusMap = await this.getSeatAvailabilityMap(
+ scheduleId, seatIds, stopTimesForSeqLookup, reqFrom, reqTo, journeyDirection,
+ );
+ return new Set(seatIds.filter(id => !statusMap.has(id)));
+ }
+
+ /**
+ * Single-seat convenience wrapper around getSeatAvailabilityMap.
+ */
+ async isSeatFreeForLeg(
+ scheduleId: string,
+ seatId: string,
+ reqFrom: number,
+ reqTo: number,
+ journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY,
+ ): Promise {
+ const stopTimes = await this.prisma.tripStopTime.findMany({
+ where: { scheduleId },
+ select: { stationId: true, sequence: true },
+ });
+ const freeSeats = await this.getFreeSeatIds(scheduleId, [seatId], stopTimes, reqFrom, reqTo, journeyDirection);
+ return freeSeats.has(seatId);
}
/** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */
diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
index fb9957b92..4fb3a4f0f 100644
--- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
+++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
@@ -3,11 +3,7 @@ import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { CurrencyService } from '../currency/currency.service';
-
-/** Maximum time (hours) a passenger has to pay after booking. */
-const MAX_PAYMENT_HOURS = 2;
-/** Minutes before departure: cutoff for new bookings and payment deadline. */
-const CUTOFF_MINUTES = 30;
+import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
// Retention windows
const OTP_RETENTION_HOURS = 1;
@@ -16,15 +12,6 @@ const AUDIT_LOG_RETENTION_DAYS = 365;
const WEBHOOK_EVENT_RETENTION_DAYS = 90;
const GATE_LOG_RETENTION_DAYS = 180;
-/**
- * payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
- */
-function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
- const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
- const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
- return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
-}
-
function fmtTime(d: Date): string {
return d.toLocaleTimeString('en-GB', {
hour: '2-digit',
@@ -192,6 +179,7 @@ export class TasksService {
},
},
paymentIntent: { select: { method: true } },
+ seats: { select: { seatId: true } },
},
});
@@ -205,9 +193,21 @@ export class TasksService {
const paymentDeadline = computePaymentDeadline(createdAt, dep);
if (now < paymentDeadline) continue;
- // 1. Release held seats (Journey rows are the occupancy source of truth)
+ // 1a. Release held seats (Journey rows are the occupancy source of truth once paid)
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
+ // 1b. Also release the SeatHold(s) covering this booking's seats — SeatsService
+ // extends these to the payment deadline when the booking is created, so without
+ // this they'd otherwise keep the seat locked for up to MAX_PAYMENT_HOURS even
+ // though the booking is now cancelled. Scoped to this booking's own schedule,
+ // since the same physical Seat row is reused across other recurring dates.
+ const seatIds = booking.seats.map(s => s.seatId);
+ if (seatIds.length > 0) {
+ await this.prisma.seatHold.deleteMany({
+ where: { scheduleId: booking.scheduleId, seatIds: { hasSome: seatIds } },
+ });
+ }
+
// 2. Audit record (no refund — payment was never completed)
await this.prisma.bookingCancellation.create({
data: {
diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
index 413383b7a..9c9ee3286 100644
--- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
@@ -35,6 +35,7 @@ export default function SeatsPage() {
queryKey: ['seatmap', selectedSchedule],
queryFn: () => selectedSchedule ? seatsApi.getSeatMap(selectedSchedule) : Promise.resolve(null),
enabled: !!selectedSchedule,
+ staleTime: 0,
});
const { data: coachTypesData } = useQuery({
@@ -49,6 +50,7 @@ export default function SeatsPage() {
const { data: routeCoachesData, isLoading: routeCoachesLoading } = useQuery({
queryKey: ['routeCoaches', selectedRoute],
+ staleTime: 0,
queryFn: async () => {
if (!selectedRoute) return null;
const template: any[] = await routeCoachTemplatesApi.get(selectedRoute);
@@ -79,10 +81,15 @@ export default function SeatsPage() {
enabled: !!selectedRoute,
});
+ const invalidateSeatData = () => {
+ queryClient.refetchQueries({ queryKey: ['seatmap', selectedSchedule] });
+ queryClient.refetchQueries({ queryKey: ['routeCoaches', selectedRoute] });
+ };
+
const blockMutation = useMutation({
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
setShowBlockModal(false);
setSelectedSeat(null);
setBlockReason('');
@@ -92,14 +99,14 @@ export default function SeatsPage() {
const unblockMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.unblock(seatId),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
},
});
const removeSeatMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.removeSeat(seatId),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
setShowRemoveModal(false);
setSelectedSeat(null);
},
@@ -108,7 +115,7 @@ export default function SeatsPage() {
const undoRemoveMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.undoRemove(seatId),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
},
});
@@ -116,7 +123,7 @@ export default function SeatsPage() {
mutationFn: ({ seatId, reason }: { seatId: string; reason: string }) =>
seatsApi.setMaintenance(seatId, reason),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
setShowMaintenanceModal(false);
setSelectedSeat(null);
setMaintenanceReason('');
@@ -125,7 +132,7 @@ export default function SeatsPage() {
const clearMaintenanceMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.clearMaintenance(seatId),
- onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seatmap'] }),
+ onSuccess: () => invalidateSeatData(),
});
const schedules = schedulesData?.items || schedulesData?.data || [];
@@ -139,7 +146,7 @@ export default function SeatsPage() {
return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason })));
},
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
setShowBlockCoachModal(false);
setSelectedCoach(null);
setBlockCoachReason('');
@@ -153,7 +160,7 @@ export default function SeatsPage() {
return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId)));
},
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
setShowUnblockCoachModal(false);
setCoachToUnblock(null);
},
@@ -1015,7 +1022,7 @@ function SeatIcon({
const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED';
- const canMaintenance = status === 'AVAILABLE' || status === 'BLOCKED';
+ const canMaintenance = false;
const canClearMaintenance = status === 'UNDER_MAINTENANCE';
return (
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 025c212bf..c0c9f56bb 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
@@ -427,6 +427,7 @@ function BookingDetailContent() {
+ {!booking.isPackageBooking && booking.bookingType !== "PACKAGE" && (
Fare breakdown
@@ -481,6 +482,7 @@ function BookingDetailContent() {
);
})}
+ )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
index e56680d11..4333e95e9 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
@@ -34,7 +34,6 @@ export default function PaymentPage() {
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore();
const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState
(null);
- const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState(null);
// CAC Bank OTP debit: on Pay, collect the payer's mobile in a modal, then the SMS'd OTP.
@@ -47,40 +46,40 @@ export default function PaymentPage() {
const [otpError, setOtpError] = useState(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
- const isPackage = !!packageName;
- // Use the same display currency as the review page (derived from nationality)
+ // Use the same display currency as the review page — stored on the schedule at search time.
+ const scheduleCurrency = isRoundTrip
+ ? outboundSchedule?.displayCurrency
+ : selectedSchedule?.displayCurrency;
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
- const displayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
+ const displayCurrency = scheduleCurrency || (nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD');
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery({
- queryKey: ['paymentMethods', displayCurrency],
+ queryKey: ['paymentMethods'],
queryFn: async () => {
- const response = await apiClient.get(`/payments/methods?currency=${displayCurrency}`);
+ const response = await apiClient.get(`/payments/methods`);
return Array.isArray(response) ? response : [];
},
});
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null;
- // A payment method only needs a currency conversion when its own currency differs from
- // the default booking currency (e.g. Waafi settles in USD) — otherwise the reviewed ETB
- // total already shown on the review page is exact and there's nothing to convert.
- const isConversionNeeded = !!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
- const amountCurrency = isConversionNeeded ? selectedMethodCurrency! : displayCurrency;
+ // Derive charge currency directly from the selected method — no separate state that can lag.
+ const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase();
- // Fetch the converted booking amount from the booking-amount-changer API whenever a
- // currency-specific payment method is selected.
- const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
+ const { data: bookingAmountData, isFetching: fetchingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
queryKey: ['bookingAmount', bookingId, amountCurrency],
queryFn: async () => {
- const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`;
- const response: any = await apiClient.get(url);
+ const response: any = await apiClient.get(`/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`);
return response;
},
- enabled: !!bookingId && isConversionNeeded,
+ enabled: !!bookingId && !!selectedMethod,
+ staleTime: 30_000,
});
+ // Data is only usable when it belongs to the currently-selected method's currency.
+ const dataReady = !fetchingAmount && bookingAmountData != null && bookingAmountData.currency.toUpperCase() === amountCurrency.toUpperCase();
+
// Per-leg subtotals for the journey header — sum each paying passenger's reviewed fare
// split equally across both legs. This guarantees leg totals are consistent with the
// per-passenger breakdown rows and the overall reviewed total.
@@ -91,39 +90,33 @@ export default function PaymentPage() {
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.inboundFareMinor ?? Math.round(f.fareMinor / 2))), 0)
: 0;
- // reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
- // in the booking's default currency (ETB) — they were computed and shown to the user on
- // the review page. But once a payment method with its own currency is selected (e.g.
- // Waafi/USD), the converted amount from the booking-amount API takes over so the user
- // sees the actual amount they'll be charged in that currency.
+ // reviewedTotalMinor is in display-currency minor units — matches what was shown on the review page.
+ // When a method with a different currency is selected, bookingAmountData gives the converted charge amount.
+ // When the method's currency matches displayCurrency (or no method selected), use reviewedTotal directly.
const reviewedTotal = reviewedTotalMinor ?? (reviewedPassengerFares?.reduce((s, f) => s + f.fareMinor, 0) ?? null);
- const totalAmountDisplay = isConversionNeeded
- ? (bookingAmountData != null ? bookingAmountData.amount : null)
- : (reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null));
- const totalAmount = isConversionNeeded
- ? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : (reviewedTotal ?? 0))
- : (reviewedTotal ?? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : 0));
- const confirmedCurrency = isConversionNeeded ? (bookingAmountData?.currency || amountCurrency) : displayCurrency;
- // Show loading spinner while the converted amount is still in flight for a
- // currency-specific method; ETB methods always have the reviewed total instantly.
- const awaitingAmount = !isPackage && isConversionNeeded && loadingAmount && totalAmountDisplay === null;
+ // When a method is selected: show spinner until dataReady, then show converted amount.
+ // When no method is selected: show the reviewed total in displayCurrency.
+ const totalAmountDisplay = selectedMethod
+ ? (dataReady ? bookingAmountData!.amount : null)
+ : (reviewedTotal != null ? reviewedTotal / 100 : null);
+ const totalAmount = selectedMethod && dataReady
+ ? Math.round(bookingAmountData!.amount * 100)
+ : (reviewedTotal ?? 0);
+ const confirmedCurrency = selectedMethod
+ ? (dataReady ? bookingAmountData!.currency : amountCurrency)
+ : displayCurrency;
+ const awaitingAmount = !!selectedMethod && !dataReady;
useEffect(() => {
- // Once a currency-specific payment method's converted amount has loaded, that's the
- // real charge amount and currency — store it as the paid amount. Otherwise fall back
- // to the reviewed ETB total shown on the review page.
- if (isConversionNeeded && bookingAmountData != null) {
- setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
- setPaidAmount(Math.round(bookingAmountData.amount * 100));
- } else if (reviewedTotal != null) {
- setCurrency('ETB');
+ if (selectedMethod && dataReady) {
+ setCurrency(bookingAmountData!.currency as 'ETB' | 'DJF' | 'USD');
+ setPaidAmount(Math.round(bookingAmountData!.amount * 100));
+ } else if (!selectedMethod && reviewedTotal != null) {
+ setCurrency(displayCurrency as 'ETB' | 'DJF' | 'USD');
setPaidAmount(reviewedTotal);
- } else if (bookingAmountData != null) {
- setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
- setPaidAmount(Math.round(bookingAmountData.amount * 100));
}
- }, [isConversionNeeded, bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]);
+ }, [selectedMethod, dataReady, bookingAmountData, reviewedTotal, displayCurrency, setCurrency, setPaidAmount]);
const paymentMutation = useMutation({
mutationFn: async (data: any) => {
@@ -603,7 +596,7 @@ export default function PaymentPage() {
return (