Merge pull request #1488 from Tria-plc/dev

dev
This commit is contained in:
marshal
2026-09-04 09:44:01 +03:00
committed by GitHub
58 changed files with 6725 additions and 239 deletions

View File

@@ -33,6 +33,9 @@ describe('RateChangeRequestsService', () => {
rate?: Rate;
pending?: RateChangeRequest | null;
applyThrows?: Error;
/** Columns buildUpdate would derive beyond the literal patch (e.g. rateType). */
derived?: Partial<Rate>;
previewThrows?: Error;
} = {}) => {
const rate = opts.rate ?? liveRate();
const saved: RateChangeRequest[] = [];
@@ -53,6 +56,12 @@ describe('RateChangeRequestsService', () => {
const rates = {
findById: jest.fn(async () => rate),
assertUpdateValid: jest.fn(async () => undefined),
// Stands in for buildUpdate: it resolves a patch into the full column
// set, including columns the form never posts (rateType and friends).
previewUpdate: jest.fn(async (_id: string, dto: Record<string, unknown>) => {
if (opts.previewThrows) throw opts.previewThrows;
return { ...dto, ...(opts.derived ?? {}) } as Partial<Rate>;
}),
applyApprovedUpdate: jest.fn(async () => {
if (opts.applyThrows) throw opts.applyThrows;
return rate;
@@ -155,14 +164,48 @@ describe('RateChangeRequestsService', () => {
});
it('validates up front so the requester hears about a bad patch, not the approver', async () => {
const { service, rates } = build();
rates.assertUpdateValid.mockRejectedValueOnce(
new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
);
// Resolving the patch IS the validation — buildUpdate throws on a bad
// unit, so previewUpdate surfaces it at submit time.
const { service } = build({
previewThrows: new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
});
await expect(
service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }),
).rejects.toThrow(/not valid for this rate/);
});
it('shows the approver a bulk switch, which only exists as a derived column', async () => {
// The form posts intercityKind: BULK — never stored. The real edit lands
// on rateType (+ the cargo/container swap), so that is what the approver
// must see. Diffing the raw patch showed an empty change list.
const { service } = build({
rate: liveRate({
rateType: 'INTERCITY_CONTAINER',
appliesTo: 'INTERCITY',
containerTypeId: 'ct-1',
}),
derived: {
rateType: 'INTERCITY_BULK',
containerTypeId: null,
cargoTypeId: 'cargo-9',
} as Partial<Rate>,
});
const request = await service.submit({
rateId: 'rate-1',
update: { intercityKind: 'BULK', cargoTypeId: 'cargo-9' } as never,
});
expect(request.payload).toMatchObject({
rateType: 'INTERCITY_BULK',
containerTypeId: null,
cargoTypeId: 'cargo-9',
});
expect(request.previousValues).toMatchObject({
rateType: 'INTERCITY_CONTAINER',
containerTypeId: 'ct-1',
});
});
});
describe('approve', () => {

View File

@@ -24,7 +24,14 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
/** Backoffice page where both the queue and the rates live. */
const RATES_LINK = '/dashboard/rules/rates';
/** Fields a change request may carry — anything else in the patch is ignored. */
/**
* Persisted columns an approver is shown a before→after for.
*
* These are RESOLVED entity columns, not raw form fields: the diff runs
* against `RatesService.previewUpdate`, so a change the form expresses through
* a non-stored selector still shows up here as the column it actually moves
* (a flip to bulk lands on `rateType` + the container/cargo swap).
*/
const DIFFABLE_FIELDS = [
'rateValue',
'currency',
@@ -34,6 +41,13 @@ const DIFFABLE_FIELDS = [
'tradeDirection',
'containerTypeId',
'cargoTypeId',
// The container-vs-bulk shape of the rate. Missing here, switching a LIVE
// rate to bulk showed the approver an empty change list — the only column
// that records the kind is rateType, and the form never posts it directly.
'rateType',
// Line-scoped pricing. Missing here, moving a rate onto (or off) a shipping
// line diffed to nothing.
'shippingLineCompanyId',
// The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate
// diffed to nothing and the submit was refused as "nothing changed".
'originYardId',
@@ -82,7 +96,11 @@ export class RateChangeRequestsService {
);
}
const payload = this.changedFieldsOnly(rate, dto.update);
// Diff the RESOLVED columns, not the raw patch: the form's cargoKind /
// intercityKind selectors are never stored, so a bulk switch only shows up
// once the patch is resolved into the columns it moves.
const resolved = await this.rates.previewUpdate(dto.rateId, dto.update as UpdateRateDto);
const payload = this.changedFieldsOnly(rate, resolved);
if (Object.keys(payload).length === 0) {
throw new BadRequestException('Nothing changed — the proposed values match the live rate.');
}
@@ -98,7 +116,9 @@ export class RateChangeRequestsService {
);
}
await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto);
// previewUpdate above already ran the full validation (it IS buildUpdate),
// so re-validating here would only repeat it — and the trimmed payload is
// resolved columns, not a form patch, so it is not the right input for it.
const request = await this.repo.save(
this.repo.create({
@@ -186,13 +206,14 @@ export class RateChangeRequestsService {
}
/**
* Keep only fields the requester actually changed. A form posts every field
* back, so without this the diff would list untouched values as changes.
* Keep only columns the edit actually moves. `buildUpdate` returns a full
* resolved column set (it re-derives scope on every patch), so without this
* the diff would list every untouched column as a change.
*/
private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record<string, unknown> {
private changedFieldsOnly(rate: Rate, resolved: Partial<Rate>): Record<string, unknown> {
const patch: Record<string, unknown> = {};
for (const field of DIFFABLE_FIELDS) {
const proposed = (update as Record<string, unknown>)[field];
const proposed = (resolved as Record<string, unknown>)[field];
if (proposed === undefined) continue;
if (this.sameValue(proposed, (rate as unknown as Record<string, unknown>)[field])) continue;
patch[field] = proposed;

View File

@@ -753,6 +753,19 @@ export class RatesService {
await this.buildUpdate(await this.findById(id), dto);
}
/**
* The exact column changes applying this patch would make, without writing.
*
* A change request diffs against THIS rather than the raw patch: the form
* posts selectors that are never stored (`cargoKind`, `intercityKind`), and
* the real edit they encode lands on derived columns — flipping a rate to
* bulk moves `rateType` and swaps `containerTypeId`/`cargoTypeId`. Diffing
* the raw patch missed all of it, so the approver saw an empty change list.
*/
async previewUpdate(id: string, dto: UpdateRateDto): Promise<Partial<Rate>> {
return this.buildUpdate(await this.findById(id), dto);
}
private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise<Rate> {
const updates = await this.buildUpdate(existing, dto);
const updated = await this.repository.update(existing.id, updates);

View File

@@ -867,6 +867,27 @@ export class TrainSchedulingController {
return res.send(buffer);
}
@Get("schedules/:id/wagons/export")
@TrainSchedulingView()
@ApiOperation({
summary:
"Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)",
})
async scheduleWagonListExport(
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
) {
const { filename, buffer } =
await this.trainSchedulingService.scheduleWagonListWorkbook(id);
res.setHeader(
"Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Content-Length", buffer.length);
return res.send(buffer);
}
@Get("schedules/:id/export/load-list/document")
@TrainSchedulingView()
@ApiOperation({ summary: "Download printable export marshalling / load list PDF" })

View File

@@ -74,6 +74,25 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
import { TabularExportService } from '../../exports/tabular-export.service';
/** One line of the schedule wagon-list export (raw SQL projection). */
interface ScheduleWagonListRow {
sequenceNo: number | null;
wagonNumber: string | null;
wagonType: string | null;
containerNumber: string | null;
containerSizeFt: number | null;
loadType: string | null;
status: string | null;
bulkCargoDescription: string | null;
/** numeric columns arrive as strings from pg. */
vgmTons: string | null;
originLabel: string | null;
destinationLabel: string | null;
bookingReference: string | null;
customerName: string | null;
}
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
@@ -427,6 +446,9 @@ export class TrainSchedulingService {
// Per-wagon history ledger (global module). @Optional keeps the positional
// spec constructors working; production always has it.
@Optional() private readonly wagonHistory?: WagonHistoryService,
// Trailing + @Optional so the positional constructors in the existing specs
// keep working; production always resolves it from ExportsModule.
@Optional() private readonly tabularExport?: TabularExportService,
) {}
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
@@ -3747,6 +3769,132 @@ export class TrainSchedulingService {
};
}
/**
* The schedule detail page's wagon-list Excel export.
*
* One row per container (a wagon carrying two boxes yields two rows, repeating
* the wagon number) so each container's own VGM is present and totals footable.
* Bulk wagons, having no containers, yield a single row carrying the bulk
* description and the allocated tonnage as the VGM figure.
*
* Only wagon slots that actually carry an allocation are listed — empty slots
* on the consist are omitted.
*/
async scheduleWagonListWorkbook(
scheduleId: string,
): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!this.tabularExport) {
throw new BadRequestException('Tabular export service is unavailable');
}
// Row grain is the container item; the LEFT JOIN keeps bulk (and any
// container-less) allocation as one row. `booking_container_units` is joined
// on BOTH container number and its booking_container line — container
// numbers repeat across bookings, so number alone would multiply rows.
const rows: ScheduleWagonListRow[] = await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType",
ci.container_number AS "containerNumber",
cit.size_ft AS "containerSizeFt",
a.load_type AS "loadType",
a.status AS "status",
bl.cargo_description AS "bulkCargoDescription",
COALESCE(
ci.gross_weight_tons,
bcu.vgm_tons,
bc.vgm_per_unit_tons,
a.allocated_weight_tons
) AS "vgmTons",
COALESCE(by_.label, so.label) AS "originLabel",
COALESCE(ay.label, sd.label) AS "destinationLabel",
b.reference AS "bookingReference",
COALESCE(
slc.name,
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
c.name
) AS "customerName"
FROM freight.train_schedules s
JOIN freight.train_set_wagons tsw
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
JOIN freight.wagon_booking_allocations a
ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.bookings b ON b.id = a.booking_id
LEFT JOIN freight.companies c ON c.id = b.company_id
LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
LEFT JOIN freight.booking_container bc
ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL
LEFT JOIN freight.booking_container_units bcu
ON bcu.container_number = ci.container_number
AND bcu.booking_container_id = bc.id
AND bcu.deleted_at IS NULL
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
WHERE s.id = $1 AND s.deleted_at IS NULL
ORDER BY tsw.sequence_no, ci.position_on_wagon, ci.container_number`,
[scheduleId],
);
// "number" is the printed line number of the sheet, not the wagon sequence —
// a two-container wagon occupies two lines, and the reader counts lines.
const sheetRows = rows.map((row, index) => ({
number: index + 1,
wagonNumber: row.wagonNumber ?? '—',
containerNumber:
row.containerNumber ??
(row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'),
vgmTons: row.vgmTons === null ? null : Number(row.vgmTons),
originLabel: row.originLabel ?? '—',
destinationLabel: row.destinationLabel ?? '—',
customerName: row.customerName ?? '—',
}));
const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0);
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
const buffer = await this.tabularExport.toXlsx({
title: `Wagons ${reference}`.slice(0, 31),
description: `Wagon list for train ${reference}`,
label: 'train-schedule:wagon-list',
kpis: [
{ label: 'Lines', value: sheetRows.length },
{
label: 'Wagons',
value: new Set(rows.map((r) => r.sequenceNo)).size,
},
{ label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' },
],
columns: [
{ key: 'number', label: 'No.', type: 'number' },
{ key: 'wagonNumber', label: 'Wagon', type: 'string' },
{ key: 'containerNumber', label: 'Container number', type: 'string' },
{ key: 'vgmTons', label: 'VGM', type: 'tons' },
{ key: 'originLabel', label: 'Origin', type: 'string' },
{ key: 'destinationLabel', label: 'Destination', type: 'string' },
{ key: 'customerName', label: 'Customer', type: 'string' },
],
rows: sheetRows,
});
return {
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
buffer,
};
}
async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {

View File

@@ -6,6 +6,7 @@ import { BillingModule } from '../billing/billing.module';
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
import { BookingsModule } from '../bookings/bookings.module';
import { Container } from '../container-management/entities/container.entity';
import { ExportsModule } from '../exports/exports.module';
import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FacilityHandlingService } from './facility-handling.service';
@@ -67,6 +68,7 @@ import { ContractsModule } from '../contracts/contracts.module';
UserTradeAccessModule,
NotificationsModule,
NotificationInboxModule,
ExportsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,

View File

@@ -105,4 +105,18 @@ export class ListWagonsQueryDto {
@IsOptional()
@IsDateString()
maintenanceTo?: string;
@ApiPropertyOptional({
description:
'Window (days) the per-row load/move counts are counted over. Does not filter rows.',
default: 90,
minimum: 1,
maximum: 3650,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(3650)
statsWindowDays?: number;
}

View File

@@ -177,6 +177,7 @@ export class WagonsService {
async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
await this.attachStatusDates(page.items);
await this.attachMovementStats(page.items, query.statsWindowDays ?? 90);
return page;
}
@@ -216,6 +217,56 @@ export class WagonsService {
}
}
/**
* Per-wagon movement rollups for the wagon performance report: when the
* wagon last arrived anywhere (the idle clock), and how many loaded / total
* moves it made inside `windowDays`. One grouped query per page, in the same
* shape as `attachStatusDates` above — never one request per row.
*/
private async attachMovementStats(wagons: Wagon[], windowDays: number): Promise<void> {
if (!wagons.length) return;
const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
const rows: Array<{
wagonId: string;
lastMovedAt: Date | null;
loadsInWindow: string;
movesInWindow: string;
emptyMovesInWindow: string;
}> = await this.dataSource
.getRepository(WagonMovement)
.createQueryBuilder('m')
.select('m.wagon_id', 'wagonId')
.addSelect('MAX(m.occurred_at)', 'lastMovedAt')
.addSelect(
'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :loaded)',
'loadsInWindow',
)
.addSelect(
'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :empty)',
'emptyMovesInWindow',
)
.addSelect('COUNT(*) FILTER (WHERE m.occurred_at >= :since)', 'movesInWindow')
.where('m.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) })
.setParameters({
since,
loaded: WagonMovementKind.Loaded,
empty: WagonMovementKind.EmptyReposition,
})
.groupBy('m.wagon_id')
.getRawMany();
const byId = new Map(rows.map((r) => [r.wagonId, r]));
for (const w of wagons) {
const r = byId.get(w.id);
Object.assign(w, {
lastMovedAt: r?.lastMovedAt ?? null,
loadsInWindow: Number(r?.loadsInWindow ?? 0),
movesInWindow: Number(r?.movesInWindow ?? 0),
emptyMovesInWindow: Number(r?.emptyMovesInWindow ?? 0),
});
}
}
async findById(id: string): Promise<Wagon> {
const wagon = await this.wagonRepo.findOne({
where: { id },
@@ -328,11 +379,35 @@ export class WagonsService {
/** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */
async listMovements(wagonId: string): Promise<WagonMovement[]> {
await this.findById(wagonId); // 404 on unknown wagon
return this.dataSource.getRepository(WagonMovement).find({
const movements = await this.dataSource.getRepository(WagonMovement).find({
where: { wagonId },
relations: { fromYard: true, toYard: true },
order: { occurredAt: 'DESC', createdAt: 'DESC' },
});
await this.attachBookingReferences(movements);
return movements;
}
/**
* Resolve each loaded move's booking to its human reference, so the UI can
* show (and link to) "BKG-11284" rather than a raw uuid. One query for the
* whole ledger; `wagon_movements` deliberately has no FK to bookings, so
* this is a read-time join on primary keys, exactly like the labels in
* `wagon-history.service`.
*/
private async attachBookingReferences(movements: WagonMovement[]): Promise<void> {
const ids = [...new Set(movements.map((m) => m.bookingId).filter((v): v is string => !!v))];
if (!ids.length) return;
const rows: Array<{ id: string; reference: string }> = await this.dataSource.query(
`SELECT id, reference FROM freight.bookings WHERE id = ANY($1::uuid[])`,
[ids],
);
const byId = new Map(rows.map((r) => [r.id, r.reference]));
for (const m of movements) {
Object.assign(m, {
bookingReference: m.bookingId ? (byId.get(m.bookingId) ?? null) : null,
});
}
}
async remove(id: string, userId?: string | null): Promise<void> {

View File

@@ -62,6 +62,8 @@ import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesP
import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import WagonPerformancePage from "./pages/wagon-performance/WagonPerformancePage";
import WagonPerformanceDetailPage from "./pages/wagon-performance/WagonPerformanceDetailPage";
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
@@ -232,6 +234,24 @@ const App = () => {
</RequirePermission>
}
/>
{/* Wagon performance — a read-only executive report beside Overview.
Separate from the Fleet Management wagons desk, which owns CRUD. */}
<Route
path="wagon-performance"
element={
<RequirePermission permission={FREIGHT_PERMS.wagons.view}>
<WagonPerformancePage />
</RequirePermission>
}
/>
<Route
path="wagon-performance/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.wagons.view}>
<WagonPerformanceDetailPage />
</RequirePermission>
}
/>
{/* One drill-down route per overview domain — the old per-tab charts,
now each on its own page. Single source of truth for the
permission gate is OVERVIEW_DOMAINS, shared with the summary

View File

@@ -69,6 +69,12 @@ export const buildSidebarSections = (
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.overview.view,
},
{
label: "Wagon Performance",
href: "/dashboard/wagon-performance",
icon: <TrainFront />,
permission: FREIGHT_PERMS.wagons.view,
},
{
label: "Customers",
href: "/dashboard/customers",

View File

@@ -537,6 +537,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/export/load-list/document`,
SCHEDULE_WAGONS_EXPORT: (id: string) =>
`/train-scheduling/schedules/${id}/wagons/export`,
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
MARSHALLING_STOPS: (id: string) =>

View File

@@ -1,13 +1,10 @@
import { useState } from "react";
import {
Badge,
Button,
Card,
Collapse,
Group,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import type { UseMutationResult } from "@tanstack/react-query";
@@ -27,8 +24,24 @@ const FIELD_LABELS: Record<string, string> = {
cargoTypeId: "Cargo type",
originYardId: "Origin yard",
destinationYardId: "Destination yard",
minKm: "From km",
maxKm: "To km",
baseLiters: "Base liters",
rateType: "Rate type",
};
/**
* A key the backend diffed but the UI has no label for still names a real
* change, so turn "baseLiters" into "Base liters" rather than hiding it.
*/
const labelFor = (field: string): string =>
FIELD_LABELS[field] ??
field
.replace(/([A-Z])/g, " $1")
.replace(/^./, (c) => c.toUpperCase())
.replace(/\bId\b/, "")
.trim();
const fmtDateTime = (iso: string) =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
@@ -43,13 +56,16 @@ const fmtValue = (
value: unknown,
labels?: Record<string, string>,
): string => {
if (value === null || value === undefined || value === "") return "—";
// "Not set" reads as a real before-state; a bare em dash on both sides of the
// arrow made a newly-set field look like no change at all.
if (value === null || value === undefined || value === "") return "Not set";
if (field === "rateValue") {
const num = Number(value);
return Number.isNaN(num) ? String(value) : num.toLocaleString();
}
// Yard ids are unreadable — an approver decides on the route, not a UUID.
if (field === "originYardId" || field === "destinationYardId") {
// Any id is unreadable — an approver decides on "Perishable → Truck", not on
// a pair of uuids. Covers yards, cargo types, container types and lines.
if (field.endsWith("Id")) {
return labels?.[String(value)] ?? String(value);
}
return String(value).replace(/_/g, " ");
@@ -66,13 +82,33 @@ const rateSummary = (r: RateChangeRequest): string => {
return parts.join(" · ") || "Rate";
};
/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */
const headline = (r: RateChangeRequest): string | null => {
if (!("rateValue" in r.payload)) return null;
const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record<string, unknown> | undefined)?.currency ?? "");
const before = fmtValue("rateValue", r.previousValues.rateValue);
const after = fmtValue("rateValue", r.payload.rateValue);
return `${before}${after}${currency ? ` ${currency}` : ""}`;
/**
* Every change in the request, as readable before→after pairs. The queue must
* be scannable without expanding: a cargo or direction change is just as much
* the point as a repricing, so it gets the same one-line treatment as the rate.
*/
const summaryRows = (
r: RateChangeRequest,
labels?: Record<string, string>,
): Array<{ field: string; label: string; before: string; after: string; suffix: string }> => {
const currency = String(
r.payload.currency ??
r.previousValues.currency ??
(r.rate as Record<string, unknown> | undefined)?.currency ??
"",
);
// Rate first — it is what most changes are about — then the rest in a stable
// order so the same edit always reads the same way.
const fields = Object.keys(r.payload).sort((a, b) =>
a === "rateValue" ? -1 : b === "rateValue" ? 1 : a.localeCompare(b),
);
return fields.map((field) => ({
field,
label: labelFor(field),
before: fmtValue(field, r.previousValues[field], labels),
after: fmtValue(field, r.payload[field], labels),
suffix: field === "rateValue" && currency ? ` ${currency}` : "",
}));
};
type Decide = UseMutationResult<
@@ -87,8 +123,9 @@ interface RateApprovalsSectionProps {
canDecide: boolean;
approve: Decide;
reject: Decide;
/** yardId → label, so a re-routed rate reads as yards, not UUIDs. */
yardLabels?: Record<string, string>;
/** id → label for every reference a diff can name (yards, cargo/container
* types, shipping lines), so a change reads as names, not UUIDs. */
refLabels?: Record<string, string>;
}
/**
@@ -101,11 +138,8 @@ const RateApprovalsSection = ({
canDecide,
approve,
reject,
yardLabels,
refLabels,
}: RateApprovalsSectionProps) => {
const [openId, setOpenId] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
if (requests.length === 0) return null;
const decidingId = approve.variables?.id ?? reject.variables?.id ?? null;
@@ -125,9 +159,8 @@ const RateApprovalsSection = ({
<Stack gap={8}>
{requests.map((r) => {
const isOpen = openId === r.id;
const fields = Object.keys(r.payload);
const summaryLine = headline(r);
const rows = summaryRows(r, refLabels);
// Only the row being decided shows a spinner — the mutation's
// isPending is shared across every row.
const busy = decidingId === r.id;
@@ -145,39 +178,26 @@ const RateApprovalsSection = ({
</Text>
</Group>
{summaryLine ? (
<Group gap={6} wrap="nowrap">
{rows.map((row) => (
<Group key={row.field} gap={6} wrap="wrap" align="center">
<Text size="xs" c="dimmed">
{row.label}
</Text>
<Text size="sm" c="dimmed" td="line-through">
{fmtValue("rateValue", r.previousValues.rateValue)}
{row.before}
</Text>
<ArrowRight size={13} />
<ArrowRight size={13} style={{ flexShrink: 0 }} />
<Text size="sm" fw={700} c="edr-green">
{fmtValue("rateValue", r.payload.rateValue)}
</Text>
<Text size="sm" c="dimmed">
{String(
r.payload.currency ??
r.previousValues.currency ??
(r.rate as Record<string, unknown> | undefined)?.currency ??
"",
)}
{row.after}
{row.suffix}
</Text>
</Group>
) : null}
))}
<Group gap={6}>
<Text size="xs" c="dimmed">
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
{fields.length === 1 ? "field" : "fields"} changed
</Text>
<Button
size="compact-xs"
variant="subtle"
onClick={() => setOpenId(isOpen ? null : r.id)}
>
{isOpen ? "Hide details" : "See all changes"}
</Button>
</Group>
<Text size="xs" c="dimmed">
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
{fields.length === 1 ? "field" : "fields"} changed
</Text>
</Stack>
{canDecide ? (
@@ -190,7 +210,7 @@ const RateApprovalsSection = ({
loading={busy && reject.isPending}
disabled={busy && approve.isPending}
onClick={() =>
reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
reject.mutate({ id: r.id })
}
>
Reject
@@ -202,7 +222,7 @@ const RateApprovalsSection = ({
loading={busy && approve.isPending}
disabled={busy && reject.isPending}
onClick={() =>
approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
approve.mutate({ id: r.id })
}
>
Approve &amp; apply
@@ -217,38 +237,6 @@ const RateApprovalsSection = ({
)}
</Group>
<Collapse in={isOpen}>
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
{fields.map((field) => (
<Group key={field} gap={8} wrap="nowrap">
<Text size="xs" c="dimmed" w={110} style={{ flexShrink: 0 }}>
{FIELD_LABELS[field] ?? field}
</Text>
<Text size="sm" c="dimmed" td="line-through">
{fmtValue(field, r.previousValues[field], yardLabels)}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{fmtValue(field, r.payload[field], yardLabels)}
</Text>
</Group>
))}
{canDecide ? (
<Textarea
mt={4}
size="xs"
autosize
minRows={2}
label="Decision note (optional)"
placeholder="Shown to the requester with your decision"
value={notes[r.id] ?? ""}
onChange={(e) =>
setNotes((prev) => ({ ...prev, [r.id]: e.currentTarget.value }))
}
/>
) : null}
</Stack>
</Collapse>
</Card>
);
})}

View File

@@ -322,9 +322,22 @@ const RuleEngineResourcePage = () => {
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField);
const yardLabelById = useMemo(
() => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])),
[yardOptions],
/**
* Every id a rate diff can name, in one map. A pending change that swaps the
* cargo type or the container size stores raw uuids, so without this the
* approver reads "a1b2… → c3d4…" instead of "Perishable → Truck".
*/
const rateRefLabelById = useMemo(
() =>
Object.fromEntries(
[
...(yardOptions ?? []),
...(cargoLeafOptions ?? []),
...(containerTypeOptions ?? []),
...(shippingLineOptions ?? []),
].map((o) => [o.value, o.label]),
),
[yardOptions, cargoLeafOptions, containerTypeOptions, shippingLineOptions],
);
const usesApprovalRoleField = Boolean(
config?.formFields.some(
@@ -868,7 +881,7 @@ const RuleEngineResourcePage = () => {
canDecide={canApproveRates}
approve={rateChangeWorkflow.approve}
reject={rateChangeWorkflow.reject}
yardLabels={yardLabelById}
refLabels={rateRefLabelById}
/>
) : null}

View File

@@ -29,6 +29,7 @@ import {
Merge,
Container as ContainerIcon,
Eye,
FileSpreadsheet,
FileText,
History as HistoryIcon,
LayoutGrid,
@@ -102,6 +103,7 @@ import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWind
import { api } from "@/services/api";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import type {
ContainerPlacement,
EligibleContainerBooking,
@@ -125,6 +127,7 @@ export default function TrainScheduleV2DetailPage() {
const { user: authUser } = useAuth();
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
const [exportingWagons, setExportingWagons] = useState(false);
const [activeStep, setActiveStep] = useState(0);
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
const [forceAssign, setForceAssign] = useState(false);
@@ -314,6 +317,31 @@ export default function TrainScheduleV2DetailPage() {
);
const marshallingStops = marshallingStopsQuery.data ?? [];
/** Wagon list (one row per container) as an .xlsx download. */
const handleExportWagons = useCallback(async () => {
if (!scheduleId) return;
setExportingWagons(true);
try {
const blob =
await trainSchedulingService.downloadScheduleWagonsWorkbook(scheduleId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `wagon-list-${schedule?.reference ?? scheduleId}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
// Blob response: the JSON reason rides inside the Blob, so the sync
// decoder would surface only "Request failed with status code 400".
toast({
title: await extractDownloadErrorMessage(error),
variant: "destructive",
});
} finally {
setExportingWagons(false);
}
}, [scheduleId, schedule?.reference, toast]);
useEffect(() => {
const operation = gatepassQuery.data;
if (!operation) return;
@@ -1323,6 +1351,18 @@ export default function TrainScheduleV2DetailPage() {
Load Empty Container
</Button>
) : null}
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="light"
color="edr-green"
size="compact-sm"
leftSection={<FileSpreadsheet size={14} />}
loading={exportingWagons}
onClick={() => void handleExportWagons()}
>
Export wagons
</Button>
) : null}
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="gradient"

View File

@@ -0,0 +1,50 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { Download } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export interface SectionExportButtonProps {
/** What this button downloads, e.g. "wagon list" — used in the tooltip and toast. */
label: string;
/** Runs the download; false means there was nothing to write. */
onExport: () => boolean;
disabled?: boolean;
}
/**
* Excel download for one section of the wagon performance report. Sits in the
* section's own header, so what it exports is unambiguous — the block it is
* attached to, exactly as filtered on screen.
*/
export function SectionExportButton({
label,
onExport,
disabled,
}: SectionExportButtonProps) {
const { toast } = useToast();
return (
<Tooltip label={`Download ${label} as Excel`}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
aria-label={`Download ${label} as Excel`}
disabled={disabled}
onClick={(e) => {
// The row underneath may navigate; a download must not trigger it.
e.stopPropagation();
const wrote = onExport();
if (!wrote) {
toast({
title: "Nothing to export",
description: `There are no ${label} rows to download yet.`,
});
}
}}
>
<Download size={16} />
</ActionIcon>
</Tooltip>
);
}

View File

@@ -0,0 +1,80 @@
import * as XLSX from "xlsx";
/**
* Excel download for one section of the wagon performance report.
*
* Each section on the page exports exactly what is on screen — the same rows,
* in the same order, honouring the same filters and date window — so a figure
* in the spreadsheet always reconciles with the figure the CEO just read.
*
* Built client-side from data already in the browser: the report holds the
* whole fleet in memory (see WagonPerformancePage), so there is nothing to
* re-fetch and no server round-trip.
*/
/** A sheet's worth of rows: ordered column headers plus plain-value records. */
export interface SheetSpec {
/** Sheet tab name. Excel caps these at 31 chars and forbids : \ / ? * [ ]. */
name: string;
rows: Array<Record<string, string | number | null>>;
}
/** Excel rejects these in a sheet name, and silently truncates past 31 chars. */
const safeSheetName = (name: string): string =>
name.replace(/[:\\/?*[\]]/g, "-").slice(0, 31) || "Sheet1";
/** Widen each column to its longest cell, so nothing opens as ####. */
function fitColumns(
rows: Array<Record<string, unknown>>,
): Array<{ wch: number }> {
const headers = Object.keys(rows[0] ?? {});
return headers.map((h) => {
const longest = rows.reduce((max, row) => {
const cell = row[h];
const len = cell == null ? 0 : String(cell).length;
return len > max ? len : max;
}, h.length);
// Cap the width so one long note cannot push a column off the screen.
return { wch: Math.min(Math.max(longest + 2, 10), 60) };
});
}
/** Timestamp suffix so repeated downloads don't overwrite each other. */
const stamp = (): string => {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
};
/**
* Download one or more sheets as a single .xlsx.
*
* `filenameBase` gets the timestamp and extension appended. Sheets with no
* rows are skipped; if that leaves nothing, the download is skipped entirely
* and the function returns false so the caller can say so.
*/
export function downloadSheets(
filenameBase: string,
sheets: SheetSpec[],
): boolean {
const populated = sheets.filter((s) => s.rows.length > 0);
if (!populated.length) return false;
const workbook = XLSX.utils.book_new();
for (const spec of populated) {
const sheet = XLSX.utils.json_to_sheet(spec.rows);
sheet["!cols"] = fitColumns(spec.rows);
XLSX.utils.book_append_sheet(workbook, sheet, safeSheetName(spec.name));
}
XLSX.writeFile(workbook, `${filenameBase}-${stamp()}.xlsx`);
return true;
}
/** Single-sheet convenience wrapper — the shape most sections need. */
export function downloadSheet(
filenameBase: string,
sheetName: string,
rows: Array<Record<string, string | number | null>>,
): boolean {
return downloadSheets(filenameBase, [{ name: sheetName, rows }]);
}

View File

@@ -0,0 +1,237 @@
/**
* Derived wagon performance figures for the CEO's wagon report.
*
* Nothing here is stored: every number is computed in the browser from the
* ledgers the API already returns — `wagon_movements` (relocations),
* `wagon_status_logs` (roster flips) and `wagon_events` (unified history).
* Keeping the derivation in one place means the report and the wagon record
* can never disagree about what "idle" or "utilisation" means.
*
* This report is READ-ONLY and lives beside the Overview dashboard. It does
* not replace the Fleet Management wagons desk, which owns wagon CRUD.
*/
import { Freight } from "@edr/types";
import type {
Wagon,
WagonMovementRecord,
WagonStatusLog,
} from "@/services/wagon.service";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Days past which a parked wagon is treated as stranded. */
export const IDLE_THRESHOLD_DAYS = 21;
/** Days off the roster past which a repair is treated as overdue. */
export const DOWN_THRESHOLD_DAYS = 30;
/** Statuses that take a wagon off the earning roster. */
export const OFF_ROSTER_STATUSES: Freight.WagonStatus[] = [
Freight.WagonStatus.Maintenance,
Freight.WagonStatus.Detained,
Freight.WagonStatus.OutOfService,
];
export const isOffRoster = (status: Freight.WagonStatus): boolean =>
OFF_ROSTER_STATUSES.includes(status);
/** Whole days between `iso` and now; null when the timestamp is missing. */
export function daysSince(iso: string | null | undefined): number | null {
if (!iso) return null;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return null;
return Math.max(0, Math.floor((Date.now() - t) / DAY_MS));
}
/** Fractional days between two timestamps; `to` null means "still open". */
export function daysBetween(
from: string | null | undefined,
to: string | null | undefined,
): number | null {
if (!from) return null;
const a = new Date(from).getTime();
if (Number.isNaN(a)) return null;
const b = to ? new Date(to).getTime() : Date.now();
if (Number.isNaN(b)) return null;
return Math.max(0, (b - a) / DAY_MS);
}
export interface WagonPerformance {
/** Days since the wagon last arrived anywhere — the idle clock. */
idleDays: number | null;
/** Days in the current off-roster spell; null while in service. */
downDays: number | null;
loads: number;
moves: number;
emptyMoves: number;
manualMoves: number;
/** Share of moves that carried cargo, 0100; null when nothing moved. */
loadedShare: number | null;
lastMovement: WagonMovementRecord | null;
/** Off-roster spells overlapping the window. */
spells: number;
/** Days off roster inside the window. */
downDaysInWindow: number;
/** Share of the window spent on the roster, 0100. */
availability: number;
}
/**
* Roll one wagon's ledgers up into the figures the report shows.
*
* `windowDays` bounds loads, moves and downtime. Idle days and the current
* down spell are "how long has this been true right now" — never windowed.
*/
export function computeWagonPerformance(
wagon: Pick<Wagon, "status" | "lastMaintenanceAt" | "lastAvailableAt">,
movements: WagonMovementRecord[],
statusLogs: WagonStatusLog[],
windowDays: number,
): WagonPerformance {
const since = Date.now() - windowDays * DAY_MS;
// Movements arrive newest-first from the API; don't rely on it.
const ordered = [...movements].sort(
(a, b) =>
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
);
const lastMovement = ordered[0] ?? null;
const inWindow = ordered.filter((m) => {
const t = new Date(m.occurredAt).getTime();
return !Number.isNaN(t) && t >= since;
});
const loads = inWindow.filter(
(m) => m.kind === Freight.WagonMovementKind.Loaded,
).length;
const emptyMoves = inWindow.filter(
(m) => m.kind === Freight.WagonMovementKind.EmptyReposition,
).length;
const manualMoves = inWindow.filter(
(m) => m.kind === Freight.WagonMovementKind.Manual,
).length;
const moves = inWindow.length;
const idleDays = daysSince(lastMovement?.occurredAt ?? null);
// Newest first, so a flip's "until" is the log entry before it in the array.
const logs = [...statusLogs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
let downDays: number | null = null;
if (isOffRoster(wagon.status)) {
const entered = logs.find((l) => l.toStatus === wagon.status);
downDays = daysSince(entered?.createdAt ?? wagon.lastMaintenanceAt ?? null);
}
// Downtime inside the window: walk each off-roster entry to the flip that
// ended it, clamping both ends to the window.
let downDaysInWindow = 0;
let spells = 0;
logs.forEach((log, i) => {
if (!isOffRoster(log.toStatus)) return;
const start = new Date(log.createdAt).getTime();
if (Number.isNaN(start)) return;
const closed = logs[i - 1];
const end = closed ? new Date(closed.createdAt).getTime() : Date.now();
const from = Math.max(start, since);
const to = Math.min(end, Date.now());
if (to <= from) return;
downDaysInWindow += (to - from) / DAY_MS;
spells += 1;
});
const availability =
windowDays > 0
? Math.max(
0,
Math.min(
100,
Math.round(((windowDays - downDaysInWindow) / windowDays) * 100),
),
)
: 100;
return {
idleDays,
downDays,
loads,
moves,
emptyMoves,
manualMoves,
loadedShare: moves > 0 ? Math.round((loads / moves) * 100) : null,
lastMovement,
spells,
downDaysInWindow: Math.round(downDaysInWindow),
availability,
};
}
/** Mantine colour per wagon status. */
export function statusColor(status: Freight.WagonStatus): string {
switch (status) {
case Freight.WagonStatus.Available:
return "edr-green";
case Freight.WagonStatus.Assigned:
case Freight.WagonStatus.ImportReady:
return "blue";
case Freight.WagonStatus.ExportReady:
return "teal";
case Freight.WagonStatus.Maintenance:
return "yellow";
case Freight.WagonStatus.Detained:
return "red";
case Freight.WagonStatus.OutOfService:
default:
return "gray";
}
}
/** Mantine colour per movement kind. */
export function movementKindColor(kind: Freight.WagonMovementKind): string {
switch (kind) {
case Freight.WagonMovementKind.Loaded:
return "edr-green";
case Freight.WagonMovementKind.EmptyReposition:
return "teal";
case Freight.WagonMovementKind.Maintenance:
return "yellow";
case Freight.WagonMovementKind.Manual:
default:
return "gray";
}
}
/** Mantine colour per history-event category. */
export function eventCategoryColor(
category: Freight.WagonEventCategory,
): string {
switch (category) {
case Freight.WagonEventCategory.Yard:
return "yellow";
case Freight.WagonEventCategory.Train:
return "blue";
case Freight.WagonEventCategory.Schedule:
return "indigo";
case Freight.WagonEventCategory.Cargo:
return "edr-green";
case Freight.WagonEventCategory.Status:
return "orange";
case Freight.WagonEventCategory.Lifecycle:
default:
return "gray";
}
}
/** Idle banding shared by the table and the distribution chart. */
export function idleBand(
idleDays: number | null,
): "ok" | "watch" | "stranded" | "unknown" {
if (idleDays == null) return "unknown";
if (idleDays > IDLE_THRESHOLD_DAYS) return "stranded";
if (idleDays > Math.round(IDLE_THRESHOLD_DAYS / 2)) return "watch";
return "ok";
}

View File

@@ -756,6 +756,15 @@ export const trainSchedulingService = {
return response.data;
},
/** The schedule detail page's wagon-list Excel export. */
downloadScheduleWagonsWorkbook: async (scheduleId: string): Promise<Blob> => {
const response = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_WAGONS_EXPORT(scheduleId),
{ responseType: "blob" },
);
return response.data as Blob;
},
downloadIntercityMarshallingDocument: async (
scheduleId: string,
): Promise<Blob> => {

View File

@@ -29,6 +29,12 @@ export interface Wagon {
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
lastMaintenanceAt?: string | null;
lastAvailableAt?: string | null;
/** Newest wagon_movements arrival — the idle clock's start (list endpoint only). */
lastMovedAt?: string | null;
/** Loaded / empty / total moves inside `statsWindowDays` (list endpoint only). */
loadsInWindow?: number;
movesInWindow?: number;
emptyMovesInWindow?: number;
currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null;
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
@@ -55,6 +61,8 @@ export interface WagonListFilters {
* the latest status-log flip to MAINTENANCE, not a stored column. */
maintenanceFrom?: string;
maintenanceTo?: string;
/** Window (days) the per-row load/move counts cover. Does not filter rows. */
statsWindowDays?: number;
/** Only read by `getPaged`. */
page?: number;
pageSize?: number;
@@ -73,6 +81,8 @@ const wagonListQuery = (filters: WagonListFilters): string => {
if (filters.createdTo) params.set('createdTo', filters.createdTo);
if (filters.maintenanceFrom) params.set('maintenanceFrom', filters.maintenanceFrom);
if (filters.maintenanceTo) params.set('maintenanceTo', filters.maintenanceTo);
if (filters.statsWindowDays)
params.set('statsWindowDays', String(filters.statsWindowDays));
if (filters.page) params.set('page', String(filters.page));
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
const qs = params.toString();
@@ -101,6 +111,8 @@ export interface WagonMovementRecord {
note: string | null;
createdAt: string;
wagon?: { id: string; wagonNumber?: string } | null;
/** The booking's human reference, joined at read time. Null when unloaded. */
bookingReference?: string | null;
}
/** One row of the wagon status audit trail. Returned newest first by the API. */

View File

@@ -0,0 +1,65 @@
-- Fare-class upgrade (policy US-17). One policy row per fare class — fare classes map 1:1 onto
-- coach types — plus a per-request table recording the frozen quote.
--
-- Structure only, and additive/idempotent. Policy data lives in prisma/seed.ts.
-- CreateTable
CREATE TABLE IF NOT EXISTS "passenger"."UpgradePolicy" (
"id" TEXT NOT NULL,
"coachTypeId" TEXT NOT NULL,
"rank" INTEGER NOT NULL DEFAULT 0,
"feePercent" INTEGER NOT NULL DEFAULT 0,
"feeMinMinor" INTEGER NOT NULL DEFAULT 0,
"feeWaived" BOOLEAN NOT NULL DEFAULT false,
"isUpgradable" BOOLEAN NOT NULL DEFAULT true,
"isTargetable" BOOLEAN NOT NULL DEFAULT true,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UpgradePolicy_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "UpgradePolicy_coachTypeId_key" ON "passenger"."UpgradePolicy"("coachTypeId");
DO $$ BEGIN
ALTER TABLE "passenger"."UpgradePolicy"
ADD CONSTRAINT "UpgradePolicy_coachTypeId_fkey" FOREIGN KEY ("coachTypeId")
REFERENCES "passenger"."CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- CreateTable
CREATE TABLE IF NOT EXISTS "passenger"."BookingUpgrade" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"leg" INTEGER NOT NULL DEFAULT 1,
"status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT',
"requestedBy" TEXT NOT NULL,
"scheduleId" TEXT NOT NULL,
"items" JSONB NOT NULL,
"holdId" TEXT,
"oldFareMinor" INTEGER NOT NULL,
"newFareMinor" INTEGER NOT NULL,
"fareDifferenceMinor" INTEGER NOT NULL,
"feeMinor" INTEGER NOT NULL,
"amountDueMinor" INTEGER NOT NULL,
"supplementaryChargeId" TEXT,
"expiresAt" TIMESTAMP(3),
"appliedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BookingUpgrade_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "BookingUpgrade_supplementaryChargeId_key" ON "passenger"."BookingUpgrade"("supplementaryChargeId");
CREATE INDEX IF NOT EXISTS "BookingUpgrade_bookingId_status_idx" ON "passenger"."BookingUpgrade"("bookingId", "status");
DO $$ BEGIN
ALTER TABLE "passenger"."BookingUpgrade"
ADD CONSTRAINT "BookingUpgrade_bookingId_fkey" FOREIGN KEY ("bookingId")
REFERENCES "passenger"."Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- No data seeding here on purpose. This migration creates structure only; the ladder itself is
-- business policy and is seeded separately by `seedUpgradePolicies` in prisma/seed.ts
-- (`pnpm prisma:seed`), so a production deploy never silently writes fare rules nobody approved.

View File

@@ -81,6 +81,7 @@ model CoachType {
coaches Coach[]
seatClasses SeatClass[]
reschedulePolicy ReschedulePolicy?
upgradePolicy UpgradePolicy?
@@schema("passenger")
}
@@ -570,6 +571,7 @@ model Booking {
agentBooking AgentBooking?
modifications BookingModification[]
reschedules BookingReschedule[]
upgrades BookingUpgrade[]
cancellation BookingCancellation?
baggage BaggageBooking[]
excessBaggageCharges ExcessBaggageCharge[]
@@ -1207,6 +1209,64 @@ model AgentCommission {
@@schema("passenger")
}
/// Fare-class upgrade rule, one row per coach type (policy US-17). A coach type with no row here
/// can be neither upgraded from nor to — the same "no policy = not allowed" semantics
/// ReschedulePolicy uses. Edited in backoffice Master Data → Upgrade Policies.
model UpgradePolicy {
id String @id @default(uuid())
coachTypeId String @unique
/// Position on the ladder — an upgrade requires target.rank > source.rank. An explicit column
/// rather than a price comparison: SeatClass.baseFareMinor is a per-km tariff, while the fare
/// actually charged resolves through SegmentFareRule/FareRule first, so on some segments the
/// price order differs from the class order. Which class is "higher" is a business decision
/// and must not flip because someone edited a tariff.
rank Int @default(0)
feePercent Int @default(0) // % of the passenger's original fare
feeMinMinor Int @default(0) // fee floor, ETB minor units
feeWaived Boolean @default(false)
isUpgradable Boolean @default(true) // passengers may leave this class
isTargetable Boolean @default(true) // passengers may arrive in this class
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coachType CoachType @relation(fields: [coachTypeId], references: [id])
@@schema("passenger")
}
/// One fare-class upgrade request for one leg. Same lifecycle as BookingReschedule
/// (PENDING_PAYMENT → APPLIED | EXPIRED) but the schedule never changes — only the seats, and
/// only for the passengers named in `items`.
model BookingUpgrade {
id String @id @default(uuid())
bookingId String
leg Int @default(1)
status String @default("PENDING_PAYMENT") // PENDING_PAYMENT | APPLIED | EXPIRED
requestedBy String
scheduleId String // unchanged by the upgrade; recorded so the audit row reads standalone
/// Frozen per-passenger quote, keyed on bookingSeatId — NOT array position. Only some
/// passengers move, so a positional pairing (as BookingReschedule uses) would be fragile.
/// Each element: { bookingSeatId, passengerName, passengerCategory,
/// oldSeatId, oldSeatLabel, oldCoachTypeId, oldSeatClassId, oldFareMinor,
/// newSeatId, newSeatLabel, newCoachTypeId, newSeatClassId, newFareMinor,
/// feeMinor, fareDifferenceMinor }
items Json
holdId String?
oldFareMinor Int
newFareMinor Int
fareDifferenceMinor Int
feeMinor Int
amountDueMinor Int
supplementaryChargeId String? @unique
expiresAt DateTime?
appliedAt DateTime?
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id])
@@index([bookingId, status])
@@schema("passenger")
}
/// Rescheduling rule per fare class. Fare families from the passenger policy map 1:1 onto
/// coach types (HSC = Standard, HBC = Flex, SBC = Premium). Seeded by migration from the policy
/// doc; edited in backoffice Settings → Reschedule Policy.

View File

@@ -657,7 +657,70 @@ async function seedNotificationTemplates() {
{ id: uuidv4(), code: 'payment.succeeded', channel: 'SMS', subject: 'Payment Received', bodyTemplate: 'Payment of {{amount}} {{currency}} received for booking {{bookingRef}}.' },
{ id: uuidv4(), code: 'payment.failed', channel: 'SMS', subject: 'Payment Failed', bodyTemplate: 'Payment for booking {{bookingRef}} could not be completed. Please try again.' },
{ id: uuidv4(), code: 'booking.cancelled', channel: 'EMAIL', subject: 'Booking Cancelled', bodyTemplate: 'Your booking {{bookingRef}} has been cancelled. Refund: {{refundAmount}} {{currency}}.' },
{ id: uuidv4(), code: 'booking.rescheduled', channel: 'EMAIL', subject: 'Booking Rescheduled', bodyTemplate: 'Your {{leg}} journey on booking {{bookingRef}} has been rescheduled. New tickets have been issued. Change fee: {{feeAmount}} {{currency}}.' },
// Rich bodies mirroring booking.created: these are delivered by SMS *and* email, so the
// wording has to stand alone in a text message. Channel is 'SMS,EMAIL' — the applied/expired
// handlers deliver directly, but the field keeps the row honest about where it goes.
{ id: uuidv4(), code: 'booking.rescheduled', channel: 'SMS,EMAIL', subject: 'Booking Rescheduled', bodyTemplate: `Dear {{passengerName}},
Your {{leg}} journey on booking ({{bookingRef}}) has been rescheduled.
Route: {{origin}} → {{destination}}
{{trainSeatLines}}
{{previousLine}}Travel Date: {{travelDate}}
Departure: {{departureTime}}
Arrival: {{arrivalTime}}
Paid: {{amountPaid}} {{currency}} (change fee: {{feeAmount}} {{currency}})
New tickets have been issued.
View your booking: {{detailLink}}
Thank you for choosing EDR.` },
{ id: uuidv4(), code: 'booking.upgraded', channel: 'SMS,EMAIL', subject: 'Fare Class Upgraded', bodyTemplate: `Dear {{passengerName}},
Your booking ({{bookingRef}}) has been upgraded on the {{leg}} journey.
Route: {{origin}} → {{destination}}
{{changeLines}}
Travel Date: {{travelDate}}
Departure: {{departureTime}}
Paid: {{amountPaid}} {{currency}}
New tickets have been issued.
View your booking: {{detailLink}}
Thank you for choosing EDR.` },
{ id: uuidv4(), code: 'booking.reschedule.expired', channel: 'SMS,EMAIL', subject: 'Reschedule Request Expired', bodyTemplate: `Dear {{passengerName}},
Your reschedule request for booking ({{bookingRef}}) expired before it was paid, so it has not been applied.
The seat that was being held for it has been released. Your original booking, seats and travel date are unchanged:
Route: {{origin}} → {{destination}}
{{trainSeatLines}}
Travel Date: {{travelDate}}
Departure: {{departureTime}}
You can start a new reschedule any time before check-in closes:
{{detailLink}}
Thank you for choosing EDR.` },
{ id: uuidv4(), code: 'booking.upgrade.expired', channel: 'SMS,EMAIL', subject: 'Upgrade Request Expired', bodyTemplate: `Dear {{passengerName}},
Your upgrade request for booking ({{bookingRef}}) expired before it was paid, so it has not been applied.
The seat that was being held for it has been released. Your original booking and seats are unchanged:
Route: {{origin}} → {{destination}}
{{trainSeatLines}}
Travel Date: {{travelDate}}
Departure: {{departureTime}}
You can start a new upgrade any time before check-in closes:
{{detailLink}}
Thank you for choosing EDR.` },
// Templates below are not wired to handlers yet (Phase 2 — full event coverage).
{ id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
{ id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },

View File

@@ -65,6 +65,7 @@ import { SegmentFareSeeder } from "./seed/segment-fare.seeder";
import { EOtpType } from "@tria-plc/iamapi-common";
import { RescheduleModule } from './modules/reschedule/reschedule.module';
import { UpgradeModule } from './modules/upgrade/upgrade.module';
@Module({
imports: [
@@ -166,6 +167,7 @@ import { RescheduleModule } from './modules/reschedule/reschedule.module';
AppReleasesModule,
ConfigurableFareModule,
RescheduleModule,
UpgradeModule,
],
providers: [
{ provide: APP_FILTER, useClass: DeleteExceptionFilter },

View File

@@ -88,6 +88,8 @@ export const AUDIT_ENTITIES = {
Booking: 'Booking',
BookingReschedule: 'BookingReschedule',
ReschedulePolicy: 'ReschedulePolicy',
BookingUpgrade: 'BookingUpgrade',
UpgradePolicy: 'UpgradePolicy',
} as const;
export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES];

View File

@@ -0,0 +1,144 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma.service';
import { MeLikeUser } from '../passenger-permission.util';
import { normalizePhone, samePhone } from './phone.utils';
/**
* Shared by every flow that lets a passenger change a confirmed booking — reschedule today,
* fare-class upgrade next. These were private to RescheduleService; they live here so the two
* features cannot drift apart on who is allowed to act or how a seat is priced.
*
* Plain functions rather than a provider on purpose: AuditService injects REQUEST, so anything
* made injectable here would drag request scope into whatever consumes it.
*/
export type ActingUser = MeLikeUser & { id?: string; sub?: string; phoneNumber?: string };
/**
* The signed-in user's phone. The session snapshot (`userInfo.phoneNumber`) is frequently an
* empty string, so `iam.users` is the source of truth — and reading it live also means a user
* who changed their number does not have to sign out before the new one counts.
*/
export async function resolveUserPhone(
prisma: PrismaService,
iamUserId: string,
user: ActingUser,
): Promise<string | null> {
const fromSession = normalizePhone(user.phoneNumber);
if (fromSession) return fromSession;
const rows = await prisma.$queryRaw<{ phone_number: string | null }[]>`
SELECT phone_number FROM iam.users WHERE id = ${iamUserId}::uuid LIMIT 1
`;
return normalizePhone(rows[0]?.phone_number);
}
/**
* Loads a booking only for the person who made it, proven by their account's phone number
* matching the booking's `contactPhone`. Being merely *named* on the booking is not enough — a
* passenger travelling on someone else's booking cannot change it.
*
* There is deliberately no staff override. `bookings:reschedule` exists in the registry (and on
* the stationMaster preset) but is not honoured, so a station master cannot act on a customer's
* behalf yet.
*
* `action` only shapes the error message ("reschedule it" / "upgrade it").
*/
export async function loadOwnedBooking<T extends Prisma.BookingInclude>(
prisma: PrismaService,
bookingRef: string,
user: ActingUser,
include: T,
action = 'change it',
) {
const booking = await prisma.booking.findUnique({ where: { bookingRef }, include });
if (!booking) throw new NotFoundException('Booking not found');
const iamUserId = user.id ?? user.sub;
if (!iamUserId) throw new ForbiddenException();
const b = booking as any;
if (b.contactPhone) {
const callerPhone = await resolveUserPhone(prisma, iamUserId, user);
if (samePhone(callerPhone, b.contactPhone)) return booking;
throw new ForbiddenException(
`Only the person who made this booking can ${action}. Sign in with the phone number used to book.`,
);
}
// A small tail of bookings carry no contactPhone at all, so there is nothing to match against.
// Fall back to the account link rather than locking their owner out entirely.
const passenger = await prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
if (!passenger || passenger.id !== b.passengerId) throw new ForbiddenException('Not your booking');
return booking;
}
/**
* Coaches nobody buys a seat in, so they can never carry a fare-class policy.
*
* Matched loosely on purpose: `CoachType.type` is documented as 'passenger' | 'sleeper' |
* 'dining' | 'baggage', but the live data holds display labels ('Dining Coach ', trailing space
* included). A `notIn: ['dining','baggage']` filter therefore matches nothing and offers the
* dining coach as a fare class. Mirrors the portal's own test (`/dining|dpc/i`).
*/
export const NON_FARE_COACH_TERMS = ['dining', 'dpc', 'baggage'];
export const NOT_A_FARE_CLASS = {
NOT: NON_FARE_COACH_TERMS.flatMap((term) => [
{ type: { contains: term, mode: 'insensitive' as const } },
{ code: { contains: term, mode: 'insensitive' as const } },
]),
};
/** True when this coach type is a dining/baggage coach rather than a sellable fare class. */
export function isNonFareCoachType(coachType: { type?: string | null; code?: string | null }): boolean {
const haystack = `${coachType.type ?? ''} ${coachType.code ?? ''}`.toLowerCase();
return NON_FARE_COACH_TERMS.some((t) => haystack.includes(t));
}
/**
* Nationality is not stored on the booking, so the display currency is the proxy the search and
* fare code already use: ETB/DJF are local tariffs, USD is the international one. Both flows must
* use the same proxy or an upgrade would be priced on a different tariff than the original sale.
*/
export function resolveNationalityProxy(displayCurrency?: string | null): {
nationalityType: 'LOCAL' | 'INTERNATIONAL';
nationality: string | undefined;
} {
return {
nationalityType: displayCurrency === 'USD' ? 'INTERNATIONAL' : 'LOCAL',
nationality:
displayCurrency === 'DJF' ? 'Djiboutian' : displayCurrency === 'ETB' ? 'Ethiopian' : undefined,
};
}
/**
* Mirrors SearchService's class matching: nationality filter, then bed position.
* `Seat.bedPosition` is lowercase and `SeatClass.bedPosition` uppercase, hence the folding.
*/
export function pickSeatClass(classes: any[], bedPosition: string | null, nationalityType: string) {
const byNat = classes.filter((c) => !c.nationalityType || c.nationalityType === nationalityType);
const pool = byNat.length ? byNat : classes;
const bed = bedPosition?.toLowerCase() ?? null;
const exact = pool.find((c) => (c.bedPosition?.toLowerCase() ?? null) === bed);
return exact ?? pool.find((c) => !c.bedPosition) ?? pool[0] ?? null;
}
/**
* Distributes a leg fare over seats; free children (fare 0) stay 0 and rounding lands on the last
* paid seat.
*/
export function splitFare(total: number, seats: Array<{ fareMinor: number | null }>): number[] {
const paid = seats.map((s) => s.fareMinor !== 0);
const n = paid.filter(Boolean).length || 1;
const each = Math.floor(total / n);
let remaining = total;
let lastPaid = -1;
const out = seats.map((_, i) => {
if (!paid[i]) return 0;
lastPaid = i;
remaining -= each;
return each;
});
if (lastPaid >= 0) out[lastPaid] += remaining;
return out;
}

View File

@@ -24,12 +24,25 @@ export const MIN_PAYMENT_WINDOW_MINUTES = 7;
export const PAYMENT_SETTLE_MARGIN_SECONDS = 60;
/**
* `windowMinutes` is how long the payer is given, and is configurable per flow
* (`booking_payment_window_minutes`, `reschedule_…`, `upgrade_…` in SystemConfig). It defaults to
* MAX_PAYMENT_HOURS so any caller that does not pass it behaves exactly as before.
*
* The check-in cutoff is still the hard ceiling: a longer window can never let someone pay after
* boarding has closed on their train.
*
* EVERY site that decides whether a booking is still payable — the payment link, the seat hold,
* and the crons that auto-cancel unpaid bookings — must pass the SAME window for a given booking,
* or a cron will cancel a booking whose link still says it is valid.
*/
export function computePaymentDeadline(
createdAt: Date,
departureAt: Date,
checkinMinutes: number = CUTOFF_MINUTES,
windowMinutes: number = MAX_PAYMENT_HOURS * 60,
): Date {
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const maxDeadline = new Date(createdAt.getTime() + windowMinutes * 60 * 1000);
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
}

View File

@@ -1,3 +1,4 @@
import { SystemConfigModule } from '../system-config/system-config.module';
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { AuditModule } from '../../common/audit.module';
@@ -14,7 +15,7 @@ import { PaymentsModule } from '../payments/payments.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule],
imports: [SystemConfigModule, AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]

View File

@@ -93,22 +93,105 @@ export class BookingsService {
private readonly paymentsService: PaymentsService,
) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
// An IAM user with no Passenger row is normal, not an error: a freshly registered
// account that has never booked, or a staff account. findUniqueOrThrow raised P2025
// here, which surfaced as a 500 on the portal's "My bookings" page. Empty page instead.
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
if (!passenger) {
const page = filters.page ?? 1;
const pageSize = filters.pageSize ?? 20;
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
}
return this.findByPassengerId(passenger.id, filters);
/**
* Every Booking-level condition that means "this booking belongs to the person who
* owns `variants`". Shared by findByPhone (public guest retrieval) and
* findByIamUserId (the portal's own history) so the two can never disagree about
* what a phone number owns.
*
* The IAM lookup is catch-and-warn: a phone match is a best-effort widening, and an
* unavailable IAM must not fail the whole listing.
*/
private async buildPhoneOwnershipClauses(
variants: string[],
): Promise<{ clauses: any[]; passengerIds: string[] }> {
if (variants.length === 0) return { clauses: [], passengerIds: [] };
// Authenticated-user bookings don't store contactPhone — their phone lives in
// iam.users.phone_number, linked through passenger.iamUserId.
const iamRows = await this.dataSource
.query<{ id: string }[]>(
`SELECT u.id FROM iam.users u WHERE u.phone_number = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`IAM phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { id: string }[];
});
const iamPassengerIds = iamRows.length > 0
? (await this.prisma.passenger.findMany({
where: { iamUserId: { in: iamRows.map(r => r.id) } },
select: { id: true },
})).map(p => p.id)
: [];
return {
clauses: [
{ contactPhone: { in: variants } },
{ passenger: { user: { phone: { in: variants } } } },
...(iamPassengerIds.length > 0 ? [{ passengerId: { in: iamPassengerIds } }] : []),
],
passengerIds: iamPassengerIds,
};
}
/**
* The portal's authenticated "My bookings" history (GET /bookings/my).
*
* Returns bookings made **while signed in** (they hang off the Passenger row linked
* to this IAM user) *and* bookings made as a **guest with the same phone number**.
* The second half matters: resolveGuestPassenger (guest-booking.service.ts) creates a
* fresh, unlinked `Passenger` for every guest booking and never looks the phone up, so
* a customer's guest history is scattered across orphan rows that a passengerId-only
* filter cannot see. On the dev database one account had 4 visible bookings out of 30
* carrying its own phone number.
*
* Privacy note: the widened set is exactly what `GET /bookings/by-phone` already
* returns to *anonymous* callers, so showing it to the verified owner of that number
* exposes nothing that was not already public. The phone comes from iam.users, not
* from the request.
*/
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
// An IAM user with no Passenger row is normal, not an error: a freshly registered
// account that has never booked, or a staff account. findUniqueOrThrow raised P2025
// here, which surfaced as a 500 on the portal's "My bookings" page.
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
const iamRows = await this.dataSource
.query<{ phone_number: string | null }[]>(
`SELECT phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
[iamUserId],
)
.catch((err: unknown) => {
this.logger.warn(`IAM self phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { phone_number: string | null }[];
});
const variants = normalizePhoneVariants(iamRows[0]?.phone_number ?? '');
const ownership: any[] = [
...(passenger ? [{ passengerId: passenger.id }] : []),
...(await this.buildPhoneOwnershipClauses(variants)).clauses,
];
if (ownership.length === 0) {
const page = filters.page ?? 1;
const pageSize = filters.pageSize ?? 20;
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
}
return this.findBookingsForOwner({ OR: ownership }, filters);
}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
return this.findBookingsForOwner({ passengerId }, filters);
}
/**
* One page of a customer's own bookings. `ownerClause` says whose they are (a single
* passengerId, or the OR of every phone-ownership clause) and is ANDed with the
* search / status / scope filters, so none of them can clobber another's `OR`.
*
* `scope` drives the Upcoming / Past / Cancelled tabs server-side so each tab paginates
* correctly, rather than the client filtering one page at a time. Note it filters on
* `schedule.departureAt` — the schedule's own origin departure — while each item's
@@ -116,40 +199,40 @@ export class BookingsService {
* boarding stop. They differ by the run time to that stop; that is close enough for a
* tab filter and avoids a correlated stopTimes query per row.
*/
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
private async findBookingsForOwner(ownerClause: any, filters: BookingFilters = {}) {
const { search, status, scope = 'all', page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = { passengerId };
const and: any[] = [ownerClause];
if (search) {
where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
];
and.push({
OR: [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
],
});
}
// `status` used to be forwarded raw, so an unrecognised value threw a Prisma
// validation error (a 500) rather than being ignored. Only accept real enum members.
if (status && (Object.values(BookingStatus) as string[]).includes(status)) {
where.status = status;
and.push({ status });
}
const now = new Date();
let orderBy: any = { createdAt: 'desc' };
if (scope === 'cancelled') {
where.status = { in: CLOSED_BOOKING_STATUSES };
and.push({ status: { in: CLOSED_BOOKING_STATUSES } });
} else if (scope === 'upcoming' || scope === 'past') {
// Don't clobber an explicit `status` filter — intersect with it.
if (!where.status) where.status = { notIn: CLOSED_BOOKING_STATUSES };
where.schedule = {
...(where.schedule ?? {}),
departureAt: scope === 'upcoming' ? { gte: now } : { lt: now },
};
and.push({ status: { notIn: CLOSED_BOOKING_STATUSES } });
and.push({ schedule: { departureAt: scope === 'upcoming' ? { gte: now } : { lt: now } } });
orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } };
}
const where: any = { AND: and };
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
@@ -227,68 +310,11 @@ export class BookingsService {
const { status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
// Authenticated-user bookings don't store contactPhone — their phone lives in
// iam.users.phone_number linked via passenger.iamUserId. Mirror the same lookup
// that findAll uses for the search field.
const iamRows = await this.dataSource
.query<{ id: string }[]>(
`SELECT u.id FROM iam.users u WHERE u.phone_number = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`IAM phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { id: string }[];
});
// Same ownership resolution the authenticated history uses, so a customer sees the
// same set here and on "My bookings".
const ownership = await this.buildPhoneOwnershipClauses(variants);
const iamPassengerIds = iamRows.length > 0
? (await this.prisma.passenger.findMany({
where: { iamUserId: { in: iamRows.map(r => r.id) } },
select: { id: true },
})).map(p => p.id)
: [];
// Guest bookings store phone in TravelerProfile.notes JSON (created for every guest booking).
// This catches cases where contactPhone was null but the phone was still recorded in the profile.
const travelerRows = await this.dataSource
.query<{ passengerId: string }[]>(
`SELECT DISTINCT passenger_id AS "passengerId"
FROM passenger.traveler_profiles
WHERE notes IS NOT NULL
AND (notes::jsonb->>'phone') = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`TravelerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { passengerId: string }[];
});
const travelerPassengerIds = travelerRows.map(r => r.passengerId);
// Guests who saved their profile (savePassengerDetails:true) have a SavedPassengerProfile
// row with phone + deviceId. Guest bookings store the deviceId in Booking.userAgent.
const savedProfileRows = await this.dataSource
.query<{ deviceId: string }[]>(
`SELECT DISTINCT device_id AS "deviceId"
FROM passenger.saved_passenger_profiles
WHERE phone = ANY($1::text[]) AND device_id IS NOT NULL`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`SavedPassengerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { deviceId: string }[];
});
const guestDeviceIds = savedProfileRows.map(r => r.deviceId);
// Merge all passenger IDs from every source
const allPassengerIds = [...new Set([...iamPassengerIds, ...travelerPassengerIds])];
const where: any = {
OR: [
{ contactPhone: { in: variants } },
{ passenger: { user: { phone: { in: variants } } } },
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
...(guestDeviceIds.length > 0 ? [{ userAgent: { in: guestDeviceIds } }] : []),
],
};
const where: any = { OR: ownership.clauses };
if (status) where.status = status;
// PackageBooking is a separate table with its own contactPhone field —
@@ -296,7 +322,7 @@ export class BookingsService {
const pkgWhere: any = {
OR: [
{ contactPhone: { in: variants } },
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
...(ownership.passengerIds.length > 0 ? [{ passengerId: { in: ownership.passengerIds } }] : []),
],
};
if (status) pkgWhere.status = status;

View File

@@ -16,6 +16,7 @@ import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
import { JourneyDirection } from '../seats/seats.dto';
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { randomUUID } from 'crypto';
@@ -90,6 +91,7 @@ export class GuestBookingService {
private readonly logger = new Logger(GuestBookingService.name);
constructor(
private systemConfig: SystemConfigService,
private prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
private seatsService: SeatsService,
@@ -582,7 +584,10 @@ export class GuestBookingService {
const { guestPassengerId } = await this.resolveGuestPassenger({}, passengerData);
const payToken = isStaff ? undefined : randomUUID();
const payTokenExpiresAt = isStaff ? undefined : computePaymentDeadline(new Date(), schedule.departureAt);
const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
const payTokenExpiresAt = isStaff
? undefined
: computePaymentDeadline(new Date(), schedule.departureAt, undefined, bookingWindowMinutes);
const booking = await this.prisma.booking.create({
data: {

View File

@@ -0,0 +1,141 @@
import { NotificationsService } from './notifications.service';
/**
* Regression cover for the bug these handlers were written to fix: reschedule/upgrade
* notifications resolved an address only through `iam.users`, where `Passenger.iamUserId` is set
* on under 2% of rows, so EMAIL and SMS were silently skipped on virtually every real booking.
* The handlers must fall back to the contact details the booking itself carries.
*/
describe('booking-change notifications', () => {
const BOOKING = {
id: 'bk-1',
bookingRef: 'NFMRR0',
passengerId: 'pax-1',
bookingType: 'ONE_WAY',
contactPhone: '+251923594242',
contactEmail: 'work.abubeker@gmail.com',
originStationId: 'st-a',
destinationStationId: 'st-b',
schedule: {
departureAt: new Date('2026-09-22T09:00:00Z'),
arrivalAt: new Date('2026-09-22T18:00:00Z'),
originStation: { id: 'st-a', name: 'Sebeta' },
destinationStation: { id: 'st-b', name: 'Dire Dawa' },
stopTimes: [],
},
seats: [
{
leg: 1,
passengerName: 'Abubeker Yasin',
seat: { seatNumber: '3', coach: { number: 'VIP-0001', coachType: { name: 'VIP Seat' } } },
},
],
};
const TEMPLATE = {
code: 'booking.upgraded',
subject: 'Fare Class Upgraded',
bodyTemplate:
'Dear {{passengerName}},\n{{bookingRef}} {{origin}} → {{destination}}\n{{changeLines}}\nPaid: {{amountPaid}} {{currency}}\n{{detailLink}}',
active: true,
};
function build(opts: { iamAddress?: string | null; template?: any; booking?: any } = {}) {
const sms = jest.fn().mockResolvedValue({ queued: true });
const email = jest.fn().mockResolvedValue({ queued: true });
const svc: any = Object.create(NotificationsService.prototype);
svc.prisma = {
booking: { findUnique: jest.fn().mockResolvedValue(opts.booking === undefined ? BOOKING : opts.booking) },
notificationTemplate: {
findUnique: jest.fn().mockResolvedValue(opts.template === undefined ? TEMPLATE : opts.template),
},
};
svc.smsClient = { sendSms: sms };
svc.emailClient = { sendEmail: email };
svc.logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };
// The live condition: IAM knows nothing about this passenger.
svc.getRecipientAddress = jest.fn().mockResolvedValue(opts.iamAddress ?? null);
svc.createInAppNotification = jest.fn().mockResolvedValue(undefined);
return { svc, sms, email };
}
const upgradePayload = {
booking: { id: 'bk-1' },
upgrade: {
leg: 1,
feeMinor: 0,
fareDifferenceMinor: 70000,
items: [
{ passengerName: 'Abubeker Yasin', oldSeatLabel: 'RS-0002 seat 5', newSeatLabel: 'VIP-0001 seat 3' },
],
},
};
it('sends SMS and email via the booking contacts when IAM resolves nothing', async () => {
const { svc, sms, email } = build();
await svc.onBookingUpgraded(upgradePayload);
expect(sms).toHaveBeenCalledTimes(1);
expect(sms.mock.calls[0][0].to).toBe('+251923594242');
expect(email).toHaveBeenCalledTimes(1);
expect(email.mock.calls[0][0].to).toBe('work.abubeker@gmail.com');
expect(email.mock.calls[0][0].subject).toBe('Fare Class Upgraded');
});
it('renders the old → new seat line and the amount paid', async () => {
const { svc, sms } = build();
await svc.onBookingUpgraded(upgradePayload);
const body = sms.mock.calls[0][0].message;
expect(body).toContain('Abubeker Yasin: RS-0002 seat 5 → VIP-0001 seat 3');
expect(body).toContain('Paid: 700.00 ETB');
expect(body).toContain('Sebeta → Dire Dawa');
expect(body).not.toContain('{{'); // every placeholder interpolated
});
it('prefers the IAM address when there is one', async () => {
const { svc, sms } = build({ iamAddress: '+251900000000' });
await svc.onBookingUpgraded(upgradePayload);
expect(sms.mock.calls[0][0].to).toBe('+251900000000');
});
it('a failing SMS gateway does not suppress the email', async () => {
const { svc, sms, email } = build();
sms.mockRejectedValue(new Error('gateway down'));
await svc.onBookingUpgraded(upgradePayload);
expect(email).toHaveBeenCalledTimes(1);
expect(svc.logger.warn).toHaveBeenCalled();
});
it('sends nothing and does not throw when the booking has no contacts', async () => {
const { svc, sms, email } = build({ booking: { ...BOOKING, contactPhone: null, contactEmail: null } });
await expect(svc.onBookingUpgraded(upgradePayload)).resolves.toBeUndefined();
expect(sms).not.toHaveBeenCalled();
expect(email).not.toHaveBeenCalled();
});
it('a missing template is logged, not thrown', async () => {
const { svc, sms } = build({ template: null });
await expect(svc.onBookingUpgraded(upgradePayload)).resolves.toBeUndefined();
expect(sms).not.toHaveBeenCalled();
expect(svc.logger.warn).toHaveBeenCalledWith(expect.stringContaining('not found or inactive'));
});
it('expiry notification sends and never throws', async () => {
const { svc, sms, email } = build({
template: { ...TEMPLATE, code: 'booking.upgrade.expired', subject: 'Upgrade Request Expired' },
});
await svc.onUpgradeExpired({
bookingId: 'bk-1',
request: { leg: 1, amountDueMinor: 70000, items: upgradePayload.upgrade.items },
});
expect(sms).toHaveBeenCalledTimes(1);
expect(email.mock.calls[0][0].subject).toBe('Upgrade Request Expired');
});
it('a booking that vanished is logged, not thrown', async () => {
const { svc, sms } = build({ booking: null });
await expect(svc.onUpgradeExpired({ bookingId: 'gone', request: {} })).resolves.toBeUndefined();
expect(sms).not.toHaveBeenCalled();
});
});

View File

@@ -148,6 +148,180 @@ export class NotificationsService {
});
}
// ── Booking-change notification helpers ──────────────────────────────────
/** Relations the change templates render: station names, coach number and coach-type name. */
private static readonly CHANGE_INCLUDE = {
schedule: {
include: {
originStation: true,
destinationStation: true,
train: true,
stopTimes: { include: { station: true } },
},
},
seats: {
include: { seat: { include: { coach: { include: { coachType: true } } } } },
orderBy: { leg: 'asc' as const },
},
};
private fmtDate(d: any): string {
return d
? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
: 'TBD';
}
private fmtTime(d: any): string {
return d
? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
: 'TBD';
}
/** Minor units → major, 2dp. Charges are raised in ETB, so no conversion applies. */
private fmtMinor(minor: number): string {
return ((minor ?? 0) / 100).toFixed(2);
}
/**
* "Abubeker Yasin: RS-0002 seat 5 → VIP-0001 seat 3", one line per upgraded passenger.
* Labels come off `BookingUpgrade.items`, which snapshots them at quote time — so the message
* still reads correctly even after the seats have moved.
*/
private buildUpgradeChangeLines(items: any[]): string {
return (items ?? [])
.map((i) => {
const who = String(i?.passengerName ?? '').trim();
const from = String(i?.oldSeatLabel ?? '').trim() || 'previous seat';
const to = String(i?.newSeatLabel ?? '').trim() || 'new seat';
return `${who ? `${who}: ` : ''}${from}${to}`;
})
.join('\n');
}
/**
* Delivery addresses for a booking-change message. IAM first so a registered passenger's
* current details win, then the contact the booking itself carries — which is the only address
* a guest booking ever has. Mirrors the `iamPhone ?? contactPhone` fallback that
* `onBookingCreated` and `onPaymentSucceeded` already use.
*/
private async resolveDeliveryContacts(
booking: any,
passengerId: string | null,
): Promise<{ phone: string | null; email: string | null }> {
const iamPhone = passengerId
? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null)
: null;
const iamEmail = passengerId
? await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null)
: null;
return {
phone: iamPhone ?? booking?.contactPhone ?? null,
email: iamEmail ?? booking?.contactEmail ?? null,
};
}
/** Shared context for every change template: who, where, when, which seats. */
private buildBookingChangeContext(booking: any, ref: string): Record<string, unknown> {
const { passengerName, trainSeatLines } = buildSeatSummary(
booking?.seats ?? [],
booking?.bookingType,
);
const segment = resolveBookingSegment(
booking?.schedule ?? {},
booking?.originStationId,
booking?.destinationStationId,
);
return {
passengerName,
bookingRef: ref,
origin: segment.origin?.name ?? '',
destination: segment.destination?.name ?? '',
trainSeatLines,
travelDate: this.fmtDate(segment.departureAt),
departureTime: this.fmtTime(segment.departureAt),
arrivalTime: this.fmtTime(segment.arrivalAt),
currency: 'ETB',
detailLink: `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`,
};
}
/**
* Re-fetch → interpolate → in-app + direct SMS/email.
*
* The event payload is not enough on its own: the reschedule/upgrade services' own
* `bookingInclude` selects `coach: { select: { id, coachTypeId } }` and no station names, so
* buildSeatSummary would render "-, seat no. N". Always read the booking back with
* CHANGE_INCLUDE.
*
* Every failure here is logged and swallowed — a notification must never take down the cron or
* the event emitter that invoked it, and the reschedule/upgrade itself is already committed.
*/
private async notifyBookingChange(
templateCode: string,
bookingId: string,
extra: Record<string, unknown>,
): Promise<void> {
try {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: NotificationsService.CHANGE_INCLUDE as any,
});
if (!booking) {
this.logger.warn(`${templateCode}: booking ${bookingId} not found — nothing sent`);
return;
}
const template = await this.prisma.notificationTemplate.findUnique({
where: { code: templateCode },
});
if (!template || !template.active) {
this.logger.warn(`Template ${templateCode} not found or inactive`);
return;
}
const ref = (booking as any).bookingRef;
const context = { ...this.buildBookingChangeContext(booking, ref), ...extra };
const { subject, body } = this.interpolate(template, context);
const passengerId = (booking as any).passengerId ?? null;
if (passengerId) {
await this.createInAppNotification(passengerId, subject, body, {
category: 'BOOKING',
deepLink: `edr://bookings/${ref}`,
}).catch((err) =>
this.logger.warn(`${templateCode}: in-app notification failed for ${ref}: ${err}`),
);
}
const { phone, email } = await this.resolveDeliveryContacts(booking, passengerId);
if (!phone && !email) {
this.logger.warn(`${templateCode}: no contact details for booking ${ref} — nothing sent`);
return;
}
// Independent try/catch per channel: a dead SMS gateway must not cost the passenger
// their email too.
if (phone) {
try {
await this.smsClient.sendSms({ to: phone, message: body });
} catch (err) {
this.logger.warn(`${templateCode}: SMS failed for booking ${ref}: ${err}`);
}
}
if (email) {
try {
await this.emailClient.sendEmail({ to: email, subject, text: body });
} catch (err) {
this.logger.warn(`${templateCode}: email failed for booking ${ref}: ${err}`);
}
}
} catch (err) {
this.logger.error(`${templateCode}: notification failed for booking ${bookingId}: ${err}`);
}
}
private interpolate(
template: { subject?: string | null; bodyTemplate: string },
context: Record<string, unknown>,
@@ -731,22 +905,68 @@ export class NotificationsService {
);
}
/**
* Booking-change notifications (reschedule / upgrade, applied or expired).
*
* These deliberately do NOT go through `send()`. That path resolves an address only via
* `iam.users`, and `Passenger.iamUserId` is set on well under 2% of rows (and can dangle even
* when set), so EMAIL and SMS were silently skipped for almost every real booking while only the
* in-app row was written. They follow `onBookingCreated` instead: re-fetch, interpolate the
* template, then deliver straight to the booking's own contact details.
*/
@OnEvent('booking.rescheduled')
async onBookingRescheduled(payload: any) {
const { booking, reschedule } = payload;
await this.send(
'booking.rescheduled',
booking.passengerId,
{
bookingRef: booking.bookingRef,
leg: reschedule?.leg === 2 ? 'return' : 'outbound',
feeAmount: ((reschedule?.feeMinor ?? 0) / 100).toFixed(2),
currency: 'ETB',
category: 'BOOKING',
deepLink: `edr://bookings/${booking.bookingRef}`,
},
['IN_APP', 'EMAIL', 'SMS'],
);
let previousTravelDate = '';
if (reschedule?.oldScheduleId) {
const old = await this.prisma.trainSchedule
.findUnique({ where: { id: reschedule.oldScheduleId }, select: { departureAt: true } })
.catch(() => null);
// Pre-formatted so the template never renders a dangling 'Previously:' label.
previousTravelDate = old?.departureAt ? `Previously: ${this.fmtDate(old.departureAt)}
` : '';
}
await this.notifyBookingChange('booking.rescheduled', booking.id, {
leg: reschedule?.leg === 2 ? 'return' : 'outbound',
previousLine: previousTravelDate,
feeAmount: this.fmtMinor(reschedule?.feeMinor ?? 0),
amountPaid: this.fmtMinor(
(reschedule?.feeMinor ?? 0) + Math.max(0, reschedule?.fareDifferenceMinor ?? 0),
),
});
}
@OnEvent('booking.upgraded')
async onBookingUpgraded(payload: any) {
const { booking, upgrade } = payload;
const items = Array.isArray(upgrade?.items) ? upgrade.items : [];
await this.notifyBookingChange('booking.upgraded', booking.id, {
leg: upgrade?.leg === 2 ? 'return' : 'outbound',
passengerSummary: items.map((i: any) => i.passengerName).filter(Boolean).join(', '),
changeLines: this.buildUpgradeChangeLines(items),
amountPaid: this.fmtMinor(
(upgrade?.feeMinor ?? 0) + Math.max(0, upgrade?.fareDifferenceMinor ?? 0),
),
});
}
@OnEvent('booking.reschedule.expired')
async onRescheduleExpired(payload: any) {
await this.notifyBookingChange('booking.reschedule.expired', payload.bookingId, {
leg: payload.request?.leg === 2 ? 'return' : 'outbound',
amountDue: this.fmtMinor(payload.request?.amountDueMinor ?? 0),
});
}
@OnEvent('booking.upgrade.expired')
async onUpgradeExpired(payload: any) {
const items = Array.isArray(payload.request?.items) ? payload.request.items : [];
await this.notifyBookingChange('booking.upgrade.expired', payload.bookingId, {
leg: payload.request?.leg === 2 ? 'return' : 'outbound',
passengerSummary: items.map((i: any) => i.passengerName).filter(Boolean).join(', '),
changeLines: this.buildUpgradeChangeLines(items),
amountDue: this.fmtMinor(payload.request?.amountDueMinor ?? 0),
});
}
@OnEvent('booking.cancelled')

View File

@@ -1,3 +1,4 @@
import { SystemConfigModule } from '../system-config/system-config.module';
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
@@ -55,6 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
@Module({
imports: [
SystemConfigModule,
SeatsModule,
TicketsModule,
CurrencyModule,

View File

@@ -1,5 +1,6 @@
import { Test, TestingModule } from "@nestjs/testing";
import { PaymentsService } from "./payments.service";
import { SystemConfigService } from "../system-config/system-config.service";
import { PaymentClientService } from "./payment-client.service";
import { CurrencyService } from "../currency/currency.service";
import { PrismaService } from "../../common/prisma.service";
@@ -133,6 +134,8 @@ describe("PaymentsService", () => {
{ provide: PaymentClientService, useValue: mockPaymentClient },
{ provide: CurrencyService, useValue: mockCurrencyService },
{ provide: AuditService, useValue: { log: jest.fn() } },
// 120 = the default booking payment window; the deadline maths under test is unchanged by it.
{ provide: SystemConfigService, useValue: { getNumber: jest.fn().mockResolvedValue(120) } },
],
}).compile();

View File

@@ -36,6 +36,7 @@ import {
MIN_PAYMENT_WINDOW_MINUTES,
PAYMENT_SETTLE_MARGIN_SECONDS,
} from "../../common/utils/payment-deadline.utils";
import { CONFIG_KEYS, SystemConfigService } from "../system-config/system-config.service";
import {
PaymentClientService,
PaymentDiagnostic,
@@ -88,6 +89,7 @@ export class PaymentsService {
private readonly waafiDemoTrustReturn = true;
constructor(
private systemConfig: SystemConfigService,
private prisma: PrismaService,
private seatsService: SeatsService,
private ticketsService: TicketsService,
@@ -748,7 +750,10 @@ export class PaymentsService {
originRouteStop?.checkinMinutesBefore ??
booking.schedule.route?.checkinMinutesBefore ??
undefined;
return computePaymentDeadline(booking.createdAt, dep, checkinMinutes);
// Same window the auto-cancel cron uses, or the payer would be shown a deadline the cron
// does not honour.
const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
return computePaymentDeadline(booking.createdAt, dep, checkinMinutes, windowMinutes);
}
private resolveReturnUrls(

View File

@@ -7,6 +7,7 @@ import { SeatsModule } from '../seats/seats.module';
import { TicketsModule } from '../tickets/tickets.module';
import { PaymentsModule } from '../payments/payments.module';
import { CurrencyModule } from '../currency/currency.module';
import { SystemConfigModule } from '../system-config/system-config.module';
import { RescheduleController } from './reschedule.controller';
import { RescheduleService, SUPPLEMENTARY_CHARGE_PAID_EVENT } from './reschedule.service';
@@ -32,7 +33,7 @@ export class RescheduleEventsListener {
}
@Module({
imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule],
imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule, SystemConfigModule],
controllers: [RescheduleController],
providers: [RescheduleService, RescheduleEventsListener],
exports: [RescheduleService],

View File

@@ -13,6 +13,7 @@ import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { MeLikeUser } from '../../common/passenger-permission.util';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service';
import { normalizePhone, samePhone } from '../../common/utils/phone.utils';
import { BookingsService } from '../bookings/bookings.service';
import { SeatsService } from '../seats/seats.service';
@@ -85,6 +86,8 @@ type LegView = {
departureAt: Date;
seats: Array<{ id: string; seatId: string; passengerName: string; fareMinor: number | null; passengerCategory: string }>;
coachTypeId: string;
/** Every distinct coach type on the leg. More than one means a partial upgrade happened. */
coachTypeIds: string[];
};
// Seats are ordered by passenger name so getOptions(), quote() and create() all see the same
@@ -109,6 +112,7 @@ export class RescheduleService {
private currencyService: CurrencyService,
private auditService: AuditService,
private eventEmitter: EventEmitter2,
private systemConfig: SystemConfigService,
) {}
// ── Policy admin ─────────────────────────────────────────────────────────
@@ -267,7 +271,8 @@ export class RescheduleService {
const requestedBy = user.id ?? user.sub ?? booking.passengerId;
const newDeparture = q.newDepartureAt;
const expiresAt = computePaymentDeadline(new Date(), newDeparture);
const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.RESCHEDULE_PAYMENT_WINDOW_MINUTES);
const expiresAt = computePaymentDeadline(new Date(), newDeparture, undefined, windowMinutes);
const reschedule = await this.prisma.bookingReschedule.create({
data: {
@@ -314,7 +319,8 @@ export class RescheduleService {
where: { id: reschedule.id },
data: { supplementaryChargeId: charge.id },
});
await this.seatsService.confirmSeats(dto.newSeatIds);
// Same instant the charge carries, so the hold and the payment link die together.
await this.seatsService.confirmSeats(dto.newSeatIds, new Date(), expiresAt);
await this.auditService.log({
userId: requestedBy,
@@ -427,7 +433,7 @@ export class RescheduleService {
async expireStale(now = new Date()): Promise<number> {
const stale = await this.prisma.bookingReschedule.findMany({
where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
select: { id: true, supplementaryChargeId: true },
select: { id: true, supplementaryChargeId: true, holdId: true, bookingId: true, leg: true, amountDueMinor: true },
});
for (const r of stale) {
await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } });
@@ -437,7 +443,22 @@ export class RescheduleService {
data: { status: 'EXPIRED' },
});
}
// Release the seat the instant the request dies instead of leaving it to the hold's own
// TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at
// creation, and nothing keeps them in step afterwards — so without this the seat can sit
// unsellable long after the link that pays for it has expired. deleteMany: an already-swept
// hold must not throw.
if (r.holdId) {
await this.prisma.seatHold.deleteMany({ where: { id: r.holdId } });
}
}
// After the loop on purpose: the rows are already committed, so a notification failure
// cannot leave a request half-expired. Fire-and-forget — the listener swallows its own errors.
for (const s of stale) {
this.eventEmitter.emit('booking.reschedule.expired', { bookingId: s.bookingId, request: s });
}
return stale.length;
}
@@ -497,11 +518,11 @@ export class RescheduleService {
.map((s) => ({ id: s.id, seatId: s.seatId, passengerName: s.passengerName, fareMinor: s.fareMinor, passengerCategory: s.passengerCategory, coachTypeId: s.seat?.coach?.coachTypeId }));
const l1 = seatsOf(1);
if (l1.length && booking.schedule) {
legs.push({ leg: 1, scheduleId: booking.scheduleId, originStationId: booking.originStationId, destinationStationId: booking.destinationStationId, departureAt: booking.schedule.departureAt, seats: l1, coachTypeId: l1[0].coachTypeId });
legs.push({ leg: 1, scheduleId: booking.scheduleId, originStationId: booking.originStationId, destinationStationId: booking.destinationStationId, departureAt: booking.schedule.departureAt, seats: l1, coachTypeId: l1[0].coachTypeId, coachTypeIds: [...new Set(l1.map((s) => s.coachTypeId))] });
}
const l2 = seatsOf(2);
if (booking.bookingType === 'ROUND_TRIP' && l2.length && booking.returnSchedule) {
legs.push({ leg: 2, scheduleId: booking.returnScheduleId, originStationId: booking.returnOriginStationId, destinationStationId: booking.returnDestinationStationId, departureAt: booking.returnSchedule.departureAt, seats: l2, coachTypeId: l2[0].coachTypeId });
legs.push({ leg: 2, scheduleId: booking.returnScheduleId, originStationId: booking.returnOriginStationId, destinationStationId: booking.returnDestinationStationId, departureAt: booking.returnSchedule.departureAt, seats: l2, coachTypeId: l2[0].coachTypeId, coachTypeIds: [...new Set(l2.map((s) => s.coachTypeId))] });
}
return legs;
}
@@ -521,6 +542,13 @@ export class RescheduleService {
// round trip whose outbound was already used can't change its return yet — needs leg-scoped
// ticket regeneration.
if (booking.outboundBoardedAt || booking.returnBoardedAt) blockers.push('This booking has already been used for travel.');
// A partial fare-class upgrade can leave one leg spanning two coach types. Everything below
// — the policy lookup, the fee, the seat map — keys off a single leg-wide class taken from
// the first seat, so a mixed leg would silently reschedule at the wrong class and price.
// Refuse it outright until reschedule is made class-aware per passenger.
if (leg.coachTypeIds.length > 1) {
blockers.push('This booking has passengers in different fare classes. Please contact support to change it.');
}
if (!policy || !policy.isActive) blockers.push('Rescheduling is not available for this fare class.');
else if (leg.departureAt.getTime() - now.getTime() < policy.cutoffMinutes * 60_000) {
blockers.push(`Changes must be made at least ${policy.cutoffMinutes} minutes before departure.`);
@@ -537,6 +565,10 @@ export class RescheduleService {
const pending = await this.prisma.bookingReschedule.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } });
if (pending) blockers.push('A reschedule is already awaiting payment for this booking.');
// One change at a time. Two live supplementary charges would both drive ticket regeneration
// on this booking and interleave unpredictably once each is paid.
const pendingUpgrade = await this.prisma.bookingUpgrade.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } });
if (pendingUpgrade) blockers.push('An upgrade is awaiting payment for this booking — finish or cancel it first.');
if (dto.newSeatIds.length !== leg.seats.length) blockers.push(`Select exactly ${leg.seats.length} seat(s).`);
if (new Set(dto.newSeatIds).size !== dto.newSeatIds.length) blockers.push('Duplicate seats selected.');

View File

@@ -701,7 +701,14 @@ export class SeatsService {
// 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<void> {
/**
* `deadlineOverride` pins the hold to a deadline the caller has already computed. The
* reschedule and upgrade flows pass the exact value their supplementary charge carries — if
* this recomputed it instead, a per-flow payment window would give the hold and the payment
* link different lifetimes and the seat could lapse while the link still worked.
* Without it, the booking payment window is used, as before.
*/
async confirmSeats(seatIds: string[], now: Date = new Date(), deadlineOverride?: Date): Promise<void> {
if (seatIds.length === 0) return;
const holds = await this.prisma.seatHold.findMany({
@@ -717,24 +724,46 @@ export class SeatsService {
});
const departureById = new Map(schedules.map(s => [s.id, s.departureAt]));
let extended = 0;
const windowMinutes = deadlineOverride
? 0 // unused — the override wins below
: await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
let aligned = 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 (deadlineOverride) {
// Authoritative in BOTH directions. The caller already issued a payment link with this
// exact deadline, so the hold must match it — including when it is EARLIER than the
// hold's own TTL. Extending only would leave the seat held after the link that pays for
// it has died (reachable whenever a flow's payment window is shorter than
// seat_hold_duration_minutes), so the seat sits unsellable in between.
if (hold.expiresAt.getTime() === deadlineOverride.getTime()) return;
await this.prisma.seatHold.update({
where: { id: hold.id },
data: { expiresAt: deadlineOverride },
});
aligned++;
return;
}
const deadline = computePaymentDeadline(now, departureAt, undefined, windowMinutes);
// Normal booking path: only ever extend forward — never shorten a hold that's already
// valid longer than the payment deadline would give it. A round trip calls confirmSeats
// up to four times, and a later call must not pull in a hold an earlier one set.
if (deadline <= hold.expiresAt) return;
await this.prisma.seatHold.update({ where: { id: hold.id }, data: { expiresAt: deadline } });
extended++;
aligned++;
}),
);
if (extended > 0) {
if (aligned > 0) {
this.logger.log(
`Extended ${extended} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
deadlineOverride
? `Aligned ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their charge's payment deadline`
: `Extended ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
);
}
}

View File

@@ -1,6 +1,7 @@
import { IsInt, IsOptional, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { MIN_PAYMENT_WINDOW_MINUTES } from '../../common/utils/payment-deadline.utils';
/**
* Whitelisted, typed body for `PATCH /config`. Config is persisted as string key/values, but every
@@ -22,6 +23,23 @@ export class UpdateSystemConfigDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
boarding_window_hours_before_departure?: number;
// Payment windows, in minutes. Floored at MIN_PAYMENT_WINDOW_MINUTES (7) because canOpenPaymentSession
// refuses to open a provider session with less than that left — a window below it makes every
// card/HPP payment impossible to start. Capped at 1440 (24h) — the check-in cutoff already bounds the
// effective deadline, but a stray 100000 would make the auto-cancel pre-filter scan pointlessly
// far back.
@ApiPropertyOptional({ example: 120, description: 'Minutes a new booking has to be paid (7..1440)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440)
booking_payment_window_minutes?: number;
@ApiPropertyOptional({ example: 120, description: 'Minutes a reschedule charge has to be paid (7..1440)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440)
reschedule_payment_window_minutes?: number;
@ApiPropertyOptional({ example: 120, description: 'Minutes a fare upgrade has to be paid (7..1440)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440)
upgrade_payment_window_minutes?: number;
@ApiPropertyOptional({ example: 5 })
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
throttle_auth_limit?: number;

View File

@@ -5,6 +5,12 @@ export const CONFIG_KEYS = {
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure',
BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE: 'boarding_window_hours_before_departure',
// How long a passenger has to pay, per flow. The effective deadline is always
// MIN(now + window, departure - check-in cutoff) — a longer window can never let someone pay
// after boarding closes.
BOOKING_PAYMENT_WINDOW_MINUTES: 'booking_payment_window_minutes',
RESCHEDULE_PAYMENT_WINDOW_MINUTES: 'reschedule_payment_window_minutes',
UPGRADE_PAYMENT_WINDOW_MINUTES: 'upgrade_payment_window_minutes',
THROTTLE_AUTH_LIMIT: 'throttle_auth_limit',
THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms',
THROTTLE_STRICT_LIMIT: 'throttle_strict_limit',
@@ -17,6 +23,10 @@ const DEFAULTS: Record<string, string> = {
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2',
[CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE]: '4',
// 120 = the 2 hours these flows used before the window became configurable.
[CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES]: '120',
[CONFIG_KEYS.RESCHEDULE_PAYMENT_WINDOW_MINUTES]: '120',
[CONFIG_KEYS.UPGRADE_PAYMENT_WINDOW_MINUTES]: '120',
[CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: '5',
[CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000',
[CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20',

View File

@@ -3,10 +3,11 @@ import { PrismaModule } from '../../common/prisma.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { CurrencyModule } from '../currency/currency.module';
import { PaymentsModule } from '../payments/payments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
import { TasksService } from './tasks.service';
@Module({
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule, SystemConfigModule],
providers: [TasksService],
})
export class TasksModule {}

View File

@@ -6,6 +6,8 @@ import { SmsClientService } from '../notifications/sms-client.service';
import { CurrencyService } from '../currency/currency.service';
import { PaymentsService } from '../payments/payments.service';
import { RescheduleService } from '../reschedule/reschedule.service';
import { UpgradeService } from '../upgrade/upgrade.service';
import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service';
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
// Retention windows
@@ -44,6 +46,8 @@ export class TasksService {
// REQUEST), and injecting a request-scoped provider here would make TasksService request-scoped
// too — which silently stops all its @Cron methods from firing. Resolve it per-tick instead.
private readonly moduleRef: ModuleRef,
// Singleton (only injects Prisma), so it does not drag request scope in and silence the crons.
private readonly systemConfig: SystemConfigService,
) {}
// ─────────────────────────────────────────────────────────────────────────
@@ -177,6 +181,7 @@ export class TasksService {
// ── Send reminder at the midpoint of each booking's payment window ────────
private async sendPaymentReminders(now: Date) {
const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
// Only look at bookings created within the last 3 h with a future departure.
const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000);
@@ -216,7 +221,7 @@ export class TasksService {
);
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
if (dep <= now) continue; // segment has already departed; cancel job handles clean-up
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes, bookingWindowMinutes);
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
@@ -260,7 +265,10 @@ export class TasksService {
// ── Cancel bookings whose payment deadline has passed ─────────────────────
private async cancelExpiredPendingBookings(now: Date) {
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
// Must be the SAME window the payment link was issued with, or a shortened window would
// leave older bookings unselected by the pre-filter and never auto-cancelled.
const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
const windowAgo = new Date(now.getTime() - bookingWindowMinutes * 60 * 1000);
// The departure pre-filter below is a query-scoping optimization only — the real
// deadline check happens per-row further down. It must be widened to the largest
@@ -286,7 +294,7 @@ export class TasksService {
where: {
status: 'PENDING_PAYMENT',
OR: [
{ createdAt: { lte: twoHoursAgo } },
{ createdAt: { lte: windowAgo } },
{ schedule: { departureAt: { lte: departureCutoff } } },
],
},
@@ -329,7 +337,7 @@ export class TasksService {
(s: any) => s.stationId === (booking as any).originStationId,
);
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes, bookingWindowMinutes);
if (now < paymentDeadline) continue;
// Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded
@@ -526,6 +534,22 @@ export class TasksService {
}
}
// ─────────────────────────────────────────────────────────────────────────
// Every 1 min: fare-class upgrades whose payment deadline passed → EXPIRED.
// Separate from the reschedule sweep on purpose — a failure in one must not
// skip the other.
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/1 * * * *')
async expireStaleUpgrades() {
try {
const upgrade = await this.moduleRef.resolve(UpgradeService, undefined, { strict: false });
const n = await upgrade.expireStale();
if (n > 0) this.logger.log(`Expired ${n} unpaid upgrade request(s)`);
} catch (err) {
this.logger.error(`expireStaleUpgrades failed: ${err instanceof Error ? err.message : err}`);
}
}
@Cron('0 2 * * *')
async purgeExpiredData() {
const now = new Date();

View File

@@ -0,0 +1,91 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { UpgradeService } from './upgrade.service';
import {
CreateUpgradeDto,
CreateUpgradePolicyDto,
UpgradeHoldDto,
UpgradeQuoteDto,
UpdateUpgradePolicyDto,
} from './upgrade.dto';
@ApiTags('Fare upgrade')
@Controller()
export class UpgradeController {
constructor(private service: UpgradeService) {}
@Get('upgrade/policies')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' })
listPolicies() {
return this.service.listPolicies();
}
@Get('upgrade/policies/available-coach-types')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' })
listUnconfiguredCoachTypes() {
return this.service.listUnconfiguredCoachTypes();
}
@Post('upgrade/policies')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' })
createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) {
return this.service.createPolicy(dto, req.user?.id);
}
@Patch('upgrade/policies/:coachTypeId')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' })
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) {
return this.service.updatePolicy(coachTypeId, dto, req.user?.id);
}
@Delete('upgrade/policies/:coachTypeId')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' })
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {
return this.service.deletePolicy(coachTypeId, req.user?.id);
}
@Get('bookings/:bookingRef/upgrade')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Per-leg upgrade eligibility, per-passenger targets, pending request and history' })
options(@Req() req: any, @Param('bookingRef') bookingRef: string) {
return this.service.getOptions(bookingRef, req.user);
}
@Post('bookings/:bookingRef/upgrade/quote')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Price an upgrade without committing to it' })
quote(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: UpgradeQuoteDto) {
return this.service.quote(bookingRef, dto, req.user);
}
@Post('bookings/:bookingRef/upgrade/hold')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Hold the chosen seats, clearing abandoned attempts on this booking first' })
hold(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: UpgradeHoldDto) {
return this.service.holdForUpgrade(bookingRef, dto, req.user);
}
@Post('bookings/:bookingRef/upgrade')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Request an upgrade; returns a payment token when money is owed' })
create(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: CreateUpgradeDto) {
return this.service.create(bookingRef, dto, req.user);
}
}

View File

@@ -0,0 +1,94 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
export class UpdateUpgradePolicyDto {
@ApiPropertyOptional({ example: 2, description: 'Ladder position — an upgrade needs a strictly higher rank' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
rank?: number;
@ApiPropertyOptional({ example: 0, description: '% of the passenger\'s original fare charged as a change fee' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100)
feePercent?: number;
@ApiPropertyOptional({ example: 0, description: 'Fee floor in ETB minor units (500 ETB = 50000)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
feeMinMinor?: number;
@ApiPropertyOptional({ example: true, description: 'Waive the change fee entirely (policy US-17 §5)' })
@IsOptional() @IsBoolean()
feeWaived?: boolean;
@ApiPropertyOptional({ example: true, description: 'Passengers may upgrade OUT of this class' })
@IsOptional() @IsBoolean()
isUpgradable?: boolean;
@ApiPropertyOptional({ example: true, description: 'Passengers may upgrade INTO this class' })
@IsOptional() @IsBoolean()
isTargetable?: boolean;
@ApiPropertyOptional({ example: true })
@IsOptional() @IsBoolean()
isActive?: boolean;
}
export class CreateUpgradePolicyDto extends UpdateUpgradePolicyDto {
@ApiProperty({ example: 'coach-type-uuid', description: 'CoachType this policy applies to (one per fare class)' })
@IsString()
coachTypeId: string;
}
export class UpgradeItemDto {
@ApiProperty({ example: 'booking-seat-uuid', description: 'The BookingSeat row being upgraded' })
@IsString()
bookingSeatId: string;
@ApiProperty({ example: 'seat-uuid', description: 'Seat this passenger moves to, in the target coach type' })
@IsString()
newSeatId: string;
}
export class UpgradeQuoteDto {
@ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2)
leg?: number;
@ApiProperty({ example: 'coach-type-uuid', description: 'Fare class every listed passenger is moving to' })
@IsString()
newCoachTypeId: string;
@ApiProperty({
type: [UpgradeItemDto],
description:
'One entry per upgrading passenger. Keyed on bookingSeatId, not array position — only some ' +
'passengers move, so a positional pairing would be ambiguous.',
})
@IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => UpgradeItemDto)
items: UpgradeItemDto[];
}
export class UpgradeHoldDto {
@ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg' })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2)
leg?: number;
@ApiProperty({ type: [String], description: 'Seats to hold, in the target coach type' })
@IsArray() @ArrayMinSize(1) @IsString({ each: true })
seatIds: string[];
}
export class CreateUpgradeDto extends UpgradeQuoteDto {
@ApiProperty({ example: 'seat-hold-uuid', description: 'Hold covering every newSeatId' })
@IsString()
holdId: string;
}

View File

@@ -0,0 +1,46 @@
import { Injectable, Logger, Module } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { OnEvent } from '@nestjs/event-emitter';
import { AuditModule } from '../../common/audit.module';
import { BookingsModule } from '../bookings/bookings.module';
import { SeatsModule } from '../seats/seats.module';
import { SegmentsModule } from '../segments/segments.module';
import { TicketsModule } from '../tickets/tickets.module';
import { PaymentsModule } from '../payments/payments.module';
import { CurrencyModule } from '../currency/currency.module';
import { SystemConfigModule } from '../system-config/system-config.module';
import { SUPPLEMENTARY_CHARGE_PAID_EVENT } from '../reschedule/reschedule.service';
import { UpgradeController } from './upgrade.controller';
import { UpgradeService } from './upgrade.service';
/**
* Same shape and same reason as RescheduleEventsListener: UpgradeService is request-scoped by
* transitivity (AuditService injects REQUEST), and Nest never fires @OnEvent on request-scoped
* providers — so the listener is a singleton that resolves the service per event.
*
* Two listeners on one event is fine: each looks its charge up by its own unique
* `supplementaryChargeId` and returns silently when the charge is not theirs.
*/
@Injectable()
export class UpgradeEventsListener {
private readonly logger = new Logger(UpgradeEventsListener.name);
constructor(private readonly moduleRef: ModuleRef) {}
@OnEvent(SUPPLEMENTARY_CHARGE_PAID_EVENT, { async: true })
async onChargePaid(payload: { chargeId: string }) {
try {
const service = await this.moduleRef.resolve(UpgradeService, undefined, { strict: false });
await service.applyForCharge(payload.chargeId);
} catch (err) {
this.logger.error(`Failed to apply upgrade for charge ${payload.chargeId}: ${err instanceof Error ? err.message : err}`);
}
}
}
@Module({
imports: [AuditModule, BookingsModule, SeatsModule, SegmentsModule, TicketsModule, PaymentsModule, CurrencyModule, SystemConfigModule],
controllers: [UpgradeController],
providers: [UpgradeService, UpgradeEventsListener],
exports: [UpgradeService],
})
export class UpgradeModule {}

View File

@@ -0,0 +1,52 @@
import { computeUpgradeAmounts } from './upgrade.service';
// Pure arithmetic only, mirroring reschedule.service.spec.ts — no Nest test module, no mocks.
describe('computeUpgradeAmounts', () => {
const free = { feePercent: 0, feeMinMinor: 0, feeWaived: false };
const waived = { feePercent: 30, feeMinMinor: 50000, feeWaived: true };
const percentOnly = { feePercent: 10, feeMinMinor: 0, feeWaived: false };
const flooredFee = { feePercent: 10, feeMinMinor: 50000, feeWaived: false };
it('charges only the fare difference when the class has no fee', () => {
// RS 1752.34 → EBC 2336.46, as seeded on dev
expect(computeUpgradeAmounts(free, 175234, 233646)).toEqual({
feeMinor: 0,
fareDifferenceMinor: 58412,
amountDueMinor: 58412,
});
});
it('ignores a configured fee when the policy waives it (US-17 §5)', () => {
expect(computeUpgradeAmounts(waived, 175234, 233646)).toEqual({
feeMinor: 0,
fareDifferenceMinor: 58412,
amountDueMinor: 58412,
});
});
it('takes the fee as a percentage of the ORIGINAL fare, not of the difference', () => {
const r = computeUpgradeAmounts(percentOnly, 175234, 233646);
expect(r.feeMinor).toBe(17523); // 10% of 175234, not of 58412
expect(r.amountDueMinor).toBe(17523 + 58412);
});
it('applies the fee floor when the percentage falls below it', () => {
const r = computeUpgradeAmounts(flooredFee, 100000, 150000);
expect(r.feeMinor).toBe(50000); // max(10% of 100000 = 10000, floor 50000)
expect(r.amountDueMinor).toBe(100000);
});
it('never lets a negative difference reduce the amount due', () => {
// Refused upstream, but the arithmetic must not produce a credit if it ever gets here.
const r = computeUpgradeAmounts(percentOnly, 200000, 150000);
expect(r.fareDifferenceMinor).toBe(-50000);
expect(r.amountDueMinor).toBe(r.feeMinor);
expect(r.amountDueMinor).toBeGreaterThanOrEqual(0);
});
it('charges the full target fare for a free child', () => {
const r = computeUpgradeAmounts(free, 0, 233646);
expect(r.feeMinor).toBe(0); // a percentage of zero is zero
expect(r.amountDueMinor).toBe(233646);
});
});

View File

@@ -0,0 +1,874 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service';
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
import {
ActingUser,
isNonFareCoachType,
loadOwnedBooking,
NOT_A_FARE_CLASS,
pickSeatClass,
resolveNationalityProxy,
} from '../../common/utils/booking-change.utils';
import { BookingsService } from '../bookings/bookings.service';
import { SeatsService } from '../seats/seats.service';
import { SegmentsService } from '../segments/segments.service';
import { TicketsService } from '../tickets/tickets.service';
import { PaymentsService } from '../payments/payments.service';
import { SupplementaryChargesService } from '../payments/supplementary-charges.service';
import { CurrencyService } from '../currency/currency.service';
import { JourneyDirection } from '../seats/seats.dto';
import {
CreateUpgradeDto,
CreateUpgradePolicyDto,
UpgradeHoldDto,
UpgradeQuoteDto,
UpdateUpgradePolicyDto,
} from './upgrade.dto';
export const UPGRADE_CHARGE_REASON = 'UPGRADE';
type PolicyFee = { feePercent: number; feeMinMinor: number; feeWaived: boolean };
/**
* Pure fee arithmetic for one upgrading passenger — policy US-17. The fee is read from the class
* being upgraded TO (§5 waives it for the premium classes), and is a percentage of that
* passenger's ORIGINAL fare, not of the difference.
*
* A non-positive difference never produces a credit: an upgrade that prices below the current
* seat is refused upstream rather than refunded here (see `buildQuote`).
*/
export function computeUpgradeAmounts(
policy: PolicyFee,
oldFareMinor: number,
newFareMinor: number,
): { feeMinor: number; fareDifferenceMinor: number; amountDueMinor: number } {
const feeMinor = policy.feeWaived
? 0
: policy.feePercent > 0 || policy.feeMinMinor > 0
? Math.max(Math.round((oldFareMinor * policy.feePercent) / 100), policy.feeMinMinor)
: 0;
const fareDifferenceMinor = newFareMinor - oldFareMinor;
return { feeMinor, fareDifferenceMinor, amountDueMinor: feeMinor + Math.max(0, fareDifferenceMinor) };
}
type UpgradeItem = {
bookingSeatId: string;
passengerName: string;
passengerCategory: string;
oldSeatId: string;
oldSeatLabel: string | null;
oldCoachTypeId: string;
oldSeatClassId: string | null;
oldFareMinor: number;
newSeatId: string;
newSeatLabel: string | null;
newCoachTypeId: string;
newSeatClassId: string | null;
newFareMinor: number;
feeMinor: number;
fareDifferenceMinor: number;
};
// Seats ordered the same way the reschedule flow orders them, so both features present a leg's
// passengers in one stable sequence. Upgrade itself keys on bookingSeatId, not position.
const bookingInclude = {
schedule: {
select: {
id: true, departureAt: true, arrivalAt: true, status: true,
originStationId: true, destinationStationId: true,
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
},
},
returnSchedule: {
select: {
id: true, departureAt: true, arrivalAt: true, status: true,
originStationId: true, destinationStationId: true,
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
},
},
seats: {
include: { seat: { include: { coach: { select: { id: true, coachTypeId: true } } } } },
orderBy: [{ passengerName: 'asc' as const }, { id: 'asc' as const }],
},
} satisfies Prisma.BookingInclude;
@Injectable()
export class UpgradeService {
private readonly logger = new Logger(UpgradeService.name);
constructor(
private prisma: PrismaService,
private bookingsService: BookingsService,
private seatsService: SeatsService,
private segmentsService: SegmentsService,
private ticketsService: TicketsService,
private paymentsService: PaymentsService,
private supplementaryCharges: SupplementaryChargesService,
private currencyService: CurrencyService,
private auditService: AuditService,
private eventEmitter: EventEmitter2,
private systemConfig: SystemConfigService,
) {}
// ── Policy admin ─────────────────────────────────────────────────────────
async listPolicies() {
return this.prisma.upgradePolicy.findMany({
include: { coachType: { select: { id: true, code: true, name: true, type: true } } },
orderBy: { rank: 'asc' },
});
}
/** Fare classes with no upgrade policy yet — the add dialog's dropdown. */
async listUnconfiguredCoachTypes() {
return this.prisma.coachType.findMany({
where: { ...NOT_A_FARE_CLASS, upgradePolicy: { is: null } },
select: { id: true, code: true, name: true, type: true },
orderBy: { code: 'asc' },
});
}
async createPolicy(dto: CreateUpgradePolicyDto, actorId?: string) {
const { coachTypeId, ...values } = dto;
const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } });
if (!coachType) throw new NotFoundException('Coach type not found');
if (isNonFareCoachType(coachType)) {
throw new BadRequestException(`${coachType.code} is not a fare class — no seats are sold in it.`);
}
const existing = await this.prisma.upgradePolicy.findUnique({ where: { coachTypeId } });
if (existing) throw new ConflictException(`${coachType.code} already has an upgrade policy — edit it instead.`);
await this.assertRankIsFree(values.rank ?? 0, null);
const policy = await this.prisma.upgradePolicy.create({ data: { coachTypeId, ...values } });
await this.auditService.log({
userId: actorId,
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.UpgradePolicy,
entityId: policy.id,
newData: { coachTypeCode: coachType.code, ...values },
});
return policy;
}
async updatePolicy(coachTypeId: string, dto: UpdateUpgradePolicyDto, actorId?: string) {
const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } });
if (!coachType) throw new NotFoundException('Coach type not found');
const before = await this.prisma.upgradePolicy.findUnique({ where: { coachTypeId } });
if (dto.rank !== undefined) await this.assertRankIsFree(dto.rank, coachTypeId);
const policy = await this.prisma.upgradePolicy.upsert({
where: { coachTypeId },
update: dto,
create: { coachTypeId, ...dto },
});
await this.auditService.log({
userId: actorId,
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.UpgradePolicy,
entityId: policy.id,
oldData: before ?? undefined,
newData: { coachTypeCode: coachType.code, ...dto },
});
return policy;
}
async deletePolicy(coachTypeId: string, actorId?: string) {
const policy = await this.prisma.upgradePolicy.findUnique({
where: { coachTypeId },
include: { coachType: { select: { code: true } } },
});
if (!policy) throw new NotFoundException('Upgrade policy not found');
await this.prisma.upgradePolicy.delete({ where: { coachTypeId } });
await this.auditService.log({
userId: actorId,
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.UpgradePolicy,
entityId: policy.id,
oldData: policy,
});
// With no policy the class can be neither left nor entered — the intended effect of deleting.
return { deleted: true, coachTypeId };
}
/**
* Two active policies sharing a rank make "strictly higher" undefined, so the ladder must stay
* a total order.
*/
private async assertRankIsFree(rank: number, exceptCoachTypeId: string | null) {
const clash = await this.prisma.upgradePolicy.findFirst({
where: { rank, isActive: true, ...(exceptCoachTypeId ? { coachTypeId: { not: exceptCoachTypeId } } : {}) },
include: { coachType: { select: { code: true } } },
});
if (clash) {
throw new ConflictException(`Rank ${rank} is already used by ${clash.coachType.code}. Ranks must be unique.`);
}
}
// ── Reads ────────────────────────────────────────────────────────────────
/** Per leg: who can upgrade, to which classes, and roughly what it costs. */
async getOptions(bookingRef: string, user: ActingUser) {
const booking = await this.load(bookingRef, user);
const legs = this.legsOf(booking);
const pending = await this.prisma.bookingUpgrade.findFirst({
where: { bookingId: booking.id, status: 'PENDING_PAYMENT' },
});
const charge = pending?.supplementaryChargeId
? await this.prisma.supplementaryCharge.findUnique({
where: { id: pending.supplementaryChargeId },
select: { paymentToken: true, status: true, expiresAt: true },
})
: null;
const out = [];
for (const leg of legs) {
const blockers = await this.legBlockers(booking, leg);
const targets = await this.targetsFor(leg);
// Each passenger's own class decides what counts as "up" for them, so the source policy
// has to be resolved per seat — on a mixed-class booking they differ.
const sourcePolicies = await this.prisma.upgradePolicy.findMany({
where: { coachTypeId: { in: [...new Set(leg.seats.map((s: any) => s.coachTypeId as string).filter(Boolean))] as string[] } },
});
const sourceByCoachType = new Map(sourcePolicies.map((p) => [p.coachTypeId, p]));
const passengers = leg.seats.map((s: any) => {
const source = sourceByCoachType.get(s.coachTypeId);
const canLeave = !!source && source.isActive && source.isUpgradable;
return {
bookingSeatId: s.id,
passengerName: s.passengerName,
passengerCategory: s.passengerCategory,
seatId: s.seatId,
seatLabel: s.seatLabel,
coachTypeId: s.coachTypeId,
currentRank: source?.rank ?? null,
currentFareMinor: s.fareMinor ?? 0,
// A passenger can only move up from where they actually sit, which on a mixed-class
// booking differs per passenger. No policy on their current class means they cannot
// leave it at all.
targets: canLeave
? targets.filter((t) => t.rank > source!.rank && t.coachTypeId !== s.coachTypeId)
: [],
};
});
out.push({
leg: leg.leg,
scheduleId: leg.scheduleId,
originStationId: leg.originStationId,
destinationStationId: leg.destinationStationId,
departureAt: leg.departureAt,
checkinCutoffAt: leg.checkin?.cutoffAt ?? null,
checkinMinutes: leg.checkin?.checkinMinutes ?? null,
canUpgrade: blockers.length === 0 && passengers.some((p: any) => p.targets.length > 0),
blockers,
passengers,
});
}
const history = await this.prisma.bookingUpgrade.findMany({
where: { bookingId: booking.id, status: { not: 'PENDING_PAYMENT' } },
orderBy: { createdAt: 'desc' },
});
return {
bookingRef: booking.bookingRef,
bookingType: booking.bookingType,
legs: out,
pending: pending ? { ...pending, paymentToken: charge?.paymentToken ?? null } : null,
history,
};
}
// ── Quote / create / apply ───────────────────────────────────────────────
/**
* Takes the seat hold for an upgrade attempt.
*
* Server-side rather than letting the portal call `/seats/hold` directly, because an upgrade
* holds on the SAME schedule the booking already occupies — so a retry collides with the
* caller's own abandoned attempt: first on the synthetic passenger id, and if they re-pick the
* same seat, on the seat itself. Clearing this booking's own stale upgrade holds first is the
* only way a passenger can change their mind inside the hold TTL. Deriving the schedule and
* stations from the booking instead of trusting the client is a bonus.
*/
async holdForUpgrade(bookingRef: string, dto: UpgradeHoldDto, user: ActingUser) {
const booking = await this.load(bookingRef, user);
const legNo = dto.leg ?? 1;
const leg = this.legsOf(booking).find((l) => l.leg === legNo);
if (!leg) throw new BadRequestException(`Booking has no leg ${legNo}`);
await this.releaseAbandonedHolds(booking.bookingRef, leg.scheduleId);
return this.seatsService.holdSeats({
scheduleId: leg.scheduleId,
originStationId: leg.originStationId,
destinationStationId: leg.destinationStationId,
journeyDirection: legNo === 2 ? JourneyDirection.RETURN : JourneyDirection.ONE_WAY,
// Synthetic ids: there is no real passenger id to hand, and holdSeats only uses them to
// stop one passenger holding two seats on a leg. Tagged with the booking ref so this
// booking's own abandoned attempts can be told apart from anyone else's hold.
passengers: dto.seatIds.map((seatId, i) => ({
passengerId: `${this.upgradeHoldPrefix(bookingRef)}${i}`,
seatId,
})),
} as any);
}
private upgradeHoldPrefix(bookingRef: string) {
return `upgrade-${bookingRef}-`;
}
/**
* Deletes holds this booking's own earlier upgrade attempts left behind, except one already
* committed to a PENDING_PAYMENT upgrade (that one is paid-for and must survive).
*/
private async releaseAbandonedHolds(bookingRef: string, scheduleId: string) {
const prefix = this.upgradeHoldPrefix(bookingRef);
const live = await this.prisma.bookingUpgrade.findMany({
where: { status: 'PENDING_PAYMENT', holdId: { not: null } },
select: { holdId: true },
});
const committed = new Set(live.map((u) => u.holdId!));
const holds = await this.prisma.seatHold.findMany({ where: { scheduleId } });
const mine = holds.filter((h) => {
if (committed.has(h.id)) return false;
if (!h.createdBy?.trimStart().startsWith('{')) return false;
try {
const meta = JSON.parse(h.createdBy);
return (meta.passengers ?? []).some((p: any) => String(p.passengerId ?? '').startsWith(prefix));
} catch {
return false;
}
});
if (mine.length) {
await this.prisma.seatHold.deleteMany({ where: { id: { in: mine.map((h) => h.id) } } });
this.logger.log(`Released ${mine.length} abandoned upgrade hold(s) for ${bookingRef}`);
}
}
async quote(bookingRef: string, dto: UpgradeQuoteDto, user: ActingUser) {
const booking = await this.load(bookingRef, user);
return this.buildQuote(booking, dto);
}
async create(bookingRef: string, dto: CreateUpgradeDto, user: ActingUser) {
const booking = await this.load(bookingRef, user);
const q = await this.buildQuote(booking, dto, { skipAvailability: true });
if (!q.allowed) throw new BadRequestException(q.blockers.join(' '));
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
if (hold.scheduleId !== q.scheduleId) throw new BadRequestException('Seat hold is for a different schedule');
const held = new Set(hold.seatIds);
if (!q.items.every((it) => held.has(it.newSeatId))) {
throw new BadRequestException('Selected seats are not covered by the hold');
}
const requestedBy = user.id ?? user.sub ?? booking.passengerId;
// Deadline is the earlier of the usual 2h payment window and the check-in cutoff, so a
// passenger can never pay for an upgrade after boarding has closed on it.
const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.UPGRADE_PAYMENT_WINDOW_MINUTES);
const expiresAt = computePaymentDeadline(
new Date(),
q.checkin.segmentTime,
q.checkin.checkinMinutes,
windowMinutes,
);
const upgrade = await this.prisma.bookingUpgrade.create({
data: {
bookingId: booking.id,
leg: q.leg,
status: 'PENDING_PAYMENT',
requestedBy,
scheduleId: q.scheduleId,
items: q.items as unknown as Prisma.InputJsonValue,
holdId: dto.holdId,
oldFareMinor: q.oldFareMinor,
newFareMinor: q.newFareMinor,
fareDifferenceMinor: q.fareDifferenceMinor,
feeMinor: q.feeMinor,
amountDueMinor: q.amountDueMinor,
expiresAt: q.amountDueMinor > 0 ? expiresAt : null,
},
});
if (q.amountDueMinor === 0) {
await this.apply(upgrade.id);
return { upgradeId: upgrade.id, status: 'APPLIED', amountDueMinor: 0, paymentToken: null, quote: q };
}
const charge = await this.supplementaryCharges.create({
bookingRef: booking.bookingRef,
amountMinor: q.amountDueMinor,
reason: UPGRADE_CHARGE_REASON,
notes: `Upgrade leg ${q.leg}${q.newCoachTypeCode} (${q.items.length} passenger(s))`,
createdBy: requestedBy,
expiresAt,
});
await this.prisma.bookingUpgrade.update({
where: { id: upgrade.id },
data: { supplementaryChargeId: charge.id },
});
// Same instant the charge carries, so the hold and the payment link die together.
await this.seatsService.confirmSeats(q.items.map((it) => it.newSeatId), new Date(), expiresAt);
await this.auditService.log({
userId: requestedBy,
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.BookingUpgrade,
entityId: upgrade.id,
newData: {
bookingRef: booking.bookingRef,
leg: q.leg,
newCoachTypeId: dto.newCoachTypeId,
amountDueMinor: q.amountDueMinor,
chargeId: charge.id,
},
});
return {
upgradeId: upgrade.id,
status: 'PENDING_PAYMENT',
amountDueMinor: q.amountDueMinor,
paymentToken: charge.paymentToken,
expiresAt,
quote: q,
};
}
/** Entry point for the paid-charge event. Idempotent: only a PENDING_PAYMENT row is applied. */
async applyForCharge(supplementaryChargeId: string) {
const u = await this.prisma.bookingUpgrade.findUnique({ where: { supplementaryChargeId } });
if (!u || u.status !== 'PENDING_PAYMENT') return;
await this.apply(u.id);
}
/** Moves the named passengers into their new seats. The schedule never changes. */
async apply(upgradeId: string) {
const u = await this.prisma.bookingUpgrade.findUnique({ where: { id: upgradeId } });
if (!u) throw new NotFoundException('Upgrade not found');
if (u.status !== 'PENDING_PAYMENT') return u;
const booking = await this.prisma.booking.findUnique({ where: { id: u.bookingId }, include: bookingInclude });
if (!booking) throw new NotFoundException('Booking not found');
const items = u.items as unknown as UpgradeItem[];
const seatById = new Map(booking.seats.map((s) => [s.id, s]));
for (const it of items) {
if (!seatById.has(it.bookingSeatId)) {
throw new BadRequestException('A passenger on this upgrade is no longer on the booking');
}
}
const newTotal = Math.max(0, booking.totalMinor + u.fareDifferenceMinor);
const displayTotal =
booking.displayCurrency && booking.displayCurrency !== 'ETB'
? await this.currencyService.convertAmount(newTotal, 'ETB' as any, booking.displayCurrency as any)
: newTotal;
await this.prisma.$transaction(async (tx) => {
await tx.booking.update({
where: { id: booking.id },
data: { totalMinor: newTotal, displayTotalMinor: displayTotal },
});
// Two passes, as the reschedule flow does. Here it is defensive rather than required: the
// schedule is unchanged, so `@@unique([scheduleId, seatId])` can only collide when one
// request upgrades two passengers and the second lands on a seat the first is vacating
// (B: EBC→VIP frees EBC-7 while A: RS→EBC takes it). Parking every row on a per-row-unique
// sentinel first makes the write order irrelevant.
for (const it of items) {
await tx.bookingSeat.update({
where: { id: it.bookingSeatId },
data: { scheduleId: `moving-${it.bookingSeatId}` },
});
}
for (const it of items) {
await tx.bookingSeat.update({
where: { id: it.bookingSeatId },
data: {
seatId: it.newSeatId,
scheduleId: u.scheduleId,
fareMinor: it.newFareMinor,
seatLabelSnapshot: null,
},
});
}
await tx.bookingModification.create({
data: {
bookingId: booking.id,
modifiedBy: u.requestedBy,
modificationType: 'UPGRADE',
oldData: {
leg: u.leg,
scheduleId: u.scheduleId,
items: items.map((i) => ({
bookingSeatId: i.bookingSeatId, passengerName: i.passengerName,
seatId: i.oldSeatId, seatLabel: i.oldSeatLabel,
coachTypeId: i.oldCoachTypeId, fareMinor: i.oldFareMinor,
})),
},
newData: {
leg: u.leg,
scheduleId: u.scheduleId,
feeMinor: u.feeMinor,
items: items.map((i) => ({
bookingSeatId: i.bookingSeatId, passengerName: i.passengerName,
seatId: i.newSeatId, seatLabel: i.newSeatLabel,
coachTypeId: i.newCoachTypeId, fareMinor: i.newFareMinor,
})),
},
fareAdjustment: u.fareDifferenceMinor,
},
});
await tx.bookingUpgrade.update({
where: { id: u.id },
data: { status: 'APPLIED', appliedAt: new Date() },
});
});
// Occupancy + tickets are rebuilt from the (now updated) booking, outside the transaction.
const fresh = await this.prisma.booking.findUnique({
where: { id: booking.id },
include: { seats: true, tickets: { select: { id: true } } },
});
if (fresh) {
try {
await this.seatsService.releaseSeats(fresh.id);
await this.paymentsService.createJourneySegments(fresh as any);
} catch (err) {
this.logger.error(`Upgrade ${u.id}: journey segments failed: ${err instanceof Error ? err.message : err}`);
}
// Old tickets' SYSTEM seat blocks reference ticket ids generate() is about to delete, and
// generate() only clears blocks for the booking's CURRENT seats — the vacated seat is no
// longer among them, so its block would survive.
for (const t of fresh.tickets) {
await this.prisma.seatBlock.deleteMany({ where: { reason: { contains: t.id }, blockedBy: 'SYSTEM' } });
}
try {
await this.ticketsService.generate(fresh.id);
} catch (err) {
this.logger.error(`Upgrade ${u.id}: ticket generation failed: ${err instanceof Error ? err.message : err}`);
}
}
// Unlike reschedule, this upgrade stayed on the SAME schedule — so the booking's original
// seat hold is still in scope and would keep the vacated seat reading HELD on the very train
// still being sold. Clearing it is what puts that seat back on sale.
await this.prisma.seatHold.deleteMany({
where: {
OR: [
{ id: u.holdId ?? '' },
{ scheduleId: u.scheduleId, seatIds: { hasSome: items.map((i) => i.oldSeatId) } },
],
},
});
await this.auditService.log({
userId: u.requestedBy,
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Booking,
entityId: booking.id,
oldData: { leg: u.leg, items: items.map((i) => ({ seatId: i.oldSeatId, coachTypeId: i.oldCoachTypeId })) },
newData: {
leg: u.leg,
upgradeId: u.id,
feeMinor: u.feeMinor,
fareDifferenceMinor: u.fareDifferenceMinor,
items: items.map((i) => ({ seatId: i.newSeatId, coachTypeId: i.newCoachTypeId })),
},
});
this.eventEmitter.emit('booking.upgraded', { booking: fresh ?? booking, upgrade: u });
return { ...u, status: 'APPLIED' };
}
/** Cron hook: unpaid upgrades past their payment deadline. The seat hold lapses by itself. */
async expireStale(now = new Date()): Promise<number> {
const stale = await this.prisma.bookingUpgrade.findMany({
where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
select: { id: true, supplementaryChargeId: true, holdId: true, bookingId: true, leg: true, amountDueMinor: true, items: true },
});
for (const u of stale) {
await this.prisma.bookingUpgrade.update({ where: { id: u.id }, data: { status: 'EXPIRED' } });
if (u.supplementaryChargeId) {
await this.prisma.supplementaryCharge.updateMany({
where: { id: u.supplementaryChargeId, status: 'PENDING' },
data: { status: 'EXPIRED' },
});
}
// Release the seat the instant the request dies instead of leaving it to the hold's own
// TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at
// creation, and nothing keeps them in step afterwards — so without this the seat can sit
// unsellable long after the link that pays for it has expired. deleteMany: an already-swept
// hold must not throw.
if (u.holdId) {
await this.prisma.seatHold.deleteMany({ where: { id: u.holdId } });
}
}
// After the loop on purpose: the rows are already committed, so a notification failure
// cannot leave a request half-expired. Fire-and-forget — the listener swallows its own errors.
for (const s of stale) {
this.eventEmitter.emit('booking.upgrade.expired', { bookingId: s.bookingId, request: s });
}
return stale.length;
}
// ── Internals ────────────────────────────────────────────────────────────
private load(bookingRef: string, user: ActingUser) {
return loadOwnedBooking(this.prisma, bookingRef, user, bookingInclude, 'upgrade it') as Promise<
Prisma.BookingGetPayload<{ include: typeof bookingInclude }>
>;
}
private legsOf(booking: any) {
const legs: any[] = [];
const build = (n: number, scheduleId: string, schedule: any, originStationId: string, destinationStationId: string) => {
const seats = (booking.seats as any[])
.filter((s) => (s.leg ?? 1) === n)
.map((s) => ({
id: s.id,
seatId: s.seatId,
seatLabel: s.seatLabelSnapshot ?? s.seat?.seatNumber ?? null,
passengerName: s.passengerName,
passengerCategory: s.passengerCategory,
fareMinor: s.fareMinor,
coachTypeId: s.seat?.coach?.coachTypeId,
}));
if (!seats.length || !schedule) return;
legs.push({ leg: n, scheduleId, schedule, originStationId, destinationStationId, departureAt: schedule.departureAt, seats });
};
build(1, booking.scheduleId, booking.schedule, booking.originStationId, booking.destinationStationId);
if (booking.bookingType === 'ROUND_TRIP') {
build(2, booking.returnScheduleId, booking.returnSchedule, booking.returnOriginStationId, booking.returnDestinationStationId);
}
return legs;
}
/** Resolves the boarding stop's check-in cutoff — the deadline US-17 §1 means by "before check-in". */
private async resolveLegCheckin(leg: any) {
const stopTime = await this.prisma.tripStopTime.findFirst({
where: { scheduleId: leg.scheduleId, stationId: leg.originStationId ?? undefined },
select: { plannedArrivalAt: true, plannedDepartureAt: true },
});
return resolveCheckinCutoff(leg.schedule, stopTime, leg.originStationId);
}
private async legBlockers(booking: any, leg: any, now = new Date()): Promise<string[]> {
const blockers: string[] = [];
if (!['ONE_WAY', 'ROUND_TRIP'].includes(booking.bookingType)) blockers.push('Only one-way and round-trip bookings can be upgraded.');
if (booking.status !== 'CONFIRMED') blockers.push('Only confirmed bookings can be upgraded.');
if (booking.outboundBoardedAt || booking.returnBoardedAt) blockers.push('This booking has already been used for travel.');
if (leg.schedule?.status !== 'SCHEDULED' || leg.departureAt <= now) blockers.push('This departure is no longer upgradable.');
leg.checkin = await this.resolveLegCheckin(leg);
if (leg.checkin.cutoffAt <= now) {
blockers.push(`Upgrades close ${leg.checkin.checkinMinutes} minutes before departure.`);
}
// One change at a time. Two live supplementary charges could both drive ticket regeneration
// on this booking and interleave unpredictably.
const pendingUpgrade = await this.prisma.bookingUpgrade.findFirst({
where: { bookingId: booking.id, status: 'PENDING_PAYMENT' },
});
if (pendingUpgrade) blockers.push('An upgrade is already awaiting payment for this booking.');
const pendingReschedule = await this.prisma.bookingReschedule.findFirst({
where: { bookingId: booking.id, status: 'PENDING_PAYMENT' },
});
if (pendingReschedule) blockers.push('A reschedule is awaiting payment for this booking — finish or cancel it first.');
return blockers;
}
/** Fare classes on this schedule that anyone could upgrade into. */
private async targetsFor(leg: any) {
const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId: leg.scheduleId, isOperational: true },
select: { coach: { select: { coachTypeId: true } } },
});
const onBoard = [...new Set(assignments.map((a) => a.coach.coachTypeId))];
if (!onBoard.length) return [];
const policies = await this.prisma.upgradePolicy.findMany({
where: { coachTypeId: { in: onBoard }, isActive: true, isTargetable: true, coachType: NOT_A_FARE_CLASS },
include: { coachType: { select: { id: true, code: true, name: true } } },
orderBy: { rank: 'asc' },
});
return policies.map((p) => ({
coachTypeId: p.coachTypeId,
code: p.coachType.code,
name: p.coachType.name,
rank: p.rank,
feePercent: p.feePercent,
feeMinMinor: p.feeMinMinor,
feeWaived: p.feeWaived,
}));
}
private async buildQuote(booking: any, dto: UpgradeQuoteDto, opts: { skipAvailability?: boolean } = {}) {
const legNo = dto.leg ?? 1;
const leg = this.legsOf(booking).find((l) => l.leg === legNo);
if (!leg) throw new BadRequestException(`Booking has no leg ${legNo}`);
const blockers = await this.legBlockers(booking, leg);
const target = await this.prisma.upgradePolicy.findUnique({
where: { coachTypeId: dto.newCoachTypeId },
include: { coachType: { select: { id: true, code: true, name: true, type: true, seatClasses: { where: { isActive: true } } } } },
});
if (!target) throw new NotFoundException('That fare class has no upgrade policy');
if (!target.isActive || !target.isTargetable) blockers.push(`${target.coachType.code} cannot be upgraded to.`);
const onSchedule = await this.prisma.coachAssignment.count({
where: { scheduleId: leg.scheduleId, isOperational: true, coach: { coachTypeId: dto.newCoachTypeId } },
});
if (!onSchedule) blockers.push(`${target.coachType.code} is not available on this train.`);
const seatRows = await this.prisma.seat.findMany({
where: { id: { in: dto.items.map((i) => i.newSeatId) } },
include: { coach: { select: { id: true, coachTypeId: true } } },
});
const seatById = new Map(seatRows.map((s) => [s.id, s]));
if (seatRows.length !== dto.items.length) blockers.push('One or more selected seats do not exist.');
if (new Set(dto.items.map((i) => i.newSeatId)).size !== dto.items.length) blockers.push('Duplicate seats selected.');
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId: leg.scheduleId },
include: { station: { select: { code: true } } },
orderBy: { sequence: 'asc' },
});
const originStop = stopTimes.find((s) => s.stationId === leg.originStationId);
const destStop = stopTimes.find((s) => s.stationId === leg.destinationStationId);
if (!originStop || !destStop) blockers.push('This leg\'s route could not be resolved.');
const { nationalityType, nationality } = resolveNationalityProxy(booking.displayCurrency);
const segmentRoute = originStop && destStop ? `${originStop.station.code}-${destStop.station.code}` : undefined;
const bookingSeats = new Map(leg.seats.map((s: any) => [s.id, s]));
const items: UpgradeItem[] = [];
let oldFareMinor = 0;
let newFareMinor = 0;
let feeMinor = 0;
for (const req of dto.items) {
const current: any = bookingSeats.get(req.bookingSeatId);
if (!current) { blockers.push('A selected passenger is not on this leg.'); break; }
const source = await this.prisma.upgradePolicy.findUnique({ where: { coachTypeId: current.coachTypeId } });
if (!source || !source.isActive || !source.isUpgradable) {
blockers.push(`${current.passengerName} is in a class that cannot be upgraded.`);
break;
}
if (target.rank <= source.rank) {
blockers.push(`${target.coachType.code} is not an upgrade from ${current.passengerName}'s current class.`);
break;
}
const seat = seatById.get(req.newSeatId);
if (!seat) break; // already reported above
if (seat.coach.coachTypeId !== dto.newCoachTypeId) {
blockers.push('Every selected seat must be in the fare class being upgraded to.');
break;
}
const seatClass = originStop && destStop
? pickSeatClass(target.coachType.seatClasses, seat.bedPosition, nationalityType)
: null;
if (!seatClass) { blockers.push('No fare is configured for the selected seat.'); break; }
const seatFare = await this.bookingsService.getBaseFare(
leg.scheduleId, seatClass.id, segmentRoute, undefined, nationality,
originStop!.sequence, destStop!.sequence, originStop!.stationId, destStop!.stationId,
);
const currentFare = current.fareMinor ?? 0;
const amounts = computeUpgradeAmounts(target, currentFare, seatFare);
// Refuse rather than credit. A "higher" class pricing below the current seat means the fare
// configuration disagrees with the ladder; handing out a free upgrade would hide that.
if (amounts.fareDifferenceMinor <= 0) {
blockers.push(`${target.coachType.code} is not priced above ${current.passengerName}'s current seat on this route.`);
break;
}
oldFareMinor += currentFare;
newFareMinor += seatFare;
feeMinor += amounts.feeMinor;
items.push({
bookingSeatId: current.id,
passengerName: current.passengerName,
passengerCategory: current.passengerCategory,
oldSeatId: current.seatId,
oldSeatLabel: current.seatLabel,
oldCoachTypeId: current.coachTypeId,
oldSeatClassId: null,
oldFareMinor: currentFare,
newSeatId: seat.id,
newSeatLabel: seat.seatNumber,
newCoachTypeId: seat.coach.coachTypeId,
newSeatClassId: seatClass.id,
newFareMinor: seatFare,
feeMinor: amounts.feeMinor,
fareDifferenceMinor: amounts.fareDifferenceMinor,
});
}
// Availability last, so a bad selection reports the clearer error first.
//
// Skipped when re-quoting inside create(): by then the caller is holding these very seats,
// so this check would see their own hold and refuse the upgrade they just paid to make. The
// hold itself is the stronger guarantee — holdSeats ran assertNoRouteSeatConflict plus the
// hold and journey-segment collision checks, and create() verifies the hold is unexpired,
// for this schedule, and covers exactly these seats.
if (!opts.skipAvailability && !blockers.length && originStop && destStop) {
const free = await this.segmentsService.getFreeSeatIds(
leg.scheduleId,
items.map((i) => i.newSeatId),
stopTimes as any,
originStop.sequence,
destStop.sequence,
legNo === 2 ? JourneyDirection.RETURN : JourneyDirection.ONE_WAY,
);
const taken = items.filter((i) => !free.has(i.newSeatId));
if (taken.length) blockers.push('One or more selected seats have just been taken.');
}
const fareDifferenceMinor = newFareMinor - oldFareMinor;
return {
allowed: blockers.length === 0 && items.length === dto.items.length,
blockers: Array.from(new Set(blockers)),
leg: legNo,
scheduleId: leg.scheduleId,
newCoachTypeId: dto.newCoachTypeId,
newCoachTypeCode: target.coachType.code,
newCoachTypeName: target.coachType.name,
checkin: leg.checkin,
items,
oldFareMinor,
newFareMinor,
fareDifferenceMinor,
feeMinor,
amountDueMinor: feeMinor + Math.max(0, fareDifferenceMinor),
currency: 'ETB',
policy: { feePercent: target.feePercent, feeMinMinor: target.feeMinMinor, feeWaived: target.feeWaived },
};
}
}

View File

@@ -9,6 +9,9 @@ type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>('general');
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
const [bookingPayWindow, setBookingPayWindow] = useState('120');
const [reschedulePayWindow, setReschedulePayWindow] = useState('120');
const [upgradePayWindow, setUpgradePayWindow] = useState('120');
const [holdCutoffHours, setHoldCutoffHours] = useState('2');
const [boardingWindowHours, setBoardingWindowHours] = useState('4');
const [throttleAuthLimit, setThrottleAuthLimit] = useState('5');
@@ -24,6 +27,9 @@ export default function SettingsPage() {
systemConfigApi.getAll()
.then((data) => {
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes);
if (data?.booking_payment_window_minutes) setBookingPayWindow(data.booking_payment_window_minutes);
if (data?.reschedule_payment_window_minutes) setReschedulePayWindow(data.reschedule_payment_window_minutes);
if (data?.upgrade_payment_window_minutes) setUpgradePayWindow(data.upgrade_payment_window_minutes);
if (data?.hold_cutoff_hours_before_departure) setHoldCutoffHours(data.hold_cutoff_hours_before_departure);
if (data?.boarding_window_hours_before_departure) setBoardingWindowHours(data.boarding_window_hours_before_departure);
if (data?.throttle_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit);
@@ -40,6 +46,9 @@ export default function SettingsPage() {
try {
await systemConfigApi.update({
seat_hold_duration_minutes: seatHoldMinutes,
booking_payment_window_minutes: bookingPayWindow,
reschedule_payment_window_minutes: reschedulePayWindow,
upgrade_payment_window_minutes: upgradePayWindow,
hold_cutoff_hours_before_departure: holdCutoffHours,
boarding_window_hours_before_departure: boardingWindowHours,
throttle_auth_limit: throttleAuthLimit,
@@ -149,6 +158,44 @@ export default function SettingsPage() {
</div>
</div>
)}
<h3 className="text-lg font-semibold text-foreground">Payment Windows (minutes)</h3>
<p className="text-xs text-muted-foreground -mt-4">
How long a payer has before the request expires and the held seat is released. The
check-in cutoff is still the hard limit a longer window can never allow payment after
boarding closes.
</p>
<div className="max-w-sm space-y-4">
<div className="space-y-2">
<label className="label" htmlFor="booking-pay-window">New booking</label>
<input
id="booking-pay-window"
type="number" min="1" className="input"
value={bookingPayWindow}
onChange={(e) => setBookingPayWindow(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Time to pay for a new booking before it is auto-cancelled. Default: 120.</p>
</div>
<div className="space-y-2">
<label className="label" htmlFor="reschedule-pay-window">Reschedule</label>
<input
id="reschedule-pay-window"
type="number" min="1" className="input"
value={reschedulePayWindow}
onChange={(e) => setReschedulePayWindow(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Time to pay a reschedule charge. Default: 120.</p>
</div>
<div className="space-y-2">
<label className="label" htmlFor="upgrade-pay-window">Fare upgrade</label>
<input
id="upgrade-pay-window"
type="number" min="1" className="input"
value={upgradePayWindow}
onChange={(e) => setUpgradePayWindow(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Time to pay a fare-class upgrade. Default: 120.</p>
</div>
</div>
<h3 className="text-lg font-semibold text-foreground">Seat Booking</h3>
{configLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function UpgradePoliciesLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,26 @@
'use client';
import UpgradePolicyManager from '@/components/upgrade/UpgradePolicyManager';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
/**
* Master Data → Upgrade Policies. One policy per fare class (coach type); a class with no policy
* can be neither upgraded from nor to. Gated on bookings:view because that is what
* `GET /upgrade/policies` requires; creating, editing and deleting are admin-only server-side.
*/
export default function UpgradePoliciesPage() {
return (
<PermissionGuard permission={PERMS.bookings.view}>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Upgrade Policies</h1>
<p className="text-muted-foreground mt-1">
Which fare classes a passenger may move up to before check-in, and what the change costs
</p>
</div>
<UpgradePolicyManager />
</div>
</PermissionGuard>
);
}

View File

@@ -29,6 +29,7 @@ import {
Briefcase,
Calendar,
CalendarClock,
ArrowUpNarrowWide,
Utensils,
Package,
Moon,
@@ -92,6 +93,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
{ name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view },
{ name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: PERMS.bookings.view },
]
},
{

View File

@@ -0,0 +1,363 @@
'use client';
import { useEffect, useState } from 'react';
import { Edit, Plus, Save, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import {
upgradePolicyApi,
type UpgradePolicyCoachType,
type UpgradePolicyRow,
type UpgradePolicyValues,
} from '@/lib/api';
const EMPTY_POLICY: UpgradePolicyValues = {
rank: 0,
feePercent: 0,
feeMinMinor: 0,
feeWaived: false,
isUpgradable: true,
isTargetable: true,
isActive: true,
};
// Money is entered in ETB and stored in minor units.
const etb = (minor: number) => String(minor / 100);
const toMinor = (value: string) => Math.round(Number(value || 0) * 100);
const feeLabel = (p: UpgradePolicyRow) =>
p.feeWaived
? 'Waived'
: p.feePercent > 0 || p.feeMinMinor > 0
? `${p.feePercent}% · min ETB ${etb(p.feeMinMinor)}`
: 'Free';
/**
* Policy US-17 — one upgrade policy per fare class (coach type), listed as a table and edited in
* a dialog, the same shape as Reschedule Policies and Coach Management.
*/
export default function UpgradePolicyManager() {
const [rows, setRows] = useState<UpgradePolicyRow[]>([]);
const [available, setAvailable] = useState<UpgradePolicyCoachType[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<UpgradePolicyRow | null>(null);
const [coachTypeId, setCoachTypeId] = useState('');
const [form, setForm] = useState<UpgradePolicyValues>(EMPTY_POLICY);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState('');
const [deleting, setDeleting] = useState<UpgradePolicyRow | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const load = async () => {
setLoading(true);
try {
const [policies, coachTypes] = await Promise.all([
upgradePolicyApi.list(),
upgradePolicyApi.availableCoachTypes(),
]);
setRows(Array.isArray(policies) ? policies : []);
setAvailable(Array.isArray(coachTypes) ? coachTypes : []);
} catch {
setMessage('Failed to load upgrade policies.');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const openCreate = () => {
setEditing(null);
setCoachTypeId('');
// Suggest the next free rung rather than 0, which would clash with an existing policy.
setForm({ ...EMPTY_POLICY, rank: Math.max(0, ...rows.map((r) => r.rank)) + 1 });
setFormError('');
setShowModal(true);
};
const openEdit = (row: UpgradePolicyRow) => {
setEditing(row);
setCoachTypeId(row.coachTypeId);
setForm({
rank: row.rank,
feePercent: row.feePercent,
feeMinMinor: row.feeMinMinor,
feeWaived: row.feeWaived,
isUpgradable: row.isUpgradable,
isTargetable: row.isTargetable,
isActive: row.isActive,
});
setFormError('');
setShowModal(true);
};
const setField = (patch: Partial<UpgradePolicyValues>) => setForm((f) => ({ ...f, ...patch }));
const submit = async () => {
if (!editing && !coachTypeId) {
setFormError('Pick a fare class.');
return;
}
setSaving(true);
setFormError('');
try {
if (editing) await upgradePolicyApi.update(editing.coachTypeId, form);
else await upgradePolicyApi.create({ coachTypeId, ...form });
setShowModal(false);
setMessage(editing ? 'Policy updated.' : 'Policy created.');
await load();
} catch (err: any) {
setFormError(err?.response?.data?.message || err?.message || 'Failed to save the policy.');
} finally {
setSaving(false);
}
};
const confirmDelete = async () => {
if (!deleting) return;
setDeleteBusy(true);
try {
await upgradePolicyApi.remove(deleting.coachTypeId);
setDeleting(null);
setMessage('Policy deleted.');
await load();
} catch {
setMessage('Failed to delete the policy.');
} finally {
setDeleteBusy(false);
}
};
const columns = [
{
key: 'coachType',
label: 'Fare class',
render: (row: UpgradePolicyRow) => (
<div>
<span className="font-semibold text-foreground">{row.coachType?.code}</span>
<span className="text-muted-foreground"> {row.coachType?.name}</span>
</div>
),
},
{
key: 'rank',
label: 'Rank',
render: (row: UpgradePolicyRow) => <span className="font-mono text-sm">{row.rank}</span>,
},
{
key: 'fee',
label: 'Change fee',
render: (row: UpgradePolicyRow) => <span className="text-sm">{feeLabel(row)}</span>,
},
{
key: 'isUpgradable',
label: 'Upgrade from',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isUpgradable ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isUpgradable ? 'Allowed' : 'No'}
</span>
),
},
{
key: 'isTargetable',
label: 'Upgrade to',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isTargetable ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isTargetable ? 'Allowed' : 'No'}
</span>
),
},
{
key: 'isActive',
label: 'Status',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isActive ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isActive ? 'Active' : 'Disabled'}
</span>
),
},
];
const actions = [
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
{
label: 'Delete',
onClick: (row: UpgradePolicyRow) => setDeleting(row),
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<p className="text-xs text-muted-foreground max-w-3xl">
Rank orders the ladder a passenger may only move to a class with a higher rank, on the same train.
Fee = max(fee % × the passenger&apos;s original fare, minimum), read from the class being upgraded
<em> to</em> and charged per upgraded passenger. A fare class with no policy here can be neither
upgraded from nor to.
</p>
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}>
Add Upgrade Policy
</ActionButton>
</div>
{!loading && available.length === 0 && (
<p className="text-xs text-muted-foreground">Every fare class already has a policy.</p>
)}
{message && <p className="text-sm text-muted-foreground">{message}</p>}
<DataTable
data={rows}
columns={columns}
actions={actions}
loading={loading}
emptyMessage="No upgrade policies yet — add one to allow fare-class upgrades."
/>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title={editing ? `Edit Upgrade Policy — ${editing.coachType?.code}` : 'Add Upgrade Policy'}
size="lg"
>
<div className="space-y-4">
<div className="space-y-1">
<label className="label">Fare class</label>
{editing ? (
<>
<input
className="input"
value={`${editing.coachType?.code}${editing.coachType?.name}`}
disabled
/>
<p className="text-xs text-muted-foreground">A policy stays attached to its fare class.</p>
</>
) : (
<select className="input" value={coachTypeId} onChange={(e) => setCoachTypeId(e.target.value)}>
<option value="">Select a fare class...</option>
{available.map((ct) => (
<option key={ct.id} value={ct.id}>
{ct.code} {ct.name}
</option>
))}
</select>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="label">Rank</label>
<input
type="number"
min="0"
className="input"
value={form.rank}
onChange={(e) => setField({ rank: Number(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">Higher beats lower. Must be unique among active policies.</p>
</div>
<div className="space-y-1">
<label className="label">Change fee</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.feeWaived}
onChange={(e) => setField({ feeWaived: e.target.checked })}
/>
Waived charge only the fare difference
</label>
</div>
<div className="space-y-1">
<label className="label">Fee (% of original fare)</label>
<input
type="number"
min="0"
max="100"
className="input"
disabled={form.feeWaived}
value={form.feePercent}
onChange={(e) => setField({ feePercent: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Minimum fee (ETB)</label>
<input
type="number"
min="0"
step="0.01"
className="input"
disabled={form.feeWaived}
value={etb(form.feeMinMinor)}
onChange={(e) => setField({ feeMinMinor: toMinor(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Upgrade from this class</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isUpgradable}
onChange={(e) => setField({ isUpgradable: e.target.checked })}
/>
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Upgrade to this class</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isTargetable}
onChange={(e) => setField({ isTargetable: e.target.checked })}
/>
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Status</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isActive}
onChange={(e) => setField({ isActive: e.target.checked })}
/>
Active
</label>
</div>
</div>
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
Cancel
</ActionButton>
<ActionButton icon={Save} onClick={submit} loading={saving}>
{editing ? 'Update Policy' : 'Create Policy'}
</ActionButton>
</div>
</div>
</Modal>
<ConfirmDialog
isOpen={!!deleting}
onClose={() => setDeleting(null)}
onConfirm={confirmDelete}
title="Delete upgrade policy"
message={`Delete the upgrade policy for ${deleting?.coachType?.code ?? ''}?`}
warning="Passengers will no longer be able to upgrade out of or into this fare class. Upgrades already applied are unaffected."
confirmText="Delete"
isDanger
isLoading={deleteBusy}
/>
</div>
);
}

View File

@@ -565,6 +565,40 @@ export interface ReschedulePolicyRow extends ReschedulePolicyValues {
coachTypeId: string;
coachType: ReschedulePolicyCoachType;
}
// Fare-class upgrade policy API — one policy per coach type. `rank` orders the ladder; an
// upgrade requires a strictly higher rank. A coach type with no policy can be neither left nor
// entered.
export interface UpgradePolicyValues {
rank: number;
feePercent: number;
feeMinMinor: number;
feeWaived: boolean;
isUpgradable: boolean;
isTargetable: boolean;
isActive: boolean;
}
export interface UpgradePolicyCoachType {
id: string;
code: string;
name: string;
type: string;
}
export interface UpgradePolicyRow extends UpgradePolicyValues {
id: string;
coachTypeId: string;
coachType: UpgradePolicyCoachType;
}
export const upgradePolicyApi = {
list: () => apiClient.get<UpgradePolicyRow[]>('/upgrade/policies'),
availableCoachTypes: () =>
apiClient.get<UpgradePolicyCoachType[]>('/upgrade/policies/available-coach-types'),
create: (data: UpgradePolicyValues & { coachTypeId: string }) =>
apiClient.post<UpgradePolicyRow>('/upgrade/policies', data),
update: (coachTypeId: string, data: Partial<UpgradePolicyValues>) =>
apiClient.patch<UpgradePolicyRow>(`/upgrade/policies/${coachTypeId}`, data),
remove: (coachTypeId: string) => apiClient.delete<any>(`/upgrade/policies/${coachTypeId}`),
};
export const reschedulePolicyApi = {
list: () => apiClient.get<ReschedulePolicyRow[]>('/reschedule/policies'),
availableCoachTypes: () =>

View File

@@ -10,6 +10,7 @@ import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
import { useEffect, useState } from "react";
import {
Clock,
ArrowUpCircle,
Users,
CheckCircle2,
AlertCircle,
@@ -351,6 +352,7 @@ function BookingDetailContent() {
const canReschedule = isAuthenticated && (isBooker || !booking.contactPhone);
const reschedulePath = `/booking/reschedule?ref=${booking.bookingRef}`;
const upgradePath = `/booking/upgrade?ref=${booking.bookingRef}`;
const StatusBadge = () => {
const statusConfig = {
@@ -1070,6 +1072,31 @@ function BookingDetailContent() {
{isAuthInitialized && !isAuthenticated ? "Sign in to reschedule" : "Reschedule"}
</button>
)}
{/* Same gating as Reschedule: hidden from a signed-in viewer who did not book
the trip, because the API refuses them; a guest still gets the sign-in
prompt, since signing in as the booker is what unblocks them. Whether any
higher class actually exists on this train is the upgrade page's call. */}
{bookingSupportsReschedule && (!isAuthInitialized || !isAuthenticated || canReschedule) && (
<button
disabled={!isAuthInitialized}
onClick={() =>
router.push(
isAuthenticated
? upgradePath
: `/login?redirect=${encodeURIComponent(upgradePath)}`,
)
}
title={
isAuthenticated
? "Move to a higher fare class on the same train"
: "Upgrading needs an account — sign in to continue"
}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<ArrowUpCircle className="w-4 h-4" />
{isAuthInitialized && !isAuthenticated ? "Sign in to upgrade" : "Upgrade class"}
</button>
)}
</>
)}
</div>

View File

@@ -0,0 +1,571 @@
"use client";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
import { AlertCircle, ArrowUpCircle, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api-client";
import { useAuthStore } from "@/lib/auth-store";
import SeatMap, { buildSeatLabel, getValidSeatsForCoach } from "@/components/SeatMap";
type Target = {
coachTypeId: string;
code: string;
name: string;
rank: number;
feePercent: number;
feeMinMinor: number;
feeWaived: boolean;
};
type PassengerOption = {
bookingSeatId: string;
passengerName: string;
passengerCategory: string;
seatId: string;
seatLabel: string | null;
coachTypeId: string;
currentFareMinor: number;
targets: Target[];
};
type LegOption = {
leg: number;
scheduleId: string;
originStationId: string | null;
destinationStationId: string | null;
departureAt: string;
checkinCutoffAt: string | null;
checkinMinutes: number | null;
canUpgrade: boolean;
blockers: string[];
passengers: PassengerOption[];
};
type Options = {
bookingRef: string;
bookingType: string;
legs: LegOption[];
pending: { id: string; amountDueMinor: number; paymentToken: string | null; expiresAt: string | null } | null;
};
type QuoteItem = {
bookingSeatId: string;
passengerName: string;
oldSeatLabel: string | null;
newSeatLabel: string | null;
oldFareMinor: number;
newFareMinor: number;
feeMinor: number;
fareDifferenceMinor: number;
};
type Quote = {
allowed: boolean;
blockers: string[];
newCoachTypeCode: string;
items: QuoteItem[];
oldFareMinor: number;
newFareMinor: number;
fareDifferenceMinor: number;
feeMinor: number;
amountDueMinor: number;
};
const etb = (minor: number) => `ETB ${(minor / 100).toFixed(2)}`;
function UpgradePageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const ref = searchParams.get("ref") || "";
// Every endpoint here is behind JwtGuard, so a guest deep-linking would otherwise watch the
// options request 401 and land on a message blaming the booking. Send them to sign in and
// bring them back. Waits for isInitialized: the store starts logged-out.
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isAuthInitialized = useAuthStore((s) => s.isInitialized);
const needsLogin = isAuthInitialized && !isAuthenticated;
useEffect(() => {
if (!needsLogin) return;
const back = ref ? `/booking/upgrade?ref=${ref}` : "/booking/lookup";
router.replace(`/login?redirect=${encodeURIComponent(back)}`);
}, [needsLogin, ref, router]);
const [legNo, setLegNo] = useState(1);
const [targetCoachTypeId, setTargetCoachTypeId] = useState("");
/** bookingSeatId → chosen seat. Only the passengers in here are upgrading. */
const [picks, setPicks] = useState<Record<string, string>>({});
const [activeBookingSeatId, setActiveBookingSeatId] = useState<string | null>(null);
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
const [done, setDone] = useState<{ status: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({
queryKey: ["upgrade-options", ref],
queryFn: () => apiClient.get<Options>(`/bookings/${ref}/upgrade`),
enabled: !!ref && isAuthInitialized && isAuthenticated,
retry: false,
});
const leg = useMemo(
() => options?.legs.find((l) => l.leg === legNo) ?? options?.legs[0],
[options, legNo],
);
// Every class anyone on this leg could move up to, de-duplicated for the chooser.
const targets = useMemo(() => {
const byId = new Map<string, Target>();
for (const p of leg?.passengers ?? []) for (const t of p.targets) byId.set(t.coachTypeId, t);
return [...byId.values()].sort((a, b) => a.rank - b.rank);
}, [leg]);
const target = targets.find((t) => t.coachTypeId === targetCoachTypeId) ?? null;
const resetSelection = () => {
setPicks({});
setActiveBookingSeatId(null);
setSelectedCoach(null);
setError(null);
};
// Switching leg or target invalidates every seat already picked — they belong to a coach that
// is no longer being shown.
useEffect(() => {
resetSelection();
}, [legNo, targetCoachTypeId]);
const { data: seatMap, isLoading: loadingSeats } = useQuery<any>({
queryKey: ["upgrade-seatmap", leg?.scheduleId, targetCoachTypeId, leg?.originStationId, leg?.destinationStationId],
queryFn: async () => {
const res: any = await apiClient.get(
`/seats/seatmap/${leg!.scheduleId}?coachTypeId=${targetCoachTypeId}` +
`&journeyDirection=${legNo === 2 ? "RETURN" : "ONE_WAY"}` +
`&originStationId=${leg!.originStationId}&destinationStationId=${leg!.destinationStationId}`,
);
return res?.data || res;
},
enabled: !!leg?.scheduleId && !!targetCoachTypeId,
});
const coaches: any[] = useMemo(() => seatMap?.coaches ?? [], [seatMap]);
const autoExpandedFor = useRef<string | null>(null);
useEffect(() => {
if (!targetCoachTypeId || coaches.length === 0) return;
if (autoExpandedFor.current === targetCoachTypeId) return;
autoExpandedFor.current = targetCoachTypeId;
setSelectedCoach(coaches[0].id);
}, [targetCoachTypeId, coaches]);
/** Passengers eligible for the chosen target, in the API's own order. */
const eligible = useMemo(
() => (leg?.passengers ?? []).filter((p) => p.targets.some((t) => t.coachTypeId === targetCoachTypeId)),
[leg, targetCoachTypeId],
);
// Someone must be "active" for a seat click to mean anything. Without this the seat map looks
// fully interactive but every click is a silent no-op until a passenger row is clicked first —
// and on a single-passenger booking there is nothing obvious to click.
useEffect(() => {
if (!targetCoachTypeId || eligible.length === 0) return;
setActiveBookingSeatId((current) => {
if (current && eligible.some((p) => p.bookingSeatId === current)) return current;
return eligible[0].bookingSeatId;
});
}, [targetCoachTypeId, eligible]);
const items = useMemo(
() =>
eligible
.filter((p) => picks[p.bookingSeatId])
.map((p) => ({ bookingSeatId: p.bookingSeatId, newSeatId: picks[p.bookingSeatId] })),
[eligible, picks],
);
const quoteBody = leg && targetCoachTypeId && items.length > 0
? { leg: leg.leg, newCoachTypeId: targetCoachTypeId, items }
: null;
const { data: quote, isFetching: quoting } = useQuery<Quote>({
queryKey: ["upgrade-quote", ref, quoteBody],
queryFn: () => apiClient.post<Quote>(`/bookings/${ref}/upgrade/quote`, quoteBody),
enabled: !!quoteBody,
});
const confirm = useMutation({
mutationFn: async () => {
// Goes through the upgrade module rather than /seats/hold directly: an upgrade holds on
// the SAME schedule the booking already occupies, so a retry collides with the caller's
// own abandoned attempt. The server clears those first, and derives the schedule and
// stations from the booking instead of trusting us.
const hold: any = await apiClient.post(`/bookings/${ref}/upgrade/hold`, {
leg: leg!.leg,
seatIds: items.map((it) => it.newSeatId),
});
return apiClient.post<any>(`/bookings/${ref}/upgrade`, { ...quoteBody, holdId: hold.holdId || hold.id });
},
onSuccess: (res) => {
if (res.paymentToken) router.push(`/pay-balance/${res.paymentToken}`);
else setDone({ status: res.status });
},
onError: (e: any) => setError(e?.response?.data?.message || e?.message || "Could not upgrade"),
});
const seatOwner = (seatId: string) =>
Object.entries(picks).find(([, sid]) => sid === seatId)?.[0] ?? null;
const handleSeatToggle = (seatId: string) => {
const owner = seatOwner(seatId);
if (owner && owner !== activeBookingSeatId) return; // already another passenger's pick
// Fall back to the first passenger still without a seat, so a click is never swallowed.
const forPassenger =
activeBookingSeatId ?? eligible.find((p) => !picks[p.bookingSeatId])?.bookingSeatId;
if (!forPassenger) return;
setPicks((prev) => {
const next = { ...prev };
if (next[forPassenger] === seatId) {
delete next[forPassenger];
return next;
}
next[forPassenger] = seatId;
// Move to the next passenger still without a seat, so a multi-passenger upgrade can be
// filled by clicking straight down the coach — same behaviour as /booking/seats.
const nextUnassigned = eligible.find((p) => p.bookingSeatId !== forPassenger && !next[p.bookingSeatId]);
if (nextUnassigned) setActiveBookingSeatId(nextUnassigned.bookingSeatId);
return next;
});
};
const labelForSeat = (seatId: string) => {
const seat = coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === seatId);
return seat ? buildSeatLabel(seat) : "";
};
if (!ref) return <Shell><p className="text-gray-600">Missing booking reference.</p></Shell>;
if (!isAuthInitialized || needsLogin) {
return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
}
if (loadingOptions) return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
if (optionsError || !options || !leg) {
return (
<Shell>
<p className="text-red-600">
{(optionsError as any)?.response?.data?.message || "This booking cannot be upgraded."}
</p>
</Shell>
);
}
if (done) {
return (
<Shell>
<div className="text-center space-y-4">
<CheckCircle2 className="w-14 h-14 text-green-600 mx-auto" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Upgrade confirmed</h1>
<p className="text-gray-600 dark:text-gray-400">New tickets have been issued for booking {ref}.</p>
<button className="btn-primary" onClick={() => router.push(`/booking/detail?ref=${ref}`)}>
View booking
</button>
</div>
</Shell>
);
}
if (options.pending) {
return (
<Shell>
<div className="space-y-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Upgrade awaiting payment</h1>
<p className="text-gray-600 dark:text-gray-400">
An upgrade of {etb(options.pending.amountDueMinor)} is waiting to be paid
{options.pending.expiresAt ? ` before ${format(new Date(options.pending.expiresAt), "dd MMM HH:mm")}` : ""}.
Your new seats are held until then.
</p>
{options.pending.paymentToken && (
<button className="btn-primary" onClick={() => router.push(`/pay-balance/${options.pending!.paymentToken}`)}>
Pay now
</button>
)}
</div>
</Shell>
);
}
const Summary = () => (
<div className="card space-y-3">
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
Upgrade summary
</h2>
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 mb-1">Journey</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{format(new Date(leg.departureAt), "EEE dd MMM, HH:mm")}
</div>
{leg.checkinCutoffAt && (
<div className="text-xs text-gray-500 dark:text-gray-400">
Upgrades close {format(new Date(leg.checkinCutoffAt), "dd MMM HH:mm")}
</div>
)}
</div>
{target && (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800">
<div className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 mb-1">Upgrading to</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{target.code} {target.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Change fee: {target.feeWaived || (target.feePercent === 0 && target.feeMinMinor === 0)
? "none"
: `${target.feePercent}% (min ${etb(target.feeMinMinor)})`}
</div>
</div>
)}
<div className="pt-3 border-t border-gray-100 dark:border-gray-800 space-y-1">
{eligible.map((p) => (
<div key={p.bookingSeatId} className="flex justify-between text-sm">
<span className="text-gray-700 dark:text-gray-300 truncate max-w-[55%]">{p.passengerName}</span>
<span className={picks[p.bookingSeatId] ? "font-semibold text-gray-900 dark:text-gray-100" : "text-gray-400"}>
{picks[p.bookingSeatId]
? `${p.seatLabel ?? "seat"}${labelForSeat(picks[p.bookingSeatId])}`
: "Not upgrading"}
</span>
</div>
))}
</div>
{!quoteBody ? (
<p className="text-xs text-gray-500 dark:text-gray-400 pt-3 border-t border-gray-100 dark:border-gray-800">
Choose a class and a seat for each passenger you want to upgrade.
</p>
) : quoting ? (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
</div>
) : quote ? (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800 space-y-2 text-sm">
<Row label="Current fare" value={etb(quote.oldFareMinor)} />
<Row label="New fare" value={etb(quote.newFareMinor)} />
<Row label="Fare difference" value={etb(Math.max(0, quote.fareDifferenceMinor))} />
<Row label="Change fee" value={etb(quote.feeMinor)} />
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-gray-700">
<span className="font-bold text-gray-900 dark:text-gray-100">Total due now</span>
<span className="text-xl font-bold text-primary">{etb(quote.amountDueMinor)}</span>
</div>
{quote.blockers.length > 0 && (
<div className="text-red-600 flex gap-2 text-xs">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{quote.blockers.map((b) => <div key={b}>{b}</div>)}</div>
</div>
)}
{error && <div className="text-red-600 text-xs">{error}</div>}
<button
className="btn-primary w-full mt-1"
disabled={!quote.allowed || confirm.isPending}
onClick={() => { setError(null); confirm.mutate(); }}
>
{confirm.isPending ? "Processing..." : `Continue to payment · ${etb(quote.amountDueMinor)}`}
</button>
</div>
) : null}
</div>
);
return (
<Shell wide>
<button
onClick={() => router.push(`/booking/detail?ref=${ref}`)}
className="flex items-center gap-1 text-sm text-gray-500 mb-4"
>
<ChevronLeft className="w-4 h-4" /> Back to booking
</button>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-1">Upgrade {ref}</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
Move to a higher fare class on the same train. Each passenger can be upgraded on their own.
</p>
{options.legs.length > 1 && (
<div className="flex gap-2 mb-6">
{options.legs.map((l) => (
<button
key={l.leg}
onClick={() => setLegNo(l.leg)}
className={`px-4 py-2 rounded-lg border text-sm ${
legNo === l.leg ? "border-primary text-primary" : "border-gray-200 text-gray-600"
}`}
>
{l.leg === 1 ? "Outbound" : "Return"} · {format(new Date(l.departureAt), "dd MMM")}
</button>
))}
</div>
)}
{!leg.canUpgrade && (
<div className="rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 p-4 text-sm text-red-700 mb-6 flex gap-2">
<AlertCircle className="w-5 h-5 shrink-0" />
<div>
{leg.blockers.length
? leg.blockers.map((b) => <div key={b}>{b}</div>)
: <div>No higher fare class is available on this train.</div>}
</div>
</div>
)}
{leg.canUpgrade && (
<div className="lg:grid lg:grid-cols-3 lg:gap-6">
<div className="lg:col-span-2 space-y-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-100 dark:border-gray-700 shadow-sm">
{/* Step 1 — class */}
<h2 className="font-semibold text-gray-900 dark:text-white mb-3">Choose a class</h2>
<div className="grid sm:grid-cols-2 gap-3 mb-6">
{targets.map((t) => (
<button
key={t.coachTypeId}
onClick={() => setTargetCoachTypeId(t.coachTypeId)}
className={`rounded-xl border p-4 text-left ${
targetCoachTypeId === t.coachTypeId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700 hover:border-gray-300"
}`}
>
<div className="font-semibold text-gray-900 dark:text-white">{t.name}</div>
<div className="text-xs text-gray-500">
{t.code} ·{" "}
{t.feeWaived || (t.feePercent === 0 && t.feeMinMinor === 0)
? "no change fee"
: `${t.feePercent}% change fee`}
</div>
</button>
))}
</div>
{/* Step 2 — who, and which seat */}
{targetCoachTypeId && (
<>
<h2 className="font-semibold text-gray-900 dark:text-white mb-1">
Who is upgrading? ({items.length}/{eligible.length})
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Pick a passenger, then choose their new seat below. Leave a passenger unselected to keep
their current seat.
</p>
<div className="mb-4 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800">
{eligible.map((p) => {
const isActive = activeBookingSeatId === p.bookingSeatId;
const picked = picks[p.bookingSeatId];
return (
<button
key={p.bookingSeatId}
type="button"
// Always selects, never clears: with nobody active every seat click is
// silently ignored, which reads as a broken seat map.
onClick={() => setActiveBookingSeatId(p.bookingSeatId)}
className={`w-full flex items-center justify-between py-2 px-3 text-left transition-all ${
isActive ? "bg-[rgb(20,113,76)]/5 dark:bg-[rgb(20,113,76)]/10" : ""
} hover:bg-gray-50 dark:hover:bg-gray-800/60`}
>
<div className="flex items-center gap-2 min-w-0">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
picked
? "bg-[rgb(20,113,76)] text-white"
: isActive
? "bg-[rgb(20,113,76)]/20 text-[rgb(20,113,76)] ring-2 ring-[rgb(20,113,76)]"
: "bg-gray-200 dark:bg-gray-700 text-gray-500"
}`}
>
<ArrowUpCircle className="w-3.5 h-3.5" />
</div>
<div className="min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 truncate block max-w-[160px]">
{p.passengerName}
</span>
<span className="text-[10px] text-gray-400">
now in seat {p.seatLabel ?? "—"}
</span>
</div>
</div>
<span
className={`text-sm font-semibold flex-shrink-0 ${
picked ? "text-[rgb(20,113,76)]" : "text-gray-400"
}`}
>
{picked ? `Seat ${labelForSeat(picked)}` : isActive ? "Pick a seat" : "Not upgrading"}
</span>
</button>
);
})}
</div>
{loadingSeats ? (
<Loader2 className="w-5 h-5 animate-spin text-primary" />
) : (
<SeatMap
coaches={coaches}
selectedCoachId={selectedCoach}
onSelectCoach={setSelectedCoach}
isSeatSelected={(id) => !!activeBookingSeatId && picks[activeBookingSeatId] === id}
isSeatAssignedToOther={(id) => {
const owner = seatOwner(id);
return !!owner && owner !== activeBookingSeatId;
}}
onSeatToggle={handleSeatToggle}
emptyLabel="No seats of that class on this train."
/>
)}
</>
)}
</div>
<div className="lg:hidden">
<Summary />
</div>
</div>
<div className="hidden lg:block">
<div className="sticky top-6 max-h-[calc(100vh-3rem)] overflow-y-auto">
<Summary />
</div>
</div>
</div>
)}
</Shell>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between text-gray-700 dark:text-gray-300">
<span>{label}</span>
<span>{value}</span>
</div>
);
}
function Shell({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
<div className="container mx-auto px-4">
{wide ? (
<div className="max-w-6xl mx-auto">{children}</div>
) : (
<div className="max-w-3xl mx-auto bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
{children}
</div>
)}
</div>
</div>
);
}
export default function UpgradePage() {
return (
<Suspense fallback={<Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>}>
<UpgradePageContent />
</Suspense>
);
}

View File

@@ -277,7 +277,10 @@ function LoginContent() {
);
const heading = {
identifier: { title: 'Sign in', subtitle: 'Enter your phone number or email to continue' },
identifier: {
title: 'Sign in or create account',
subtitle: "Enter your phone number or email — we'll sign you in, or set up a new account",
},
password: { title: 'Welcome back', subtitle: 'Enter your password to sign in' },
setup: { title: 'Set your password', subtitle: 'Enter the code we sent, then choose a password' },
signup: { title: 'Create your account', subtitle: 'We just need a couple of details' },

View File

@@ -10,6 +10,7 @@ import {
ChevronLeft,
ChevronRight,
Clock,
ArrowUpCircle,
Eye,
CreditCard,
RefreshCw,
@@ -63,29 +64,52 @@ function describeSeats(seats: MyBookingItem['seats'], leg: number) {
interface RowActions {
canReschedule: boolean;
rescheduleBlocker: string | null;
canUpgrade: boolean;
upgradeBlocker: string | null;
isPendingPayment: boolean;
}
/**
* The coarse reschedule gate, mirroring booking/detail/page.tsx. The per-leg rules
* (fare-class policy, cutoff, seats still free) belong to the reschedule page, which
* names them as blockers — this only avoids sending the customer somewhere that is
* certain to reject them. The phone test matches the API's own ownership check
* (reschedule.service.ts loadOwnedBooking), which is phone-based, not account-based.
* The coarse gate for both change actions, mirroring booking/detail/page.tsx. Reschedule and
* upgrade share it because the booking-shape rules and the ownership check are identical — only
* the wording differs, hence the verb.
*
* The per-leg rules (fare-class policy, cutoffs, whether a higher class even runs on this train,
* seats still free) belong to the reschedule and upgrade pages, which name them as blockers. This
* only avoids sending the customer somewhere certain to reject them. The phone test matches the
* API's own ownership check (loadOwnedBooking), which is phone-based, not account-based.
*/
function resolveActions(b: MyBookingItem, userPhone?: string): RowActions {
const isPendingPayment = b.status === 'PENDING_PAYMENT' || b.status === 'DRAFT';
let rescheduleBlocker: string | null = null;
if (b.status !== 'CONFIRMED') rescheduleBlocker = 'Only a confirmed booking can be rescheduled';
else if (b.isPackageBooking) rescheduleBlocker = 'Package bookings cannot be rescheduled online';
else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType))
rescheduleBlocker = 'Transit bookings cannot be rescheduled online';
else if (b.outboundBoardedAt) rescheduleBlocker = 'This trip has already been boarded';
else if (b.contactPhone && !samePhone(userPhone, b.contactPhone))
rescheduleBlocker = 'Only the person who made this booking can reschedule it';
// Both forms are needed: "can be rescheduled" but "can reschedule it".
type Verbs = { past: string; base: string };
const RESCHEDULE: Verbs = { past: 'rescheduled', base: 'reschedule' };
const UPGRADE: Verbs = { past: 'upgraded', base: 'upgrade' };
return { canReschedule: rescheduleBlocker === null, rescheduleBlocker, isPendingPayment };
let reason: ((v: Verbs) => string) | null = null;
if (b.status !== 'CONFIRMED') reason = (v) => `Only a confirmed booking can be ${v.past}`;
else if (b.isPackageBooking) reason = (v) => `Package bookings cannot be ${v.past} online`;
else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType))
reason = (v) => `Transit bookings cannot be ${v.past} online`;
else if (b.outboundBoardedAt) reason = () => 'This trip has already been boarded';
// Both APIs apply a cutoff measured against departure, so a departed trip is always rejected.
// Say so here instead of sending them to a page that refuses.
else if (new Date(b.schedule.departureAt).getTime() <= Date.now())
reason = () => 'This trip has already departed';
else if (b.contactPhone && !samePhone(userPhone, b.contactPhone))
reason = (v) => `Only the person who made this booking can ${v.base} it`;
const rescheduleBlocker = reason ? reason(RESCHEDULE) : null;
const upgradeBlocker = reason ? reason(UPGRADE) : null;
return {
canReschedule: rescheduleBlocker === null,
rescheduleBlocker,
canUpgrade: upgradeBlocker === null,
upgradeBlocker,
isPendingPayment,
};
}
/**
@@ -118,6 +142,7 @@ export default function MyBookingsTable() {
const openDetail = (b: MyBookingItem) => router.push(`/booking/detail?ref=${b.bookingRef}`);
const openReschedule = (b: MyBookingItem) => router.push(`/booking/reschedule?ref=${b.bookingRef}`);
const openUpgrade = (b: MyBookingItem) => router.push(`/booking/upgrade?ref=${b.bookingRef}`);
const cardClass =
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700';
@@ -264,6 +289,20 @@ export default function MyBookingsTable() {
Reschedule
</button>
)}
{!actions.isPendingPayment && (
<button
onClick={() => openUpgrade(b)}
disabled={!actions.canUpgrade}
title={
actions.upgradeBlocker ??
'Move to a higher fare class on the same train'
}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium transition-colors whitespace-nowrap"
>
<ArrowUpCircle className="w-3.5 h-3.5" />
Upgrade
</button>
)}
</div>
</td>
</tr>
@@ -340,6 +379,20 @@ export default function MyBookingsTable() {
Reschedule
</button>
)}
{!actions.isPendingPayment && (
<button
onClick={() => openUpgrade(b)}
disabled={!actions.canUpgrade}
title={
actions.upgradeBlocker ??
'Move to a higher fare class on the same train'
}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium"
>
<ArrowUpCircle className="w-3.5 h-3.5" />
Upgrade
</button>
)}
</div>
</div>
);