diff --git a/apps/edr-passenger-api/prisma/migrations/20260821000001_add_reschedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260821000001_add_reschedule/migration.sql
new file mode 100644
index 000000000..0d98e6d0e
--- /dev/null
+++ b/apps/edr-passenger-api/prisma/migrations/20260821000001_add_reschedule/migration.sql
@@ -0,0 +1,82 @@
+-- Rescheduling (passenger policy §3). One policy row per fare class — fare classes map 1:1 onto
+-- coach types (HSC = Standard, HBC = Flex, SBC = Premium) — plus a per-leg request table.
+-- Additive and idempotent; the seed below only inserts for coach types that exist.
+
+-- CreateTable
+CREATE TABLE IF NOT EXISTS "passenger"."ReschedulePolicy" (
+ "id" TEXT NOT NULL,
+ "coachTypeId" TEXT NOT NULL,
+ "feePercent" INTEGER NOT NULL DEFAULT 0,
+ "feeMinMinor" INTEGER NOT NULL DEFAULT 0,
+ "routeChangeAllowed" BOOLEAN NOT NULL DEFAULT true,
+ "sameDayAllowed" BOOLEAN NOT NULL DEFAULT true,
+ "sameDayFeePercent" INTEGER NOT NULL DEFAULT 0,
+ "sameDayFeeMinMinor" INTEGER NOT NULL DEFAULT 0,
+ "cutoffMinutes" INTEGER NOT NULL DEFAULT 60,
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ReschedulePolicy_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS "ReschedulePolicy_coachTypeId_key" ON "passenger"."ReschedulePolicy"("coachTypeId");
+
+DO $$ BEGIN
+ ALTER TABLE "passenger"."ReschedulePolicy"
+ ADD CONSTRAINT "ReschedulePolicy_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"."BookingReschedule" (
+ "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,
+ "oldScheduleId" TEXT NOT NULL,
+ "newScheduleId" TEXT NOT NULL,
+ "oldOriginStationId" TEXT,
+ "oldDestinationStationId" TEXT,
+ "newOriginStationId" TEXT NOT NULL,
+ "newDestinationStationId" TEXT NOT NULL,
+ "oldSeatIds" TEXT[],
+ "newSeatIds" TEXT[],
+ "holdId" TEXT,
+ "oldFareMinor" INTEGER NOT NULL,
+ "newFareMinor" INTEGER NOT NULL,
+ "fareDifferenceMinor" INTEGER NOT NULL,
+ "feeMinor" INTEGER NOT NULL,
+ "amountDueMinor" INTEGER NOT NULL,
+ "isSameDay" BOOLEAN NOT NULL DEFAULT false,
+ "isRouteChange" BOOLEAN NOT NULL DEFAULT false,
+ "supplementaryChargeId" TEXT,
+ "expiresAt" TIMESTAMP(3),
+ "appliedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "BookingReschedule_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS "BookingReschedule_supplementaryChargeId_key" ON "passenger"."BookingReschedule"("supplementaryChargeId");
+CREATE INDEX IF NOT EXISTS "BookingReschedule_bookingId_status_idx" ON "passenger"."BookingReschedule"("bookingId", "status");
+
+DO $$ BEGIN
+ ALTER TABLE "passenger"."BookingReschedule"
+ ADD CONSTRAINT "BookingReschedule_bookingId_fkey" FOREIGN KEY ("bookingId")
+ REFERENCES "passenger"."Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+
+-- Seed the policy doc's §3 values. Same-day fee is stored as an absolute rule per class:
+-- Flex's "50% of the Standard fee" (30% / min 500 ETB) is 15% / min 250 ETB.
+INSERT INTO "passenger"."ReschedulePolicy"
+ ("id", "coachTypeId", "feePercent", "feeMinMinor", "routeChangeAllowed", "sameDayAllowed", "sameDayFeePercent", "sameDayFeeMinMinor", "cutoffMinutes", "updatedAt")
+SELECT gen_random_uuid()::text, ct."id", v."feePercent", v."feeMinMinor", v."routeChangeAllowed", v."sameDayAllowed", v."sameDayFeePercent", v."sameDayFeeMinMinor", v."cutoffMinutes", CURRENT_TIMESTAMP
+FROM (VALUES
+ ('HSC', 30, 50000, false, false, 0, 0, 120),
+ ('HBC', 0, 0, true, true, 15, 25000, 60),
+ ('SBC', 0, 0, true, true, 0, 0, 60)
+) AS v("code", "feePercent", "feeMinMinor", "routeChangeAllowed", "sameDayAllowed", "sameDayFeePercent", "sameDayFeeMinMinor", "cutoffMinutes")
+JOIN "passenger"."CoachType" ct ON ct."code" = v."code"
+ON CONFLICT ("coachTypeId") DO NOTHING;
diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma
index 22bfa8f40..693bf1fb2 100644
--- a/apps/edr-passenger-api/prisma/schema.prisma
+++ b/apps/edr-passenger-api/prisma/schema.prisma
@@ -80,6 +80,7 @@ model CoachType {
updatedAt DateTime @updatedAt
coaches Coach[]
seatClasses SeatClass[]
+ reschedulePolicy ReschedulePolicy?
@@schema("passenger")
}
@@ -568,6 +569,7 @@ model Booking {
foodOrders FoodOrder[]
agentBooking AgentBooking?
modifications BookingModification[]
+ reschedules BookingReschedule[]
cancellation BookingCancellation?
baggage BaggageBooking[]
excessBaggageCharges ExcessBaggageCharge[]
@@ -1205,6 +1207,61 @@ model AgentCommission {
@@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.
+model ReschedulePolicy {
+ id String @id @default(uuid())
+ coachTypeId String @unique
+ feePercent Int @default(0) // % of the leg's original fare
+ feeMinMinor Int @default(0) // fee floor, ETB minor units
+ routeChangeAllowed Boolean @default(true)
+ sameDayAllowed Boolean @default(true)
+ sameDayFeePercent Int @default(0) // same-day change: % of the leg's original fare (replaces feePercent)
+ sameDayFeeMinMinor Int @default(0) // same-day change: fee floor, ETB minor units
+ cutoffMinutes Int @default(60) // reject when departure - now < this
+ isActive Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ coachType CoachType @relation(fields: [coachTypeId], references: [id])
+
+ @@schema("passenger")
+}
+
+/// One reschedule request for one leg of a booking. PENDING_PAYMENT while the supplementary
+/// charge is unpaid; APPLIED once the booking was moved; EXPIRED when the payment deadline passed.
+model BookingReschedule {
+ id String @id @default(uuid())
+ bookingId String
+ leg Int @default(1) // 1 = outbound, 2 = return
+ status String @default("PENDING_PAYMENT") // PENDING_PAYMENT | APPLIED | EXPIRED
+ requestedBy String // passengerId or IAM user id
+ oldScheduleId String
+ newScheduleId String
+ oldOriginStationId String?
+ oldDestinationStationId String?
+ newOriginStationId String
+ newDestinationStationId String
+ oldSeatIds String[]
+ newSeatIds String[]
+ holdId String?
+ oldFareMinor Int
+ newFareMinor Int
+ fareDifferenceMinor Int // new - old; negative = forfeited for now
+ feeMinor Int
+ amountDueMinor Int // fee + max(0, difference)
+ isSameDay Boolean @default(false)
+ isRouteChange Boolean @default(false)
+ supplementaryChargeId String? @unique
+ expiresAt DateTime?
+ appliedAt DateTime?
+ createdAt DateTime @default(now())
+ booking Booking @relation(fields: [bookingId], references: [id])
+
+ @@index([bookingId, status])
+ @@schema("passenger")
+}
+
model BookingModification {
id String @id @default(uuid())
bookingId String
diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts
index 313ce34b0..8768ba899 100644
--- a/apps/edr-passenger-api/prisma/seed.ts
+++ b/apps/edr-passenger-api/prisma/seed.ts
@@ -657,6 +657,7 @@ 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}}.' },
// 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' },
diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts
index e3b85c21f..ff77b8b07 100644
--- a/apps/edr-passenger-api/src/app.module.ts
+++ b/apps/edr-passenger-api/src/app.module.ts
@@ -64,6 +64,7 @@ import { ConfigurableFareModule } from "./modules/configurable-fare/configurable
import { SegmentFareSeeder } from "./seed/segment-fare.seeder";
import { EOtpType } from "@tria-plc/iamapi-common";
+import { RescheduleModule } from './modules/reschedule/reschedule.module';
@Module({
imports: [
@@ -164,6 +165,7 @@ import { EOtpType } from "@tria-plc/iamapi-common";
TasksModule,
AppReleasesModule,
ConfigurableFareModule,
+ RescheduleModule,
],
providers: [
{ provide: APP_FILTER, useClass: DeleteExceptionFilter },
diff --git a/apps/edr-passenger-api/src/common/audit.actions.ts b/apps/edr-passenger-api/src/common/audit.actions.ts
index f9ee389db..8ac818e48 100644
--- a/apps/edr-passenger-api/src/common/audit.actions.ts
+++ b/apps/edr-passenger-api/src/common/audit.actions.ts
@@ -86,6 +86,8 @@ export const AUDIT_ENTITIES = {
// Operations
Ticket: 'Ticket',
Booking: 'Booking',
+ BookingReschedule: 'BookingReschedule',
+ ReschedulePolicy: 'ReschedulePolicy',
} as const;
export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES];
diff --git a/apps/edr-passenger-api/src/common/passenger-permission.util.ts b/apps/edr-passenger-api/src/common/passenger-permission.util.ts
index cc94b0e0c..628d39dfe 100644
--- a/apps/edr-passenger-api/src/common/passenger-permission.util.ts
+++ b/apps/edr-passenger-api/src/common/passenger-permission.util.ts
@@ -28,7 +28,7 @@ type EmployeeLike = {
delegatedPositions?: PositionLike[];
};
-type MeLikeUser = {
+export type MeLikeUser = {
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?: EmployeeLike | EmployeeLike[] | null;
diff --git a/apps/edr-passenger-api/src/common/utils/phone.utils.ts b/apps/edr-passenger-api/src/common/utils/phone.utils.ts
index 73ca8dc16..501d86427 100644
--- a/apps/edr-passenger-api/src/common/utils/phone.utils.ts
+++ b/apps/edr-passenger-api/src/common/utils/phone.utils.ts
@@ -1,15 +1,3 @@
-/**
- * Phone normalisation shared by any lookup that has to match a number a customer typed
- * against one already stored. Ethiopian numbers reach us in three interchangeable shapes
- * (+2519…, 2519…, 09…) depending on whether they came from IAM, a guest booking form or a
- * saved profile, so an exact-string match silently misses.
- */
-
-/**
- * Returns all plausible normalised variants of a raw phone string so that the
- * DB query matches regardless of how the number was stored (local 09… vs international +251…).
- * Returns an empty array when the input is clearly invalid (< 7 digits).
- */
export function normalizePhoneVariants(raw: string): string[] {
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
const stripped = raw.replace(/[^\d+]/g, '');
@@ -38,13 +26,6 @@ export function normalizePhoneVariants(raw: string): string[] {
return [...variants];
}
-/**
- * A sign-in identifier is a single free-text field: the passenger types either an email
- * address or a phone number and the server works out which. Phone is the default reading —
- * an email must contain an `@` with something either side of it, everything else is treated
- * as a number so that malformed emails don't silently fall through to a phone lookup that
- * can never match.
- */
export type ResolvedIdentifier = {
kind: 'email' | 'phone';
/** Lower-cased email, or null when the input is a phone number. */
@@ -75,3 +56,27 @@ export function maskPhone(phone: string): string {
const tail = stripped.slice(-3);
return `${head}${'*'.repeat(4)}${tail}`;
}
+
+/**
+ * Collapses a number to a single canonical E.164 form so two values can be compared directly.
+ * Mirrors `PassengerAuthService.standardizePhone`, plus the bare-9-digit case the passenger
+ * form produces (its input sits behind a fixed `+251` prefix control).
+ *
+ */
+export function normalizePhone(phone?: string | null): string | null {
+ if (!phone) return null;
+ const digits = phone.replace(/\D/g, '');
+ if (!digits) return null;
+ if (digits.startsWith('251')) return `+${digits}`;
+ if (digits.startsWith('0')) return `+251${digits.slice(1)}`;
+ // A bare local subscriber number, e.g. "912345678" from the +251-prefixed input.
+ if (digits.length === 9) return `+251${digits}`;
+ return `+${digits}`;
+}
+
+/** True only when both numbers are present and resolve to the same E.164 form. */
+export function samePhone(a?: string | null, b?: string | null): boolean {
+ const left = normalizePhone(a);
+ const right = normalizePhone(b);
+ return !!left && !!right && left === right;
+}
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
index 2aff404b8..ecd3b4043 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
@@ -26,7 +26,6 @@ import { BookingsService } from "./bookings.service";
import { GuestBookingService } from "./guest-booking.service";
import {
CreateBookingDto,
- ModifyBookingDto,
CancelBookingDto,
} from "./bookings.dto";
import {
@@ -692,22 +691,6 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b
return this.service.getByRef(ref);
}
- @Patch(":bookingRef/modify")
- @UseGuards(JwtGuard)
- @ApiBearerAuth("JWT-auth")
- @ApiOperation({
- summary: "Modify booking seats or trip",
- description: "Allows modification of confirmed bookings before departure",
- })
- @ApiResponse({ status: 200, description: "Booking modified successfully" })
- @ApiResponse({
- status: 400,
- description: "Cannot modify cancelled or past bookings",
- })
- modify(@Req() req: any, @Body() dto: ModifyBookingDto) {
- return this.service.modify(dto, req.user?.id);
- }
-
@Delete(":id")
@PassengerAdmin()
@ApiBearerAuth("IAM-auth")
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
index 454339516..5e9d98d11 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
@@ -214,13 +214,6 @@ export class CreateBookingDto {
@IsOptional() @IsString() returnLeg2SeatClassId?: string;
}
-export class ModifyBookingDto {
- @ApiProperty() @IsString() bookingRef: string;
- @ApiProperty({ example: 'schedule-uuid' }) @IsString() newScheduleId: string;
- @ApiProperty({ type: [String] }) @IsArray() newSeatIds: string[];
- @ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
-}
-
export class CancelBookingDto {
@ApiProperty() @IsString() bookingRef: string;
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
index 01367bdb1..7a447690a 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -5,8 +5,9 @@ import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
-import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
+import { CreateBookingDto } from './bookings.dto';
import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util';
+import { buildPaymentBreakdown } from './payment-breakdown.util';
import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
@@ -1873,7 +1874,7 @@ export class BookingsService {
};
}
- private async getBaseFare(
+ async getBaseFare(
scheduleId: string,
seatClassId: string,
segmentRoute?: string,
@@ -2005,6 +2006,8 @@ export class BookingsService {
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
+ // Money collected after the original payment (reschedule fees, underpayments).
+ supplementaryCharges: { orderBy: { createdAt: 'asc' } },
priceTier: { select: { priceMinor: true } },
},
});
@@ -2098,6 +2101,8 @@ export class BookingsService {
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
+ // Money collected after the original payment (reschedule fees, underpayments).
+ supplementaryCharges: { orderBy: { createdAt: 'asc' } },
priceTier: { select: { priceMinor: true } },
},
});
@@ -2176,12 +2181,20 @@ export class BookingsService {
},
};
}),
+ // `amountMinor` stays exactly as it was — the original intent, in the major units that
+ // column actually stores — so existing callers keep working. Everything collected since
+ // (reschedule fees and fare differences) lives in `breakdown`, whose `totalPaidMinor` is
+ // the number to show as "Total paid". See payment-breakdown.util.ts.
payment: (booking as any).paymentIntent
? {
method: (booking as any).paymentIntent.method,
status: (booking as any).paymentIntent.status,
amountMinor: (booking as any).paymentIntent.amountMinor,
currency: (booking as any).paymentIntent.currency,
+ breakdown: buildPaymentBreakdown(
+ (booking as any).paymentIntent,
+ (booking as any).supplementaryCharges ?? [],
+ ),
}
: undefined,
// One ticket per passenger per leg (round trips have a separate ticket — and
@@ -2199,22 +2212,6 @@ export class BookingsService {
};
}
- async modify(dto: ModifyBookingDto, iamUserId?: string) {
- const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, schedule: true } });
- if (!booking) throw new NotFoundException('Booking not found');
- if (booking.status !== 'CONFIRMED') throw new BadRequestException('Only confirmed bookings can be modified');
- if (booking.schedule.departureAt < new Date()) throw new BadRequestException('Cannot modify past bookings');
-
- const oldSeats = booking.seats.map(s => s.seatId);
- await this.prisma.bookingModification.create({
- data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason },
- });
- await this.seatsService.releaseSeats(booking.id);
- await this.seatsService.confirmSeats(dto.newSeatIds);
- await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'UPDATE', entityType: 'Booking', entityId: booking.id, oldData: { seatIds: oldSeats }, newData: { seatIds: dto.newSeatIds, reason: dto.reason } });
- return { modified: true, bookingRef: dto.bookingRef };
- }
-
async cancel(bookingRef: string, reason?: string, iamUserId?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
if (!booking) throw new NotFoundException('Booking not found');
diff --git a/apps/edr-passenger-api/src/modules/bookings/payment-breakdown.util.ts b/apps/edr-passenger-api/src/modules/bookings/payment-breakdown.util.ts
new file mode 100644
index 000000000..0980fcc97
--- /dev/null
+++ b/apps/edr-passenger-api/src/modules/bookings/payment-breakdown.util.ts
@@ -0,0 +1,101 @@
+export type PaymentLineKind = 'BOOKING' | 'SUPPLEMENTARY';
+
+export interface PaymentLine {
+ kind: PaymentLineKind;
+ /** Human label — "Original booking", "Reschedule", "Excess baggage". */
+ label: string;
+ /** Raw reason for supplementary lines (RESCHEDULE, UNDERPAYMENT, …); null for the booking line. */
+ reason: string | null;
+ /** Always true minor units, whatever the source column stored. */
+ amountMinor: number;
+ currency: string;
+ status: string;
+ paidAt: string | null;
+ /** Whether this line represents money actually collected. */
+ settled: boolean;
+}
+
+export interface PaymentBreakdown {
+ lines: PaymentLine[];
+ /** Sum of settled lines, or null when they are not all in one currency. */
+ totalPaidMinor: number | null;
+ totalPaidCurrency: string | null;
+ /** True when something is still owed (a charge raised but not yet paid). */
+ hasOutstanding: boolean;
+ outstandingMinor: number;
+}
+
+/** `PaymentIntent.amountMinor` is a Float in major units — bring it onto the minor-unit scale. */
+export function intentAmountToMinor(amount: number | null | undefined): number {
+ if (amount == null) return 0;
+ return Math.round(amount * 100);
+}
+
+const SUPPLEMENTARY_LABELS: Record = {
+ RESCHEDULE: 'Reschedule',
+ UNDERPAYMENT: 'Underpayment',
+ FARE_CORRECTION: 'Fare correction',
+ EXCESS_BAGGAGE: 'Excess baggage',
+};
+
+function labelFor(reason: string): string {
+ return (
+ SUPPLEMENTARY_LABELS[reason] ??
+ // "SOME_OTHER_REASON" → "Some other reason"
+ reason.charAt(0).toUpperCase() + reason.slice(1).toLowerCase().replace(/_/g, ' ')
+ );
+}
+
+export function buildPaymentBreakdown(
+ paymentIntent: { status?: string | null; amountMinor?: number | null; currency?: string | null } | null | undefined,
+ supplementaryCharges: Array<{
+ reason: string;
+ amountMinor: number;
+ currency: string;
+ status: string;
+ paidAt: Date | string | null;
+ }> = [],
+): PaymentBreakdown {
+ const lines: PaymentLine[] = [];
+
+ if (paymentIntent) {
+ lines.push({
+ kind: 'BOOKING',
+ label: 'Original booking',
+ reason: null,
+ amountMinor: intentAmountToMinor(paymentIntent.amountMinor),
+ currency: paymentIntent.currency ?? 'ETB',
+ status: paymentIntent.status ?? 'UNKNOWN',
+ paidAt: null,
+ settled: paymentIntent.status === 'SUCCEEDED',
+ });
+ }
+
+ for (const charge of supplementaryCharges) {
+ if (charge.status === 'WAIVED' || charge.status === 'EXPIRED') continue;
+ lines.push({
+ kind: 'SUPPLEMENTARY',
+ label: labelFor(charge.reason),
+ reason: charge.reason,
+ amountMinor: charge.amountMinor,
+ currency: charge.currency ?? 'ETB',
+ status: charge.status,
+ paidAt: charge.paidAt ? new Date(charge.paidAt).toISOString() : null,
+ settled: charge.status === 'PAID',
+ });
+ }
+
+ const settled = lines.filter((l) => l.settled);
+ const currencies = new Set(settled.map((l) => l.currency));
+ const singleCurrency = currencies.size === 1 ? [...currencies][0] : null;
+
+ const outstanding = lines.filter((l) => l.status === 'PENDING');
+
+ return {
+ lines,
+ totalPaidMinor: singleCurrency ? settled.reduce((sum, l) => sum + l.amountMinor, 0) : null,
+ totalPaidCurrency: singleCurrency,
+ hasOutstanding: outstanding.length > 0,
+ outstandingMinor: outstanding.reduce((sum, l) => sum + l.amountMinor, 0),
+ };
+}
diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts
index 3b7e7be2f..44ba03dea 100644
--- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts
@@ -731,6 +731,24 @@ export class NotificationsService {
);
}
+ @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'],
+ );
+ }
+
@OnEvent('booking.cancelled')
async onBookingCancelled(payload: any) {
const booking = payload.booking;
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts
index 07f452e43..970983cb4 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts
@@ -74,6 +74,6 @@ function rabbitMQImport(): DynamicModule[] {
PaymentSyncService,
ServiceAuthGuard,
],
- exports: [PaymentClientService, PaymentsService],
+ exports: [PaymentClientService, PaymentsService, SupplementaryChargesService],
})
export class PaymentsModule {}
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
index 7192c7fc7..ced758d98 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
@@ -1619,6 +1619,7 @@ export class PaymentsService {
settledCurrency: event.currency,
},
});
+ this.eventEmitter.emit("supplementary-charge.paid", { chargeId: charge.id });
}
return { processed: true, alreadyFinalized: count === 0 };
}
@@ -1996,7 +1997,7 @@ export class PaymentsService {
});
}
- private async createJourneySegments(
+ async createJourneySegments(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
) {
const b = booking as any;
diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts
index 28188f70f..24e112548 100644
--- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts
+++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts
@@ -52,7 +52,7 @@ describe('SupplementaryChargesService — audit', () => {
};
audit = { log: jest.fn().mockResolvedValue(undefined) };
- // Constructor order: prisma, audit, sms, email, paymentClient, currency.
+ // Constructor order: prisma, audit, sms, email, paymentClient, currency, eventEmitter.
service = new SupplementaryChargesService(
prisma as any,
audit as any,
@@ -60,6 +60,7 @@ describe('SupplementaryChargesService — audit', () => {
{ sendEmail: jest.fn() } as any,
{} as any,
{} as any,
+ { emit: jest.fn() } as any,
);
return row;
};
diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts
index 184ff69ad..30460c8b8 100644
--- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts
@@ -14,6 +14,7 @@ import {
} from '@edr/types';
import { PaymentPlatformDto } from './payments.dto';
import { PaymentMethodType } from '@prisma/client';
+import { EventEmitter2 } from '@nestjs/event-emitter';
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
@@ -45,6 +46,7 @@ export class SupplementaryChargesService {
private emailClient: EmailClientService,
private paymentClient: PaymentClientService,
private currencyService: CurrencyService,
+ private eventEmitter: EventEmitter2,
) {}
async create(dto: {
@@ -55,6 +57,8 @@ export class SupplementaryChargesService {
contactPhone?: string;
contactEmail?: string;
createdBy: string;
+ /** Overrides the default 72h link lifetime (a reschedule charge must die with its seat hold). */
+ expiresAt?: Date;
}) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef: dto.bookingRef },
@@ -66,7 +70,7 @@ export class SupplementaryChargesService {
}
if (dto.amountMinor <= 0) throw new BadRequestException('Amount must be positive');
- const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
+ const expiresAt = dto.expiresAt ?? new Date(Date.now() + CHARGE_TTL_MS);
const charge = await this.prisma.supplementaryCharge.create({
data: {
bookingId: booking.id,
@@ -172,6 +176,7 @@ export class SupplementaryChargesService {
providerTxnId: providerTxnId ?? null,
},
});
+ this.eventEmitter.emit('supplementary-charge.paid', { chargeId: id });
}
return updated!;
diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts
index 043ec919c..bd72b649b 100644
--- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts
+++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts
@@ -69,6 +69,7 @@ describe('SupplementaryChargesService — payment methods', () => {
{} as any,
paymentClient as any,
new CurrencyService(prisma as any),
+ { emit: jest.fn() } as any,
);
};
diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts
new file mode 100644
index 000000000..0aba110d1
--- /dev/null
+++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts
@@ -0,0 +1,82 @@
+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 { RescheduleService } from './reschedule.service';
+import {
+ CreateReschedulePolicyDto,
+ CreateRescheduleDto,
+ RescheduleQuoteDto,
+ UpdateReschedulePolicyDto,
+} from './reschedule.dto';
+
+@ApiTags('Reschedule')
+@Controller()
+export class RescheduleController {
+ constructor(private service: RescheduleService) {}
+
+ @Get('reschedule/policies')
+ @PassengerStaff(PASSENGER_PERMS.bookings.view)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' })
+ listPolicies() {
+ return this.service.listPolicies();
+ }
+
+ @Get('reschedule/policies/available-coach-types')
+ @PassengerStaff(PASSENGER_PERMS.bookings.view)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' })
+ listUnconfiguredCoachTypes() {
+ return this.service.listUnconfiguredCoachTypes();
+ }
+
+ @Post('reschedule/policies')
+ @PassengerAdmin()
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' })
+ createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) {
+ return this.service.createPolicy(dto, req.user?.id);
+ }
+
+ @Patch('reschedule/policies/:coachTypeId')
+ @PassengerAdmin()
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' })
+ updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) {
+ return this.service.updatePolicy(coachTypeId, dto, req.user?.id);
+ }
+
+ @Delete('reschedule/policies/:coachTypeId')
+ @PassengerAdmin()
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' })
+ deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {
+ return this.service.deletePolicy(coachTypeId, req.user?.id);
+ }
+
+ @Get('bookings/:bookingRef/reschedule')
+ @UseGuards(JwtGuard)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Reschedule eligibility per leg, pending request, history' })
+ options(@Req() req: any, @Param('bookingRef') bookingRef: string) {
+ return this.service.getOptions(bookingRef, req.user);
+ }
+
+ @Post('bookings/:bookingRef/reschedule/quote')
+ @UseGuards(JwtGuard)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Itemised quote (fee, fare difference, amount due) for a proposed change' })
+ quote(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: RescheduleQuoteDto) {
+ return this.service.quote(bookingRef, dto, req.user);
+ }
+
+ @Post('bookings/:bookingRef/reschedule')
+ @UseGuards(JwtGuard)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Reschedule a leg. Applies immediately when nothing is due, otherwise returns a payment token' })
+ create(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: CreateRescheduleDto) {
+ return this.service.create(bookingRef, dto, req.user);
+ }
+}
diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts
new file mode 100644
index 000000000..479896adf
--- /dev/null
+++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts
@@ -0,0 +1,77 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import {
+ ArrayMinSize,
+ IsArray,
+ IsBoolean,
+ IsInt,
+ IsOptional,
+ IsString,
+ Max,
+ Min,
+} from 'class-validator';
+
+export class UpdateReschedulePolicyDto {
+ @ApiPropertyOptional({ example: 30, description: '% of the leg fare charged as a change fee' })
+ @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100)
+ feePercent?: number;
+
+ @ApiPropertyOptional({ example: 50000, description: 'Fee floor in ETB minor units (500 ETB = 50000)' })
+ @IsOptional() @Type(() => Number) @IsInt() @Min(0)
+ feeMinMinor?: number;
+
+ @ApiPropertyOptional({ example: false })
+ @IsOptional() @IsBoolean()
+ routeChangeAllowed?: boolean;
+
+ @ApiPropertyOptional({ example: false })
+ @IsOptional() @IsBoolean()
+ sameDayAllowed?: boolean;
+
+ @ApiPropertyOptional({ example: 15, description: 'Same-day change: % of the leg fare (replaces feePercent)' })
+ @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100)
+ sameDayFeePercent?: number;
+
+ @ApiPropertyOptional({ example: 25000, description: 'Same-day change: fee floor in ETB minor units' })
+ @IsOptional() @Type(() => Number) @IsInt() @Min(0)
+ sameDayFeeMinMinor?: number;
+
+ @ApiPropertyOptional({ example: 120, description: 'Changes are refused this many minutes before departure' })
+ @IsOptional() @Type(() => Number) @IsInt() @Min(0)
+ cutoffMinutes?: number;
+
+ @ApiPropertyOptional({ example: true })
+ @IsOptional() @IsBoolean()
+ isActive?: boolean;
+}
+
+/** Same fields as the update DTO, plus the fare class the new policy attaches to. */
+export class CreateReschedulePolicyDto extends UpdateReschedulePolicyDto {
+ @ApiProperty({ example: 'coach-type-uuid', description: 'CoachType the policy applies to (one policy per fare class)' })
+ @IsString()
+ coachTypeId: string;
+}
+
+export class RescheduleQuoteDto {
+ @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: 'schedule-uuid' })
+ @IsString() newScheduleId: string;
+
+ @ApiProperty({ example: 'station-uuid' })
+ @IsString() newOriginStationId: string;
+
+ @ApiProperty({ example: 'station-uuid' })
+ @IsString() newDestinationStationId: string;
+
+ @ApiProperty({ type: [String], description: 'One seat per seated passenger of the leg, in BookingSeat order' })
+ @IsArray() @ArrayMinSize(1) @IsString({ each: true })
+ newSeatIds: string[];
+}
+
+export class CreateRescheduleDto extends RescheduleQuoteDto {
+ @ApiProperty({ example: 'hold-uuid', description: 'SeatHold on the new schedule covering newSeatIds (POST /seats/hold)' })
+ @IsString() holdId: string;
+}
diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts
new file mode 100644
index 000000000..ffb2c23bc
--- /dev/null
+++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts
@@ -0,0 +1,40 @@
+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 { TicketsModule } from '../tickets/tickets.module';
+import { PaymentsModule } from '../payments/payments.module';
+import { CurrencyModule } from '../currency/currency.module';
+import { RescheduleController } from './reschedule.controller';
+import { RescheduleService, SUPPLEMENTARY_CHARGE_PAID_EVENT } from './reschedule.service';
+
+/**
+ * RescheduleService is request-scoped by transitivity (AuditService injects REQUEST), and Nest
+ * never fires @OnEvent on request-scoped providers — so the listener lives on this singleton and
+ * resolves the service per event, the same way TasksService reaches PaymentsService.
+ */
+@Injectable()
+export class RescheduleEventsListener {
+ private readonly logger = new Logger(RescheduleEventsListener.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(RescheduleService, undefined, { strict: false });
+ await service.applyForCharge(payload.chargeId);
+ } catch (err) {
+ this.logger.error(`Failed to apply reschedule for charge ${payload.chargeId}: ${err instanceof Error ? err.message : err}`);
+ }
+ }
+}
+
+@Module({
+ imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule],
+ controllers: [RescheduleController],
+ providers: [RescheduleService, RescheduleEventsListener],
+ exports: [RescheduleService],
+})
+export class RescheduleModule {}
diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.spec.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.spec.ts
new file mode 100644
index 000000000..d9dca0157
--- /dev/null
+++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.spec.ts
@@ -0,0 +1,37 @@
+import { addisDay, computeRescheduleAmounts } from './reschedule.service';
+
+const standard = { feePercent: 30, feeMinMinor: 50000, sameDayFeePercent: 0, sameDayFeeMinMinor: 0 };
+const flex = { feePercent: 0, feeMinMinor: 0, sameDayFeePercent: 15, sameDayFeeMinMinor: 25000 };
+const premium = { feePercent: 0, feeMinMinor: 0, sameDayFeePercent: 0, sameDayFeeMinMinor: 0 };
+
+describe('computeRescheduleAmounts (policy §3)', () => {
+ it('Standard: 30% of fare, floored at 500 ETB, plus positive fare difference', () => {
+ // 1000 ETB fare → 30% = 300 < 500 floor
+ expect(computeRescheduleAmounts(standard, 100000, 120000, false)).toEqual({ feeMinor: 50000, fareDifferenceMinor: 20000, amountDueMinor: 70000 });
+ // 3000 ETB fare → 30% = 900 > floor
+ expect(computeRescheduleAmounts(standard, 300000, 300000, false)).toEqual({ feeMinor: 90000, fareDifferenceMinor: 0, amountDueMinor: 90000 });
+ });
+
+ it('negative fare difference is recorded but never paid out', () => {
+ expect(computeRescheduleAmounts(standard, 300000, 200000, false)).toEqual({ feeMinor: 90000, fareDifferenceMinor: -100000, amountDueMinor: 90000 });
+ expect(computeRescheduleAmounts(flex, 300000, 200000, false)).toEqual({ feeMinor: 0, fareDifferenceMinor: -100000, amountDueMinor: 0 });
+ });
+
+ it('Flex: free, fare difference only; same-day is 15% min 250 ETB', () => {
+ expect(computeRescheduleAmounts(flex, 100000, 150000, false)).toEqual({ feeMinor: 0, fareDifferenceMinor: 50000, amountDueMinor: 50000 });
+ expect(computeRescheduleAmounts(flex, 100000, 100000, true)).toEqual({ feeMinor: 25000, fareDifferenceMinor: 0, amountDueMinor: 25000 });
+ expect(computeRescheduleAmounts(flex, 400000, 400000, true)).toEqual({ feeMinor: 60000, fareDifferenceMinor: 0, amountDueMinor: 60000 });
+ });
+
+ it('Premium: always free, even same-day', () => {
+ expect(computeRescheduleAmounts(premium, 500000, 500000, true).amountDueMinor).toBe(0);
+ expect(computeRescheduleAmounts(premium, 500000, 560000, true).amountDueMinor).toBe(60000);
+ });
+});
+
+describe('addisDay', () => {
+ it('compares calendar days in Africa/Addis_Ababa (UTC+3), not UTC', () => {
+ expect(addisDay(new Date('2026-09-01T21:30:00Z'))).toBe('2026-09-02');
+ expect(addisDay(new Date('2026-09-01T20:30:00Z'))).toBe('2026-09-01');
+ });
+});
diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts
new file mode 100644
index 000000000..fdf10ca0a
--- /dev/null
+++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts
@@ -0,0 +1,643 @@
+import {
+ BadRequestException,
+ ConflictException,
+ ForbiddenException,
+ 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 { MeLikeUser } from '../../common/passenger-permission.util';
+import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
+import { normalizePhone, samePhone } from '../../common/utils/phone.utils';
+import { BookingsService } from '../bookings/bookings.service';
+import { SeatsService } from '../seats/seats.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 {
+ CreateReschedulePolicyDto,
+ CreateRescheduleDto,
+ RescheduleQuoteDto,
+ UpdateReschedulePolicyDto,
+} from './reschedule.dto';
+
+export const RESCHEDULE_CHARGE_REASON = 'RESCHEDULE';
+export const SUPPLEMENTARY_CHARGE_PAID_EVENT = 'supplementary-charge.paid';
+
+/**
+ * Coaches nobody buys a seat in, so they can never carry a reschedule 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. This mirrors the portal's own test (`/dining|dpc/i`,
+ * booking/seats/page.tsx) and checks `code` as well as `type`.
+ */
+const NON_FARE_COACH_TERMS = ['dining', 'dpc', 'baggage'];
+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 } },
+ ]),
+};
+
+type PolicyNumbers = {
+ feePercent: number;
+ feeMinMinor: number;
+ sameDayFeePercent: number;
+ sameDayFeeMinMinor: number;
+};
+
+/**
+ * Pure fee arithmetic — policy §3. Negative fare differences are recorded but NOT paid out
+ * (credit/refund handling is a later step), so amountDue never goes below the fee.
+ */
+export function computeRescheduleAmounts(
+ policy: PolicyNumbers,
+ oldFareMinor: number,
+ newFareMinor: number,
+ isSameDay: boolean,
+): { feeMinor: number; fareDifferenceMinor: number; amountDueMinor: number } {
+ const pct = isSameDay ? policy.sameDayFeePercent : policy.feePercent;
+ const min = isSameDay ? policy.sameDayFeeMinMinor : policy.feeMinMinor;
+ const feeMinor = pct > 0 || min > 0 ? Math.max(Math.round((oldFareMinor * pct) / 100), min) : 0;
+ const fareDifferenceMinor = newFareMinor - oldFareMinor;
+ return { feeMinor, fareDifferenceMinor, amountDueMinor: feeMinor + Math.max(0, fareDifferenceMinor) };
+}
+
+export function addisDay(d: Date): string {
+ return d.toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
+}
+
+type ActingUser = MeLikeUser & { id?: string; sub?: string; phoneNumber?: string };
+
+type LegView = {
+ leg: number;
+ scheduleId: string;
+ originStationId: string | null;
+ destinationStationId: string | null;
+ departureAt: Date;
+ seats: Array<{ id: string; seatId: string; passengerName: string; fareMinor: number | null; passengerCategory: string }>;
+ coachTypeId: string;
+};
+
+// Seats are ordered by passenger name so getOptions(), quote() and create() all see the same
+// sequence — the client submits newSeatIds in that order (BookingSeat has no creation order).
+const bookingInclude: Prisma.BookingInclude = {
+ schedule: { select: { id: true, departureAt: true, arrivalAt: true, originStationId: true, destinationStationId: true } },
+ returnSchedule: { select: { id: true, departureAt: true, arrivalAt: true, originStationId: true, destinationStationId: true } },
+ seats: { include: { seat: { include: { coach: { select: { coachTypeId: true } } } } }, orderBy: [{ passengerName: 'asc' }, { id: 'asc' }] },
+};
+
+@Injectable()
+export class RescheduleService {
+ private readonly logger = new Logger(RescheduleService.name);
+
+ constructor(
+ private prisma: PrismaService,
+ private bookingsService: BookingsService,
+ private seatsService: SeatsService,
+ private ticketsService: TicketsService,
+ private paymentsService: PaymentsService,
+ private supplementaryCharges: SupplementaryChargesService,
+ private currencyService: CurrencyService,
+ private auditService: AuditService,
+ private eventEmitter: EventEmitter2,
+ ) {}
+
+ // ── Policy admin ─────────────────────────────────────────────────────────
+
+ /** The policies that exist, each carrying its fare class. A coach type with no policy is simply absent. */
+ async listPolicies() {
+ return this.prisma.reschedulePolicy.findMany({
+ include: { coachType: { select: { id: true, code: true, name: true, type: true } } },
+ orderBy: { coachType: { code: 'asc' } },
+ });
+ }
+
+ /** Fare classes still available to attach a policy to — the "add" dialog's dropdown. */
+ async listUnconfiguredCoachTypes() {
+ return this.prisma.coachType.findMany({
+ where: { ...NOT_A_FARE_CLASS, reschedulePolicy: { is: null } },
+ select: { id: true, code: true, name: true, type: true },
+ orderBy: { code: 'asc' },
+ });
+ }
+
+ async createPolicy(dto: CreateReschedulePolicyDto, 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 (NON_FARE_COACH_TERMS.some((t) => `${coachType.type} ${coachType.code}`.toLowerCase().includes(t))) {
+ throw new BadRequestException(`${coachType.code} is not a fare class — no seats are sold in it.`);
+ }
+ const existing = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId } });
+ if (existing) throw new ConflictException(`${coachType.code} already has a reschedule policy — edit it instead.`);
+
+ const policy = await this.prisma.reschedulePolicy.create({ data: { coachTypeId, ...values } });
+ await this.auditService.log({
+ userId: actorId,
+ action: AUDIT_ACTIONS.CREATE,
+ entityType: AUDIT_ENTITIES.ReschedulePolicy,
+ entityId: policy.id,
+ newData: { coachTypeCode: coachType.code, ...values },
+ });
+ return policy;
+ }
+
+ async deletePolicy(coachTypeId: string, actorId?: string) {
+ const policy = await this.prisma.reschedulePolicy.findUnique({
+ where: { coachTypeId },
+ include: { coachType: { select: { code: true } } },
+ });
+ if (!policy) throw new NotFoundException('Reschedule policy not found');
+
+ await this.prisma.reschedulePolicy.delete({ where: { coachTypeId } });
+ await this.auditService.log({
+ userId: actorId,
+ action: AUDIT_ACTIONS.DELETE,
+ entityType: AUDIT_ENTITIES.ReschedulePolicy,
+ entityId: policy.id,
+ oldData: policy,
+ });
+ // Rescheduling for this fare class is now refused outright (legBlockers treats a missing
+ // policy the same as an inactive one), which is the intended effect of deleting it.
+ return { deleted: true, coachTypeId };
+ }
+
+ async updatePolicy(coachTypeId: string, dto: UpdateReschedulePolicyDto, 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.reschedulePolicy.findUnique({ where: { coachTypeId } });
+ const policy = await this.prisma.reschedulePolicy.upsert({
+ where: { coachTypeId },
+ update: dto,
+ create: { coachTypeId, ...dto },
+ });
+ await this.auditService.log({
+ userId: actorId,
+ action: AUDIT_ACTIONS.UPDATE,
+ entityType: AUDIT_ENTITIES.ReschedulePolicy,
+ entityId: policy.id,
+ oldData: before ?? undefined,
+ newData: { coachTypeCode: coachType.code, ...dto },
+ });
+ return policy;
+ }
+
+ // ── Reads ────────────────────────────────────────────────────────────────
+
+ /** What the portal needs before picking a new schedule: per-leg eligibility + the rule set. */
+ async getOptions(bookingRef: string, user: ActingUser) {
+ const booking = await this.loadOwnedBooking(bookingRef, user);
+ const legs = this.legsOf(booking);
+ const out = [];
+ for (const leg of legs) {
+ const policy = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId: leg.coachTypeId } });
+ const blockers = this.legBlockers(booking, leg, policy);
+ out.push({
+ leg: leg.leg,
+ scheduleId: leg.scheduleId,
+ originStationId: leg.originStationId,
+ destinationStationId: leg.destinationStationId,
+ departureAt: leg.departureAt,
+ coachTypeId: leg.coachTypeId,
+ seatCount: leg.seats.length,
+ passengerNames: leg.seats.map((s) => s.passengerName),
+ oldFareMinor: this.legFare(booking, leg),
+ policy: policy && {
+ feePercent: policy.feePercent,
+ feeMinMinor: policy.feeMinMinor,
+ routeChangeAllowed: policy.routeChangeAllowed,
+ sameDayAllowed: policy.sameDayAllowed,
+ sameDayFeePercent: policy.sameDayFeePercent,
+ sameDayFeeMinMinor: policy.sameDayFeeMinMinor,
+ cutoffMinutes: policy.cutoffMinutes,
+ },
+ canReschedule: blockers.length === 0,
+ blockers,
+ });
+ }
+ const reschedules = await this.prisma.bookingReschedule.findMany({
+ where: { bookingId: booking.id },
+ orderBy: { createdAt: 'desc' },
+ });
+ const pending = reschedules.find((r) => r.status === 'PENDING_PAYMENT');
+ const charge = pending?.supplementaryChargeId
+ ? await this.prisma.supplementaryCharge.findUnique({
+ where: { id: pending.supplementaryChargeId },
+ select: { paymentToken: true, status: true, expiresAt: true },
+ })
+ : null;
+ return {
+ bookingRef: booking.bookingRef,
+ bookingType: booking.bookingType,
+ legs: out,
+ pending: pending ? { ...pending, paymentToken: charge?.paymentToken ?? null } : null,
+ history: reschedules.filter((r) => r.status !== 'PENDING_PAYMENT'),
+ };
+ }
+
+ // ── Quote / create / apply ───────────────────────────────────────────────
+
+ async quote(bookingRef: string, dto: RescheduleQuoteDto, user: ActingUser) {
+ const booking = await this.loadOwnedBooking(bookingRef, user);
+ return this.buildQuote(booking, dto);
+ }
+
+ async create(bookingRef: string, dto: CreateRescheduleDto, user: ActingUser) {
+ const booking = await this.loadOwnedBooking(bookingRef, user);
+ const q = await this.buildQuote(booking, dto);
+ 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 !== dto.newScheduleId) throw new BadRequestException('Seat hold is for a different schedule');
+ const held = new Set(hold.seatIds);
+ if (!dto.newSeatIds.every((id) => held.has(id))) throw new BadRequestException('Selected seats are not covered by the hold');
+
+ // Availability was enforced when the hold was taken (holdSeats checks holds + booked
+ // segments for the leg); tickets.generate() re-checks at apply time.
+
+ const requestedBy = user.id ?? user.sub ?? booking.passengerId;
+ const newDeparture = q.newDepartureAt;
+ const expiresAt = computePaymentDeadline(new Date(), newDeparture);
+
+ const reschedule = await this.prisma.bookingReschedule.create({
+ data: {
+ bookingId: booking.id,
+ leg: q.leg,
+ status: 'PENDING_PAYMENT',
+ requestedBy,
+ oldScheduleId: q.oldScheduleId,
+ newScheduleId: dto.newScheduleId,
+ oldOriginStationId: q.oldOriginStationId,
+ oldDestinationStationId: q.oldDestinationStationId,
+ newOriginStationId: dto.newOriginStationId,
+ newDestinationStationId: dto.newDestinationStationId,
+ oldSeatIds: q.oldSeatIds,
+ newSeatIds: dto.newSeatIds,
+ holdId: dto.holdId,
+ oldFareMinor: q.oldFareMinor,
+ newFareMinor: q.newFareMinor,
+ fareDifferenceMinor: q.fareDifferenceMinor,
+ feeMinor: q.feeMinor,
+ amountDueMinor: q.amountDueMinor,
+ isSameDay: q.isSameDay,
+ isRouteChange: q.isRouteChange,
+ expiresAt: q.amountDueMinor > 0 ? expiresAt : null,
+ },
+ });
+
+ if (q.amountDueMinor === 0) {
+ await this.apply(reschedule.id);
+ return { rescheduleId: reschedule.id, status: 'APPLIED', amountDueMinor: 0, paymentToken: null, quote: q };
+ }
+
+ // Money owed: raise a supplementary charge (pay page /pay-balance/:token, SMS+email link) and
+ // keep the new seats held until the same deadline the charge carries.
+ const charge = await this.supplementaryCharges.create({
+ bookingRef: booking.bookingRef,
+ amountMinor: q.amountDueMinor,
+ reason: RESCHEDULE_CHARGE_REASON,
+ notes: `Reschedule leg ${q.leg} → schedule ${dto.newScheduleId}`,
+ createdBy: requestedBy,
+ expiresAt,
+ });
+ await this.prisma.bookingReschedule.update({
+ where: { id: reschedule.id },
+ data: { supplementaryChargeId: charge.id },
+ });
+ await this.seatsService.confirmSeats(dto.newSeatIds);
+
+ await this.auditService.log({
+ userId: requestedBy,
+ action: AUDIT_ACTIONS.CREATE,
+ entityType: AUDIT_ENTITIES.BookingReschedule,
+ entityId: reschedule.id,
+ newData: { bookingRef: booking.bookingRef, leg: q.leg, amountDueMinor: q.amountDueMinor, chargeId: charge.id },
+ });
+ return { rescheduleId: reschedule.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 r = await this.prisma.bookingReschedule.findUnique({ where: { supplementaryChargeId } });
+ if (!r || r.status !== 'PENDING_PAYMENT') return;
+ await this.apply(r.id);
+ }
+
+ /** Moves the booking leg: booking fields, seats, journey segments, tickets. */
+ async apply(rescheduleId: string) {
+ const r = await this.prisma.bookingReschedule.findUnique({ where: { id: rescheduleId } });
+ if (!r) throw new NotFoundException('Reschedule not found');
+ if (r.status !== 'PENDING_PAYMENT') return r;
+
+ const booking = await this.prisma.booking.findUnique({ where: { id: r.bookingId }, include: bookingInclude });
+ if (!booking) throw new NotFoundException('Booking not found');
+ const leg = this.legsOf(booking).find((l) => l.leg === r.leg);
+ if (!leg) throw new BadRequestException('Leg no longer exists on booking');
+ if (leg.seats.length !== r.newSeatIds.length) throw new BadRequestException('Seat count changed since quote');
+
+ const newTotal = Math.max(0, booking.totalMinor + r.fareDifferenceMinor);
+ const displayTotal =
+ booking.displayCurrency && booking.displayCurrency !== 'ETB'
+ ? await this.currencyService.convertAmount(newTotal, 'ETB' as any, booking.displayCurrency as any)
+ : newTotal;
+ const perSeatNew = this.splitFare(r.newFareMinor, leg.seats);
+
+ await this.prisma.$transaction(async (tx) => {
+ await tx.booking.update({
+ where: { id: booking.id },
+ data: {
+ ...(r.leg === 1
+ ? { scheduleId: r.newScheduleId, originStationId: r.newOriginStationId, destinationStationId: r.newDestinationStationId }
+ : { returnScheduleId: r.newScheduleId, returnOriginStationId: r.newOriginStationId, returnDestinationStationId: r.newDestinationStationId }),
+ totalMinor: newTotal,
+ displayTotalMinor: displayTotal,
+ },
+ });
+ // Two passes so the (scheduleId, seatId) unique key never collides mid-update when a
+ // passenger takes a seat another passenger of the same booking is leaving.
+ for (const s of leg.seats) {
+ await tx.bookingSeat.update({ where: { id: s.id }, data: { scheduleId: `moving-${s.id}` } });
+ }
+ for (let i = 0; i < leg.seats.length; i++) {
+ await tx.bookingSeat.update({
+ where: { id: leg.seats[i].id },
+ data: { seatId: r.newSeatIds[i], scheduleId: r.newScheduleId, fareMinor: perSeatNew[i], seatLabelSnapshot: null },
+ });
+ }
+ await tx.bookingModification.create({
+ data: {
+ bookingId: booking.id,
+ modifiedBy: r.requestedBy,
+ modificationType: 'RESCHEDULE',
+ oldData: { leg: r.leg, scheduleId: r.oldScheduleId, originStationId: r.oldOriginStationId, destinationStationId: r.oldDestinationStationId, seatIds: r.oldSeatIds, fareMinor: r.oldFareMinor },
+ newData: { leg: r.leg, scheduleId: r.newScheduleId, originStationId: r.newOriginStationId, destinationStationId: r.newDestinationStationId, seatIds: r.newSeatIds, fareMinor: r.newFareMinor, feeMinor: r.feeMinor },
+ fareAdjustment: r.fareDifferenceMinor,
+ },
+ });
+ await tx.bookingReschedule.update({ where: { id: r.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(`Reschedule ${r.id}: journey segments failed: ${err instanceof Error ? err.message : err}`);
+ }
+ // Old tickets' SYSTEM seat blocks reference ticket ids that generate() is about to delete.
+ 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(`Reschedule ${r.id}: ticket generation failed: ${err instanceof Error ? err.message : err}`);
+ }
+ }
+ // The new seats are owned by the journey now; the old seats' own booking-time hold (holds
+ // outlive confirmation until the payment deadline) would otherwise keep them HELD on the old
+ // schedule. Nobody else can hold a booked seat, so any hold there is this booking's.
+ await this.prisma.seatHold.deleteMany({
+ where: { OR: [{ id: r.holdId ?? '' }, { scheduleId: r.oldScheduleId, seatIds: { hasSome: r.oldSeatIds } }] },
+ });
+
+ await this.auditService.log({
+ userId: r.requestedBy,
+ action: AUDIT_ACTIONS.UPDATE,
+ entityType: AUDIT_ENTITIES.Booking,
+ entityId: booking.id,
+ oldData: { leg: r.leg, scheduleId: r.oldScheduleId, seatIds: r.oldSeatIds },
+ newData: { leg: r.leg, scheduleId: r.newScheduleId, seatIds: r.newSeatIds, feeMinor: r.feeMinor, fareDifferenceMinor: r.fareDifferenceMinor, rescheduleId: r.id },
+ });
+ this.eventEmitter.emit('booking.rescheduled', { booking: fresh ?? booking, reschedule: r });
+ return { ...r, status: 'APPLIED' };
+ }
+
+ /** Cron hook: unpaid reschedules past their payment deadline. The seat hold lapses by itself. */
+ async expireStale(now = new Date()): Promise {
+ const stale = await this.prisma.bookingReschedule.findMany({
+ where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
+ select: { id: true, supplementaryChargeId: true },
+ });
+ for (const r of stale) {
+ await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } });
+ if (r.supplementaryChargeId) {
+ await this.prisma.supplementaryCharge.updateMany({
+ where: { id: r.supplementaryChargeId, status: 'PENDING' },
+ data: { status: 'EXPIRED' },
+ });
+ }
+ }
+ return stale.length;
+ }
+
+ // ── Internals ────────────────────────────────────────────────────────────
+
+ /**
+ * Who may act on this booking: only 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 move it.
+ *
+ * There is deliberately no staff override. The `bookings:reschedule` permission still exists in
+ * the registry (and on the stationMaster preset) but is not honoured here, so a station master
+ * cannot reschedule on a customer's behalf yet. To restore it, re-import
+ * `hasPassengerPermission` / `PASSENGER_PERMS` and return the booking early when the caller
+ * holds `PASSENGER_PERMS.bookings.reschedule`.
+ */
+ private async loadOwnedBooking(bookingRef: string, user: ActingUser) {
+ const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: bookingInclude });
+ if (!booking) throw new NotFoundException('Booking not found');
+ const iamUserId = user.id ?? user.sub;
+ if (!iamUserId) throw new ForbiddenException();
+
+ if (booking.contactPhone) {
+ const callerPhone = await this.resolveUserPhone(iamUserId, user);
+ if (samePhone(callerPhone, booking.contactPhone)) return booking;
+ throw new ForbiddenException(
+ 'Only the person who made this booking can reschedule it. Sign in with the phone number used to book.',
+ );
+ }
+
+ // ~0.3% of bookings (72 of 24.7k on dev) 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 this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
+ if (!passenger || passenger.id !== booking.passengerId) throw new ForbiddenException('Not your booking');
+ return booking;
+ }
+
+ /**
+ * 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.
+ */
+ private async resolveUserPhone(iamUserId: string, user: ActingUser): Promise {
+ const fromSession = normalizePhone(user.phoneNumber);
+ if (fromSession) return fromSession;
+ const rows = await this.prisma.$queryRaw<{ phone_number: string | null }[]>`
+ SELECT phone_number FROM iam.users WHERE id = ${iamUserId}::uuid LIMIT 1
+ `;
+ return normalizePhone(rows[0]?.phone_number);
+ }
+
+ private legsOf(booking: any): LegView[] {
+ const legs: LegView[] = [];
+ const seatsOf = (n: number) =>
+ (booking.seats as any[])
+ .filter((s) => (s.leg ?? 1) === n)
+ .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 });
+ }
+ 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 });
+ }
+ return legs;
+ }
+
+ /** The leg's original fare: per-seat amounts when recorded, else the whole booking (one-way). */
+ private legFare(booking: any, leg: LegView): number {
+ const recorded = leg.seats.reduce((sum, s) => sum + (s.fareMinor ?? 0), 0);
+ if (recorded > 0) return recorded;
+ return booking.bookingType === 'ONE_WAY' ? booking.totalMinor : Math.round(booking.totalMinor / 2);
+ }
+
+ private legBlockers(booking: any, leg: LegView, policy: any, now = new Date()): string[] {
+ const blockers: string[] = [];
+ if (!['ONE_WAY', 'ROUND_TRIP'].includes(booking.bookingType)) blockers.push('Only one-way and round-trip bookings can be rescheduled.');
+ if (booking.status !== 'CONFIRMED') blockers.push('Only confirmed bookings can be rescheduled.');
+ // ponytail: a boarded leg can't be moved and tickets.generate() rebuilds every leg, so a
+ // 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.');
+ 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.`);
+ }
+ return blockers;
+ }
+
+ private async buildQuote(booking: any, dto: RescheduleQuoteDto) {
+ 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 policy = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId: leg.coachTypeId } });
+ const blockers = this.legBlockers(booking, leg, policy);
+
+ 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.');
+ 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.');
+
+ const schedule = await this.prisma.trainSchedule.findUnique({
+ where: { id: dto.newScheduleId },
+ include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
+ });
+ if (!schedule) throw new NotFoundException('New schedule not found');
+ const now = new Date();
+ if (schedule.departureAt <= now || schedule.status !== 'SCHEDULED') blockers.push('The selected departure is no longer bookable.');
+ if (schedule.id === leg.scheduleId && dto.newOriginStationId === leg.originStationId && dto.newDestinationStationId === leg.destinationStationId) {
+ blockers.push('Pick a different departure, route or date.');
+ }
+ const originStop = schedule.stopTimes.find((s) => s.stationId === dto.newOriginStationId);
+ const destStop = schedule.stopTimes.find((s) => s.stationId === dto.newDestinationStationId);
+ if (!originStop || !destStop || originStop.sequence >= destStop.sequence) blockers.push('Origin/destination are not valid for this schedule.');
+
+ const isRouteChange = dto.newOriginStationId !== leg.originStationId || dto.newDestinationStationId !== leg.destinationStationId;
+ if (isRouteChange && policy && !policy.routeChangeAllowed) blockers.push('Route changes are not permitted for this fare class.');
+ const isSameDay = addisDay(schedule.departureAt) === addisDay(leg.departureAt);
+ if (isSameDay && policy && !policy.sameDayAllowed) blockers.push('Same-day changes are not permitted for this fare class.');
+
+ // Keep the round trip chronologically sane.
+ if (booking.bookingType === 'ROUND_TRIP') {
+ if (legNo === 1 && booking.returnSchedule && schedule.arrivalAt >= booking.returnSchedule.departureAt) blockers.push('New outbound must arrive before the return departs.');
+ if (legNo === 2 && booking.schedule && schedule.departureAt <= booking.schedule.arrivalAt) blockers.push('New return must depart after the outbound arrives.');
+ }
+
+ // New seats: same coach type as booked (no class change in this step), priced per seat.
+ const seats = await this.prisma.seat.findMany({
+ where: { id: { in: dto.newSeatIds } },
+ include: { coach: { include: { coachType: { include: { seatClasses: { where: { isActive: true } } } } } } },
+ });
+ const seatById = new Map(seats.map((s) => [s.id, s]));
+ let newFareMinor = 0;
+ if (originStop && destStop && seats.length === dto.newSeatIds.length) {
+ const nationalityType = booking.displayCurrency === 'USD' ? 'INTERNATIONAL' : 'LOCAL';
+ // ponytail: passenger nationality isn't stored on the booking; currency is the proxy the
+ // search/fare code already uses (ETB/DJF = local, USD = international).
+ const nationality = booking.displayCurrency === 'DJF' ? 'Djiboutian' : booking.displayCurrency === 'ETB' ? 'Ethiopian' : undefined;
+ const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
+ for (let i = 0; i < dto.newSeatIds.length; i++) {
+ const seat = seatById.get(dto.newSeatIds[i])!;
+ if (seat.coach.coachTypeId !== leg.coachTypeId) { blockers.push('New seats must be in the same class as the original booking.'); break; }
+ const oldSeat = leg.seats[i];
+ if (oldSeat.fareMinor === 0) continue; // free child keeps riding free
+ const seatClass = this.pickSeatClass(seat.coach.coachType.seatClasses, seat.bedPosition, nationalityType);
+ if (!seatClass) { blockers.push('No fare is configured for the selected seat.'); break; }
+ newFareMinor += await this.bookingsService.getBaseFare(
+ schedule.id, seatClass.id, segmentRoute, undefined, nationality,
+ originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId,
+ );
+ }
+ } else if (seats.length !== dto.newSeatIds.length) {
+ blockers.push('One or more selected seats do not exist.');
+ }
+
+ const oldFareMinor = this.legFare(booking, leg);
+ const amounts = policy
+ ? computeRescheduleAmounts(policy, oldFareMinor, newFareMinor, isSameDay)
+ : { feeMinor: 0, fareDifferenceMinor: newFareMinor - oldFareMinor, amountDueMinor: 0 };
+
+ return {
+ allowed: blockers.length === 0,
+ blockers: Array.from(new Set(blockers)),
+ leg: legNo,
+ oldScheduleId: leg.scheduleId,
+ oldOriginStationId: leg.originStationId,
+ oldDestinationStationId: leg.destinationStationId,
+ oldSeatIds: leg.seats.map((s) => s.seatId),
+ newScheduleId: schedule.id,
+ newDepartureAt: schedule.departureAt,
+ isSameDay,
+ isRouteChange,
+ oldFareMinor,
+ newFareMinor,
+ ...amounts,
+ currency: 'ETB',
+ cutoffAt: policy ? new Date(leg.departureAt.getTime() - policy.cutoffMinutes * 60_000) : null,
+ policy: policy && { feePercent: policy.feePercent, feeMinMinor: policy.feeMinMinor, sameDayFeePercent: policy.sameDayFeePercent, sameDayFeeMinMinor: policy.sameDayFeeMinMinor, routeChangeAllowed: policy.routeChangeAllowed, sameDayAllowed: policy.sameDayAllowed, cutoffMinutes: policy.cutoffMinutes },
+ };
+ }
+
+ /** Mirrors SearchService's class matching: nationality filter, then bed position. */
+ private 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 the leg fare over seats, free children (fare 0) stay 0; rounding lands on the last paid seat. */
+ private splitFare(total: number, seats: LegView['seats']): 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;
+ }
+}
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
index f39da4e80..c678dcf44 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
@@ -739,9 +739,11 @@ export class SeatsService {
}
}
- // Delete the Journey (and its JourneySegments) scoped to this booking.
+ // Delete the Journey (and its JourneySegments) scoped to this booking. The segment FK has no
+ // ON DELETE CASCADE, so segments go first or the journey delete fails on a ticketed booking.
async releaseSeats(bookingId: string) {
- await this.prisma.journey.deleteMany({ where: { bookingId } as any });
+ await this.prisma.journeySegment.deleteMany({ where: { journey: { bookingId } } });
+ await this.prisma.journey.deleteMany({ where: { bookingId } });
}
async getBlockedSeats() {
diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
index fe4207a50..27a94f4e9 100644
--- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
+++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
@@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service';
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 { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
// Retention windows
@@ -510,6 +511,21 @@ export class TasksService {
// ─────────────────────────────────────────────────────────────────────────
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
// ─────────────────────────────────────────────────────────────────────────
+ // ─────────────────────────────────────────────────────────────────────────
+ // Every 1 min: reschedule requests whose payment deadline passed → EXPIRED
+ // (their supplementary charge too). The new-seat hold lapses on its own.
+ // ─────────────────────────────────────────────────────────────────────────
+ @Cron('*/1 * * * *')
+ async expireStaleReschedules() {
+ try {
+ const reschedule = await this.moduleRef.resolve(RescheduleService, undefined, { strict: false });
+ const n = await reschedule.expireStale();
+ if (n > 0) this.logger.log(`Expired ${n} unpaid reschedule request(s)`);
+ } catch (err) {
+ this.logger.error(`expireStaleReschedules failed: ${err instanceof Error ? err.message : err}`);
+ }
+ }
+
@Cron('0 2 * * *')
async purgeExpiredData() {
const now = new Date();
diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts
index 82cc62ef6..d30f37d94 100644
--- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts
+++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts
@@ -18,6 +18,7 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
perm('40f1b49c-c33d-4563-a6bb-9373eabbde9b', 'edr_passenger_app:bookings:view', 'View bookings'),
perm('62810ae5-315e-4ae5-8ed1-33cead51b95a', 'edr_passenger_app:bookings:manage', 'Manage bookings'),
perm('b593adf3-2060-48b0-b35d-ff9ff5d72bc4', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'),
+ perm('0c5e7a2b-9d41-4f7e-8b36-2a1c6d9e4f50', 'edr_passenger_app:bookings:reschedule', 'Reschedule bookings'),
perm('566c968f-71f1-462d-9824-4b7cd33cecbb', 'edr_passenger_app:passengers:view', 'View passengers'),
perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'),
perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'),
@@ -77,6 +78,7 @@ export const PASSENGER_PERMS = {
view: 'edr_passenger_app:bookings:view',
manage: 'edr_passenger_app:bookings:manage',
cancel: 'edr_passenger_app:bookings:cancel',
+ reschedule: 'edr_passenger_app:bookings:reschedule',
},
passengers: {
view: 'edr_passenger_app:passengers:view',
@@ -181,6 +183,7 @@ export const ROLE_PERMISSION_PRESETS = {
stationMaster: [
PASSENGER_PERMS.bookings.view,
PASSENGER_PERMS.bookings.manage,
+ PASSENGER_PERMS.bookings.reschedule,
PASSENGER_PERMS.tickets.view,
PASSENGER_PERMS.tickets.manage,
PASSENGER_PERMS.tickets.generate,
diff --git a/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/layout.tsx
new file mode 100644
index 000000000..8e7a60fce
--- /dev/null
+++ b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/layout.tsx
@@ -0,0 +1,5 @@
+import DashboardLayout from '../dashboard/layout';
+
+export default function ReschedulePoliciesLayout({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
diff --git a/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/page.tsx
new file mode 100644
index 000000000..31f265b6c
--- /dev/null
+++ b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/page.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import ReschedulePolicyManager from '@/components/reschedule/ReschedulePolicyManager';
+import { PermissionGuard } from '@/components/layout/PermissionGuard';
+import { PERMS } from '@/lib/permissions';
+
+/**
+ * Master Data → Reschedule Policies. One policy per fare class (coach type); a class with no
+ * policy cannot be rescheduled at all. Gated on bookings:view because that is what
+ * `GET /reschedule/policies` requires; creating, editing and deleting are admin-only server-side.
+ */
+export default function ReschedulePoliciesPage() {
+ return (
+
+
+
+
Reschedule Policies
+
+ Rules that decide whether a booked journey can be moved, and what the change costs
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx
index 873e81578..90725b683 100644
--- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx
@@ -111,6 +111,7 @@ export default function SettingsPage() {
)}
+
{activeTab === 'configurations' && (
Rate Limiting (requests / minute / IP)
diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx
index 909361c46..99c24be9a 100644
--- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx
+++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx
@@ -28,6 +28,7 @@ import {
FileText,
Briefcase,
Calendar,
+ CalendarClock,
Utensils,
Package,
Moon,
@@ -90,6 +91,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
{ 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 },
]
},
{
diff --git a/apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx b/apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx
new file mode 100644
index 000000000..efc1ca5d2
--- /dev/null
+++ b/apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx
@@ -0,0 +1,376 @@
+'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 {
+ reschedulePolicyApi,
+ type ReschedulePolicyCoachType,
+ type ReschedulePolicyRow,
+ type ReschedulePolicyValues,
+} from '@/lib/api';
+
+const EMPTY_POLICY: ReschedulePolicyValues = {
+ feePercent: 0,
+ feeMinMinor: 0,
+ routeChangeAllowed: true,
+ sameDayAllowed: true,
+ sameDayFeePercent: 0,
+ sameDayFeeMinMinor: 0,
+ cutoffMinutes: 60,
+ 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 = (percent: number, minMinor: number) =>
+ percent > 0 || minMinor > 0 ? `${percent}% · min ETB ${etb(minMinor)}` : 'Free';
+
+/**
+ * Policy §3 - one policy per fare class (coach type), listed as a table and edited in a dialog,
+ * the same shape as Coach Management. A fare class with no row here cannot be rescheduled at all.
+ */
+export default function ReschedulePolicyManager() {
+ const [rows, setRows] = useState
([]);
+ const [available, setAvailable] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [message, setMessage] = useState('');
+
+ const [showModal, setShowModal] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [coachTypeId, setCoachTypeId] = useState('');
+ const [form, setForm] = useState(EMPTY_POLICY);
+ const [saving, setSaving] = useState(false);
+ const [formError, setFormError] = useState('');
+
+ const [deleting, setDeleting] = useState(null);
+ const [deleteBusy, setDeleteBusy] = useState(false);
+
+ const load = async () => {
+ setLoading(true);
+ try {
+ const [policies, coachTypes] = await Promise.all([
+ reschedulePolicyApi.list(),
+ reschedulePolicyApi.availableCoachTypes(),
+ ]);
+ setRows(Array.isArray(policies) ? policies : []);
+ setAvailable(Array.isArray(coachTypes) ? coachTypes : []);
+ } catch {
+ setMessage('Failed to load reschedule policies.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ void load();
+ }, []);
+
+ const openCreate = () => {
+ setEditing(null);
+ setCoachTypeId('');
+ setForm(EMPTY_POLICY);
+ setFormError('');
+ setShowModal(true);
+ };
+
+ const openEdit = (row: ReschedulePolicyRow) => {
+ setEditing(row);
+ setCoachTypeId(row.coachTypeId);
+ setForm({
+ feePercent: row.feePercent,
+ feeMinMinor: row.feeMinMinor,
+ routeChangeAllowed: row.routeChangeAllowed,
+ sameDayAllowed: row.sameDayAllowed,
+ sameDayFeePercent: row.sameDayFeePercent,
+ sameDayFeeMinMinor: row.sameDayFeeMinMinor,
+ cutoffMinutes: row.cutoffMinutes,
+ isActive: row.isActive,
+ });
+ setFormError('');
+ setShowModal(true);
+ };
+
+ const setField = (patch: Partial) => setForm((f) => ({ ...f, ...patch }));
+
+ const submit = async () => {
+ if (!editing && !coachTypeId) {
+ setFormError('Pick a fare class.');
+ return;
+ }
+ setSaving(true);
+ setFormError('');
+ try {
+ if (editing) await reschedulePolicyApi.update(editing.coachTypeId, form);
+ else await reschedulePolicyApi.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 reschedulePolicyApi.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: ReschedulePolicyRow) => (
+
+ {row.coachType?.code}
+ - {row.coachType?.name}
+
+ ),
+ },
+ {
+ key: 'fee',
+ label: 'Change fee',
+ render: (row: ReschedulePolicyRow) => (
+ {feeLabel(row.feePercent, row.feeMinMinor)}
+ ),
+ },
+ {
+ key: 'routeChangeAllowed',
+ label: 'Route change',
+ render: (row: ReschedulePolicyRow) => (
+
+ {row.routeChangeAllowed ? 'Allowed' : 'Not permitted'}
+
+ ),
+ },
+ {
+ key: 'sameDay',
+ label: 'Same-day change',
+ render: (row: ReschedulePolicyRow) =>
+ row.sameDayAllowed ? (
+ {feeLabel(row.sameDayFeePercent, row.sameDayFeeMinMinor)}
+ ) : (
+ Not permitted
+ ),
+ },
+ {
+ key: 'cutoffMinutes',
+ label: 'Cutoff',
+ render: (row: ReschedulePolicyRow) => (
+ {row.cutoffMinutes} min
+ ),
+ },
+ {
+ key: 'isActive',
+ label: 'Status',
+ render: (row: ReschedulePolicyRow) => (
+
+ {row.isActive ? 'Active' : 'Disabled'}
+
+ ),
+ },
+ ];
+
+ const actions = [
+ { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
+ {
+ label: 'Delete',
+ onClick: (row: ReschedulePolicyRow) => setDeleting(row),
+ variant: 'danger' as const,
+ icon: Trash2,
+ },
+ ];
+
+ return (
+
+
+ {/* The page supplies the title; this is the rule-of-thumb the table's numbers mean. */}
+
+ Fee = max(fee % × original leg fare, minimum). A higher new fare is always charged on top; a lower one is
+ not refunded. Same-day = new departure on the same calendar day as the original. A fare class with no
+ policy here cannot be rescheduled at all.
+
+
+ Add Reschedule Policy
+
+
+
+ {!loading && available.length === 0 && (
+
Every fare class already has a policy.
+ )}
+ {message &&
{message}
}
+
+
+
+
setShowModal(false)}
+ title={editing ? `Edit Reschedule Policy - ${editing.coachType?.code}` : 'Add Reschedule Policy'}
+ size="lg"
+ >
+
+
+
Fare class
+ {editing ? (
+ <>
+
+ {/* One policy per fare class, so editing never re-points a row at another class. */}
+
A policy stays attached to its fare class.
+ >
+ ) : (
+
setCoachTypeId(e.target.value)}>
+ Select a fare class...
+ {available.map((ct) => (
+
+ {ct.code} - {ct.name}
+
+ ))}
+
+ )}
+
+
+
+
+ {formError &&
{formError}
}
+
+
+
setShowModal(false)}>
+ Cancel
+
+
+ {editing ? 'Update Policy' : 'Create Policy'}
+
+
+
+
+
+
setDeleting(null)}
+ onConfirm={confirmDelete}
+ title="Delete reschedule policy"
+ message={`Delete the reschedule policy for ${deleting?.coachType?.code ?? ''}?`}
+ warning="Passengers on this fare class will no longer be able to reschedule. Bookings already rescheduled are unaffected."
+ confirmText="Delete"
+ isDanger
+ isLoading={deleteBusy}
+ />
+
+ );
+}
diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts
index cede0b966..f444adda8 100644
--- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts
+++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts
@@ -542,6 +542,40 @@ export const systemConfigApi = {
update: (data: Record) => apiClient.patch>('/config', data),
};
+// Reschedule Policy API — one policy per coach type (fare class). A coach type with no policy
+// simply has no row, and rescheduling is refused for it.
+export interface ReschedulePolicyValues {
+ feePercent: number;
+ feeMinMinor: number;
+ routeChangeAllowed: boolean;
+ sameDayAllowed: boolean;
+ sameDayFeePercent: number;
+ sameDayFeeMinMinor: number;
+ cutoffMinutes: number;
+ isActive: boolean;
+}
+export interface ReschedulePolicyCoachType {
+ id: string;
+ code: string;
+ name: string;
+ type: string;
+}
+export interface ReschedulePolicyRow extends ReschedulePolicyValues {
+ id: string;
+ coachTypeId: string;
+ coachType: ReschedulePolicyCoachType;
+}
+export const reschedulePolicyApi = {
+ list: () => apiClient.get('/reschedule/policies'),
+ availableCoachTypes: () =>
+ apiClient.get('/reschedule/policies/available-coach-types'),
+ create: (data: ReschedulePolicyValues & { coachTypeId: string }) =>
+ apiClient.post('/reschedule/policies', data),
+ update: (coachTypeId: string, data: Partial) =>
+ apiClient.patch(`/reschedule/policies/${coachTypeId}`, data),
+ remove: (coachTypeId: string) => apiClient.delete(`/reschedule/policies/${coachTypeId}`),
+};
+
// App Releases API
export const appReleasesApi = {
getAll: async () => {
diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
index d42ab0dc3..ab3a6376f 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
@@ -4,6 +4,8 @@ import { Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
+import { useAuthStore } from "@/lib/auth-store";
+import { samePhone } from "@/utils/phone";
import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
import { useEffect, useState } from "react";
import {
@@ -78,6 +80,13 @@ function BookingDetailContent() {
searchParams.get("bookingRef") ||
searchParams.get("pnr");
+ // `isInitialized` gates on the auth store having read localStorage. Without it a signed-in
+ // user watches the Reschedule button appear a beat after the page, because the store starts
+ // every render as logged-out. AppSidebar calls initialize() from the root layout.
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const isAuthInitialized = useAuthStore((s) => s.isInitialized);
+ const currentUserPhone = useAuthStore((s) => s.user?.phone);
+
// Mirrors /booking/payment's state shape: selectedMethod is the PaymentMethod `type`
// (used both for lookup and to decide provider-specific redirect handling), not the id.
const [selectedMethod, setSelectedMethod] = useState(null);
@@ -329,6 +338,20 @@ function BookingDetailContent() {
const isExpired = booking.status === "EXPIRED";
const isCancelled = booking.status === "CANCELLED";
+ // Whether this booking is the kind that can be rescheduled at all. The per-leg rules
+ // (fare-class policy, cutoff, already-boarded) are the API's call and are shown on the
+ // reschedule page itself; this is only the coarse shape test.
+ const bookingSupportsReschedule =
+ !booking.isPackageBooking &&
+ ["ONE_WAY", "ROUND_TRIP"].includes(booking.bookingType) &&
+ !booking.outboundBoardedAt;
+
+ const isBooker = samePhone(currentUserPhone, booking.contactPhone);
+
+ const canReschedule = isAuthenticated && (isBooker || !booking.contactPhone);
+
+ const reschedulePath = `/booking/reschedule?ref=${booking.bookingRef}`;
+
const StatusBadge = () => {
const statusConfig = {
PENDING_PAYMENT: {
@@ -1022,6 +1045,31 @@ function BookingDetailContent() {
>
)}
+ {/* Rescheduling is account-only: every /bookings/:ref/reschedule route sits behind
+ JwtGuard and resolves ownership from the signed-in IAM user. A guest who got
+ here through booking lookup (ref + phone) has no session, so instead of hiding
+ the option we name the blocker and send them somewhere that fixes it. */}
+ {bookingSupportsReschedule && (!isAuthInitialized || !isAuthenticated || canReschedule) && (
+
+ router.push(
+ isAuthenticated
+ ? reschedulePath
+ : `/login?redirect=${encodeURIComponent(reschedulePath)}`,
+ )
+ }
+ title={
+ isAuthenticated
+ ? "Change the date, train or seats on this booking"
+ : "Rescheduling 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"
+ >
+
+ {isAuthInitialized && !isAuthenticated ? "Sign in to reschedule" : "Reschedule"}
+
+ )}
>
)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx
new file mode 100644
index 000000000..816d2547d
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx
@@ -0,0 +1,772 @@
+"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, ArrowRight, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react";
+import { apiClient } from "@/lib/api-client";
+import { useAuthStore } from "@/lib/auth-store";
+import ModernDatePicker from "@/components/ModernDatePicker";
+import StationDropdown, {
+ pushRecentStation,
+ readRecentStationIds,
+} from "@/components/StationDropdown";
+import SeatMap, { buildSeatLabel, getValidSeatsForCoach } from "@/components/SeatMap";
+import { formatTime } from "@/utils/format";
+import { Station } from "@/types";
+
+// Same horizon the search widget uses — /search/available-dates is server-clamped to 90 days,
+// so the picker's maxDate has to match or unchecked future months render as pickable again.
+const AVAILABLE_DATES_RANGE_DAYS = 90;
+
+const toDateStr = (d: Date) =>
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+
+interface AvailableDatesResponse {
+ routeExists: boolean;
+ dates: { date: string; available: boolean }[];
+}
+
+type LegOption = {
+ leg: number;
+ scheduleId: string;
+ originStationId: string | null;
+ destinationStationId: string | null;
+ departureAt: string;
+ coachTypeId: string;
+ seatCount: number;
+ passengerNames: string[];
+ oldFareMinor: number;
+ policy: {
+ feePercent: number;
+ feeMinMinor: number;
+ routeChangeAllowed: boolean;
+ sameDayAllowed: boolean;
+ sameDayFeePercent: number;
+ sameDayFeeMinMinor: number;
+ cutoffMinutes: number;
+ } | null;
+ canReschedule: boolean;
+ blockers: string[];
+};
+
+type Options = {
+ bookingRef: string;
+ bookingType: string;
+ legs: LegOption[];
+ pending: { id: string; amountDueMinor: number; paymentToken: string | null; expiresAt: string | null } | null;
+};
+
+type Quote = {
+ allowed: boolean;
+ blockers: string[];
+ oldFareMinor: number;
+ newFareMinor: number;
+ feeMinor: number;
+ fareDifferenceMinor: number;
+ amountDueMinor: number;
+ isSameDay: boolean;
+ isRouteChange: boolean;
+};
+
+const etb = (minor: number) => `ETB ${(minor / 100).toFixed(2)}`;
+
+/** "Sat 29 Aug, 21:00" — the summary's departure line. Null when the value isn't a real date. */
+const formatDepartureDay = (value: string | Date | undefined | null): string | null => {
+ if (!value) return null;
+ const d = value instanceof Date ? value : new Date(value);
+ return isNaN(d.getTime()) ? null : format(d, "EEE dd MMM, HH:mm");
+};
+
+function ReschedulePageContent() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const ref = searchParams.get("ref") || "";
+
+ // Every endpoint this page calls is behind JwtGuard, so a guest who deep-links here would
+ // otherwise watch the options request 401 and land on "This booking cannot be rescheduled" —
+ // which blames the booking for what is really a missing session. Send them to sign in and
+ // bring them straight back instead. 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/reschedule?ref=${ref}` : "/booking/lookup";
+ router.replace(`/login?redirect=${encodeURIComponent(back)}`);
+ }, [needsLogin, ref, router]);
+
+ const [legNo, setLegNo] = useState(1);
+ const [date, setDate] = useState(undefined);
+ const [originId, setOriginId] = useState("");
+ const [destinationId, setDestinationId] = useState("");
+ const [searched, setSearched] = useState<{ originId: string; destinationId: string; date: string } | null>(null);
+ const [schedule, setSchedule] = useState(null);
+ // Seat per passenger, keyed by index into leg.passengerNames. The API pairs newSeatIds[i]
+ // with the i-th BookingSeat in that same order, so an explicit map is the only way the
+ // right passenger keeps the right fare (a free child must not inherit an adult's seat).
+ const [passengerSeatMap, setPassengerSeatMap] = useState>({});
+ const [activePassengerIndex, setActivePassengerIndex] = useState(0);
+ const [selectedCoach, setSelectedCoach] = useState(null);
+ const [done, setDone] = useState<{ status: string } | null>(null);
+ const [error, setError] = useState(null);
+ const [dateNotice, setDateNotice] = useState(null);
+ const [recentStationIds, setRecentStationIds] = useState(
+ readRecentStationIds,
+ );
+ const saveRecent = (id: string) =>
+ setRecentStationIds((prev) => pushRecentStation(id, prev));
+
+ const resetSeats = () => {
+ setPassengerSeatMap({});
+ setActivePassengerIndex(0);
+ setSelectedCoach(null);
+ };
+
+ const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery({
+ queryKey: ["reschedule-options", ref],
+ queryFn: () => apiClient.get(`/bookings/${ref}/reschedule`),
+ // Never fire before the session is known — an unauthenticated call only 401s.
+ enabled: !!ref && isAuthInitialized && isAuthenticated,
+ retry: false,
+ });
+ const { data: stations = [] } = useQuery({
+ queryKey: ["stations"],
+ queryFn: () => apiClient.get("/stations"),
+ });
+
+ const leg = useMemo(() => options?.legs.find((l) => l.leg === legNo) ?? options?.legs[0], [options, legNo]);
+
+ // Prefill route from the leg being changed.
+ useEffect(() => {
+ if (!leg) return;
+ setOriginId(leg.originStationId ?? "");
+ setDestinationId(leg.destinationStationId ?? "");
+ setSchedule(null);
+ resetSeats();
+ setSearched(null);
+ }, [leg?.leg, leg?.scheduleId]);
+
+ const journeyDirection = leg?.leg === 2 ? "RETURN" : options?.bookingType === "ROUND_TRIP" ? "OUTBOUND" : "ONE_WAY";
+
+ // Which dates actually have a bookable train for the chosen From/To. Same endpoint and same
+ // shape the home-page search widget uses, so the reschedule calendar greys out the same days
+ // rather than letting someone pick a date that can only come back empty.
+ const { data: availableDates } = useQuery({
+ queryKey: ["available-dates", originId, destinationId],
+ queryFn: async () => {
+ const from = new Date();
+ const to = new Date();
+ to.setDate(to.getDate() + AVAILABLE_DATES_RANGE_DAYS);
+ return (await apiClient.get("/search/available-dates", {
+ params: {
+ originStationId: originId,
+ destinationStationId: destinationId,
+ from: toDateStr(from),
+ to: toDateStr(to),
+ },
+ })) as AvailableDatesResponse;
+ },
+ enabled: !!originId && !!destinationId && originId !== destinationId,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ const disabledDates = useMemo(() => {
+ const set = new Set();
+ // routeExists === false is handled by disabling the control outright (noRouteForPair):
+ // the server returns an empty `dates` array in that case anyway.
+ if (!availableDates?.routeExists) return set;
+ for (const d of availableDates.dates) if (!d.available) set.add(d.date);
+ return set;
+ }, [availableDates]);
+
+ const noRouteForPair =
+ !!originId && !!destinationId && availableDates?.routeExists === false;
+
+ const maxSearchDate = useMemo(() => {
+ const d = new Date();
+ d.setDate(d.getDate() + AVAILABLE_DATES_RANGE_DAYS);
+ return d;
+ }, []);
+
+ // If the picked date turns out to have no train (stations changed, or the availability query
+ // just resolved), drop it and say why — rather than letting "Find trains" return nothing.
+ useEffect(() => {
+ if (date && disabledDates.has(toDateStr(date))) {
+ setDate(undefined);
+ setSearched(null);
+ setSchedule(null);
+ resetSeats();
+ setDateNotice("No trains run this route on that date — please pick another.");
+ }
+ }, [date, disabledDates]);
+
+ // Losing the route invalidates any date already chosen, so nothing stale can be submitted
+ // from behind a now-disabled control.
+ useEffect(() => {
+ if (noRouteForPair && date) {
+ setDate(undefined);
+ setSearched(null);
+ setSchedule(null);
+ resetSeats();
+ }
+ }, [noRouteForPair, date]);
+
+ const { data: schedules = [], isFetching: searching } = useQuery({
+ queryKey: ["reschedule-search", searched],
+ queryFn: async () => {
+ const res: any = await apiClient.post("/search", {
+ originStationId: searched!.originId,
+ destinationStationId: searched!.destinationId,
+ date: searched!.date,
+ adultCount: leg?.seatCount ?? 1,
+ childCount: 0,
+ journeyType: "ONE_WAY",
+ });
+ const list = Array.isArray(res) ? res : res?.outbound ?? res?.data ?? [];
+ return list.filter((s: any) => (s.scheduleId || s.id) !== leg?.scheduleId || searched!.originId !== leg?.originStationId || searched!.destinationId !== leg?.destinationStationId);
+ },
+ enabled: !!searched && !!leg,
+ });
+
+ const scheduleId = schedule ? schedule.scheduleId || schedule.id : null;
+ const { data: seatMap, isLoading: loadingSeats } = useQuery({
+ queryKey: ["reschedule-seatmap", scheduleId, leg?.coachTypeId, originId, destinationId],
+ queryFn: async () => {
+ const res: any = await apiClient.get(
+ `/seats/seatmap/${scheduleId}?coachTypeId=${leg!.coachTypeId}&journeyDirection=${journeyDirection}&originStationId=${originId}&destinationStationId=${destinationId}`,
+ );
+ return res?.data || res;
+ },
+ enabled: !!scheduleId && !!leg,
+ });
+
+ const coaches: any[] = useMemo(() => seatMap?.coaches ?? [], [seatMap]);
+
+ // Expand the first coach automatically — with one coach of the booked class on most trains,
+ // making the user open it before any seat is visible is a click for nothing. Fires once per
+ // schedule: keying it on `selectedCoach` instead would re-open the coach the moment the user
+ // collapsed it, since collapsing sets selectedCoach back to null.
+ const autoExpandedFor = useRef(null);
+ useEffect(() => {
+ if (!scheduleId || coaches.length === 0) return;
+ if (autoExpandedFor.current === scheduleId) return;
+ autoExpandedFor.current = scheduleId;
+ setSelectedCoach(coaches[0].id);
+ }, [scheduleId, coaches]);
+
+ // newSeatIds must line up with the leg's BookingSeats, which the API orders by passenger
+ // name — the same order `passengerNames` arrives in. Indexing by passenger builds that
+ // order by construction, so there is nothing for a click sequence to get wrong.
+ const orderedSeatIds = useMemo(
+ () => Array.from({ length: leg?.seatCount ?? 0 }, (_, i) => passengerSeatMap[i]).filter(Boolean) as string[],
+ [passengerSeatMap, leg?.seatCount],
+ );
+ const allSeatsChosen = !!leg && orderedSeatIds.length === leg.seatCount;
+
+ const isSeatSelected = (seatId: string) => passengerSeatMap[activePassengerIndex] === seatId;
+ const isSeatAssignedToOther = (seatId: string) =>
+ Object.entries(passengerSeatMap).some(
+ ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId,
+ );
+
+ const handleSeatToggle = (seatId: string) => {
+ if (isSeatAssignedToOther(seatId)) return;
+ setPassengerSeatMap((prev) => {
+ const next = { ...prev };
+ if (next[activePassengerIndex] === seatId) {
+ delete next[activePassengerIndex];
+ return next;
+ }
+ next[activePassengerIndex] = seatId;
+ // Move to the next passenger still without a seat so a multi-passenger leg can be
+ // filled by clicking straight down the coach.
+ const total = leg?.seatCount ?? 1;
+ const nextUnassigned = Array.from({ length: total }, (_, i) => i).find((i) => !next[i]);
+ if (nextUnassigned !== undefined) setActivePassengerIndex(nextUnassigned);
+ return next;
+ });
+ };
+
+ // No skipping ahead of a passenger who still needs a seat — same rule as /booking/seats.
+ const firstUnassignedIndex = Array.from({ length: leg?.seatCount ?? 0 }, (_, i) => i).find(
+ (i) => !passengerSeatMap[i],
+ );
+ const maxSelectableIndex =
+ firstUnassignedIndex === undefined ? (leg?.seatCount ?? 1) - 1 : firstUnassignedIndex;
+
+ const quoteBody = leg && scheduleId && allSeatsChosen
+ ? { leg: leg.leg, newScheduleId: scheduleId, newOriginStationId: originId, newDestinationStationId: destinationId, newSeatIds: orderedSeatIds }
+ : null;
+ const { data: quote, isFetching: quoting } = useQuery({
+ queryKey: ["reschedule-quote", ref, quoteBody],
+ queryFn: () => apiClient.post(`/bookings/${ref}/reschedule/quote`, quoteBody),
+ enabled: !!quoteBody,
+ });
+
+ const confirm = useMutation({
+ mutationFn: async () => {
+ const hold: any = await apiClient.post("/seats/hold", {
+ scheduleId,
+ originStationId: originId,
+ destinationStationId: destinationId,
+ journeyDirection,
+ passengers: orderedSeatIds.map((seatId, i) => ({ passengerId: `reschedule-${ref}-${i}`, seatId })),
+ });
+ return apiClient.post(`/bookings/${ref}/reschedule`, { ...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 reschedule"),
+ });
+
+ const stationName = (id: string | null) => stations.find((s) => s.id === id)?.name ?? id ?? "—";
+
+ if (!ref) return Missing booking reference.
;
+ // Hold the spinner through the redirect rather than flashing the booking's error state.
+ if (!isAuthInitialized || needsLogin) {
+ return ;
+ }
+ if (loadingOptions) return ;
+ if (optionsError || !options || !leg) {
+ return {(optionsError as any)?.response?.data?.message || "This booking cannot be rescheduled."}
;
+ }
+
+ if (done) {
+ return (
+
+
+
+
Booking rescheduled
+
New tickets have been issued for booking {ref}.
+
router.push(`/booking/detail?ref=${ref}`)}>View booking
+
+
+ );
+ }
+
+ if (options.pending) {
+ return (
+
+
+
Reschedule awaiting payment
+
+ A change 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.
+
+ {options.pending.paymentToken && (
+
router.push(`/pay-balance/${options.pending!.paymentToken}`)}>Pay now
+ )}
+
+
+ );
+ }
+
+ const routeLocked = !leg.policy?.routeChangeAllowed;
+
+ // Short label/value pairs rather than prose — the rules are scanned, not read.
+ const fareRules = leg.policy
+ ? [
+ {
+ label: "Change fee",
+ value:
+ leg.policy.feePercent > 0 || leg.policy.feeMinMinor > 0
+ ? `${leg.policy.feePercent}% of fare (min ${etb(leg.policy.feeMinMinor)})`
+ : "Free",
+ },
+ { label: "Route change", value: leg.policy.routeChangeAllowed ? "Allowed" : "Not permitted" },
+ {
+ label: "Same-day change",
+ value: !leg.policy.sameDayAllowed
+ ? "Not permitted"
+ : leg.policy.sameDayFeePercent > 0 || leg.policy.sameDayFeeMinMinor > 0
+ ? `${leg.policy.sameDayFeePercent}% (min ${etb(leg.policy.sameDayFeeMinMinor)})`
+ : "Free",
+ },
+ { label: "Changes close", value: `${leg.policy.cutoffMinutes} min before departure` },
+ { label: "Higher new fare", value: "Payable" },
+ { label: "Lower new fare", value: "Not refunded" },
+ ]
+ : [];
+
+ const canSearch =
+ !!date && !!originId && !!destinationId && originId !== destinationId && !noRouteForPair;
+
+ // Mirrors the booking flow's FareSidebar: a sticky money card that is present from the
+ // start and fills in as choices are made, rather than a total that appears at the end.
+ // Rendered twice — inline under the content on mobile, sticky beside it on desktop.
+ const ChangeSummary = () => (
+
+
+ Change summary
+
+
+
+
Currently
+
+ {formatDepartureDay(leg.departureAt)}
+
+
+ {stationName(leg.originStationId)} → {stationName(leg.destinationStationId)}
+
+
+
+ {schedule && (
+
+
Changing to
+ {/* Same date+time line as "Currently" above, so the two are read side by side. */}
+
+ {formatDepartureDay(schedule.departureAt) ?? formatTime(schedule.departureAt)}
+
+
+ {schedule.trainName || schedule.trainNumber} · arrives {formatTime(schedule.arrivalAt)}
+
+
+ {stationName(originId)} → {stationName(destinationId)}
+
+
+ )}
+
+
+ {leg.passengerNames.map((name, i) => {
+ const seatId = passengerSeatMap[i];
+ const seat = seatId
+ ? coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === seatId)
+ : null;
+ return (
+
+ {name}
+
+ {seat ? `Seat ${buildSeatLabel(seat)}` : "—"}
+
+
+ );
+ })}
+
+
+ {!quoteBody ? (
+
+ Pick a new train and a seat for {leg.seatCount > 1 ? "every passenger" : "the passenger"} to see what this change costs.
+
+ ) : quoting ? (
+
+
+
+ ) : quote ? (
+
+
|
+
|
+
= 0 ? "Fare difference" : "Fare difference (not refunded)"}
+ value={etb(Math.max(0, quote.fareDifferenceMinor))}
+ />
+
+
+ Total due now
+ {etb(quote.amountDueMinor)}
+
+ {quote.blockers.length > 0 && (
+
+
+
{quote.blockers.map((b) =>
{b}
)}
+
+ )}
+ {error && {error}
}
+ { setError(null); confirm.mutate(); }}
+ >
+ {confirm.isPending
+ ? "Processing..."
+ : quote.amountDueMinor > 0
+ ? `Continue to payment · ${etb(quote.amountDueMinor)}`
+ : "Confirm reschedule"}
+
+
+ ) : null}
+
+ );
+
+ return (
+
+ router.push(`/booking/detail?ref=${ref}`)} className="flex items-center gap-1 text-sm text-gray-500 mb-4">
+ Back to booking
+
+ Reschedule {ref}
+
+ {leg.passengerNames.join(", ")} · currently {format(new Date(leg.departureAt), "EEE dd MMM, HH:mm")} · {stationName(leg.originStationId)} → {stationName(leg.destinationStationId)}
+
+
+ {options.legs.length > 1 && (
+
+ {options.legs.map((l) => (
+ 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")}
+
+ ))}
+
+ )}
+
+ {/* No `items-start` here on purpose: it shrinks each column to its own content height, and a
+ sticky child can only travel inside its containing block — so the summary would scroll
+ away like a normal card. The grid default (stretch) gives the right column the full row
+ height to stick within. */}
+
+ {/* Left column — the choices */}
+
+
+ {fareRules.length > 0 && (
+
+
Your fare rules
+
+ {fareRules.map((rule) => (
+
+
+
+ {rule.label}:{" "}
+ {rule.value}
+
+
+ ))}
+
+
+ )}
+
+ {!leg.canReschedule && (
+
+
+
{leg.blockers.map((b) =>
{b}
)}
+
+ )}
+
+ {leg.canReschedule && (
+ <>
+ {/* Step 1: route + date */}
+
+ {
+ setOriginId(s.id);
+ if (s.id) saveRecent(s.id);
+ setDateNotice(null);
+ setSchedule(null);
+ resetSeats();
+ setSearched(null);
+ }}
+ />
+ {
+ setDestinationId(s.id);
+ if (s.id) saveRecent(s.id);
+ setDateNotice(null);
+ setSchedule(null);
+ resetSeats();
+ setSearched(null);
+ }}
+ />
+ {
+ setDateNotice(null);
+ setDate(d);
+ }}
+ minDate={new Date()}
+ maxDate={originId && destinationId ? maxSearchDate : undefined}
+ disabledDates={disabledDates}
+ disabled={noRouteForPair}
+ placeholder="New date"
+ />
+ { setSchedule(null); resetSeats(); setSearched({ originId, destinationId, date: format(date!, "yyyy-MM-dd") }); }}>
+ {searching ? "Searching..." : "Find trains"}
+
+
+ {routeLocked && (
+
+ Your fare class does not permit changing stations — only the date and train.
+
+ )}
+ {noRouteForPair && (
+
+ No route connects these stations — pick a different destination.
+
+ )}
+ {dateNotice && !noRouteForPair && (
+
{dateNotice}
+ )}
+
+ {/* Step 2: schedule */}
+ {searched && !searching && schedules.length === 0 &&
No trains on that day.
}
+ {schedules.length > 0 && (
+
+ {schedules.map((s: any) => {
+ const id = s.scheduleId || s.id;
+ const selected = scheduleId === id;
+ return (
+
{ setSchedule(s); resetSeats(); }}
+ className={`w-full text-left rounded-xl border p-4 flex items-center justify-between ${selected ? "border-primary bg-primary/5" : "border-gray-200 dark:border-gray-700"} disabled:opacity-50`}>
+
+
{s.trainName || s.trainNumber}
+
{formatTime(s.departureAt)}
{formatTime(s.arrivalAt)}
+
+ {s.hasAvailability === false ? "Sold out" : "Select"}
+
+ );
+ })}
+
+ )}
+
+ {/* Step 3: seats (same class as booked) */}
+ {scheduleId && (
+
+
+ Pick {leg.seatCount} seat{leg.seatCount > 1 ? "s" : ""} ({orderedSeatIds.length}/{leg.seatCount})
+
+
+ {allSeatsChosen
+ ? "Every passenger has a seat."
+ : `Choosing a seat for ${leg.passengerNames[activePassengerIndex] ?? `Passenger ${activePassengerIndex + 1}`}.`}
+
+
+ {/* Who gets which seat. The API pairs seats to passengers by position, so this
+ mapping is the payload — not a display convenience. */}
+
+ {leg.passengerNames.map((name, i) => {
+ const assignedSeatId = passengerSeatMap[i];
+ const assignedSeat = assignedSeatId
+ ? coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === assignedSeatId)
+ : null;
+ const isActive = i === activePassengerIndex;
+ const isClickable = i <= maxSelectableIndex;
+ return (
+
isClickable && setActivePassengerIndex(i)}
+ disabled={!isClickable}
+ 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" : ""
+ } ${
+ isClickable
+ ? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/60"
+ : "cursor-not-allowed opacity-50"
+ }`}
+ >
+
+
+ {i + 1}
+
+
+
+ {name}
+
+ {isActive && !assignedSeat && (
+
+ Now selecting
+
+ )}
+
+
+
+ {assignedSeat ? `Seat ${buildSeatLabel(assignedSeat)}` : "Not Assigned"}
+
+
+ );
+ })}
+
+
+ {loadingSeats ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ >
+ )}
+
{/* end left card */}
+
+ {/* Mobile: the summary sits under the choices instead of beside them */}
+
+
+
+
{/* end left column */}
+
+ {/* Right column — sticky change summary (desktop only) */}
+
+ {/* Caps at the viewport and scrolls inside itself, so a long passenger list can't push
+ the total and the confirm button off the bottom of the screen. */}
+
+
+
+
+
+
+ );
+}
+
+function Row({ label, value }: { label: string; value: string }) {
+ return {label} {value}
;
+}
+
+// `wide` switches to the booking flow's two-column width and hands card styling to the
+// columns themselves; the narrow single-card form still carries the loading/error states.
+function Shell({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) {
+ return (
+
+
+ {wide ? (
+
{children}
+ ) : (
+
+ {children}
+
+ )}
+
+
+ );
+}
+
+export default function ReschedulePage() {
+ return (
+ }>
+
+
+ );
+}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
index bb4e2cf12..3b6a17bb8 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
@@ -23,6 +23,10 @@ import {
} from "lucide-react";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import ModernDatePicker from "@/components/ModernDatePicker";
+import StationDropdown, {
+ pushRecentStation,
+ readRecentStationIds,
+} from "@/components/StationDropdown";
const AVAILABLE_DATES_RANGE_DAYS = 90;
@@ -393,163 +397,6 @@ function PassengerModal({
);
}
-// ─── Station Autocomplete (Desktop dropdown) ──────────────────────────────────
-function StationDropdown({
- stations,
- value,
- excludeId,
- placeholder,
- onSelect,
- error,
- recentIds,
- onOpen,
-}: {
- stations: Station[];
- value: string;
- excludeId?: string;
- placeholder: string;
- onSelect: (s: Station) => void;
- error?: string;
- recentIds: string[];
- onOpen?: () => void;
-}) {
- const [query, setQuery] = useState("");
- const [open, setOpen] = useState(false);
- const ref = useRef(null);
- const inputRef = useRef(null);
- const selectedStation = stations.find((s) => s.id === value);
-
- useEffect(() => {
- if (selectedStation && !open) setQuery("");
- }, [selectedStation, open]);
-
- useEffect(() => {
- const handler = (e: MouseEvent) => {
- if (ref.current && !ref.current.contains(e.target as Node))
- setOpen(false);
- };
- document.addEventListener("mousedown", handler);
- return () => document.removeEventListener("mousedown", handler);
- }, []);
-
- const filtered = query.trim()
- ? stations.filter(
- (s) =>
- s.id !== excludeId &&
- (s.name.toLowerCase().includes(query.toLowerCase()) ||
- s.code?.toLowerCase().includes(query.toLowerCase())),
- )
- : stations.filter((s) => s.id !== excludeId).slice(0, 20);
-
- const displayValue = open ? query : (selectedStation?.name ?? "");
-
- return (
-
-
-
- {
- setQuery(e.target.value);
- setOpen(true);
- }}
- onFocus={() => {
- setQuery("");
- setOpen(true);
- onOpen?.();
- }}
- placeholder={placeholder}
- className="w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm text-gray-900 dark:text-white placeholder-gray-400"
- />
- {value && (
- {
- onSelect({ id: "", name: "", code: "", country: "" });
- setQuery("");
- }}
- className="absolute right-3 p-0.5"
- >
-
-
- )}
-
-
- {open && (
-
- {!query && recentIds.length > 0 && (
-
-
- Recent
-
- {recentIds
- .map((id) => stations.find((s) => s.id === id))
- .filter(Boolean)
- .map((s) => (
-
{
- onSelect(s!);
- setOpen(false);
- setQuery("");
- }}
- className="w-full flex items-center gap-2 px-2 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-left"
- >
-
-
- {s!.name}
-
-
- ))}
-
-
- )}
- {filtered.length === 0 ? (
-
- No stations found
-
- ) : (
- filtered.map((s) => (
-
{
- onSelect(s);
- setOpen(false);
- setQuery("");
- }}
- className="w-full flex items-center gap-2 px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700 text-left transition-colors"
- >
-
-
-
- {s.name}
-
- {s.code && (
-
- {s.code}
-
- )}
-
-
- ))
- )}
-
- )}
-
- );
-}
-
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function SearchPage() {
const router = useRouter();
@@ -581,13 +428,9 @@ export default function SearchPage() {
useEffect(() => {
router.prefetch("/booking/results");
}, [router]);
- const [recentStationIds, setRecentStationIds] = useState(() => {
- try {
- return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]");
- } catch {
- return [];
- }
- });
+ const [recentStationIds, setRecentStationIds] = useState(
+ readRecentStationIds,
+ );
const passengerRef = useRef(null);
const widgetRef = useRef(null);
@@ -847,11 +690,7 @@ export default function SearchPage() {
}, [noReturnRouteForPair, returnDate, setValue, clearErrors]);
const saveRecent = useCallback((id: string) => {
- setRecentStationIds((prev) => {
- const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5);
- localStorage.setItem("edr_recent_stations", JSON.stringify(next));
- return next;
- });
+ setRecentStationIds((prev) => pushRecentStation(id, prev));
}, []);
const handleSwap = () => {
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
index fa1a86524..e457c7d62 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
@@ -6,157 +6,18 @@ import { useRouter } from "next/navigation";
import { useBookingStore } from "@/lib/booking-store";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
-import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react";
-import { Armchair, Bed, ChevronLeft, ChevronDown, Train, TrainFront, X } from "lucide-react";
-import Image from "next/image";
+import { useState, useEffect, useCallback, useMemo, useRef } from "react";
+import { ChevronLeft, ChevronDown, Train, TrainFront, X } from "lucide-react";
import CustomModal from "@/components/CustomModal";
import { Skeleton } from "@/components/Skeleton";
import { isChild } from "@/utils/fare-utils";
-
-const BED_POSITION_SUFFIX: Record = { lower: 'L', middle: 'M', upper: 'U' };
-
-const buildSeatLabel = (seat: any): string => {
- const base: string = seat.number || seat.label || seat.seatNumber || '';
- if (!base) return '';
- const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? '') : '';
- return suffix ? `${base}${suffix}` : base;
-};
-
-const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => {
- const seatLabel = bed.label || bed.seatNumber || bed.number || "?";
- const bedPosition = bed.bedPosition || "";
- const bedType =
- bedPosition === "upper"
- ? "Upper"
- : bedPosition === "middle"
- ? "Middle"
- : "Lower";
- const isDisabled = bed.status !== "AVAILABLE" || isAssignedToOther;
-
- return (
- onToggle(bed.id)}
- disabled={isDisabled}
- title={
- isAssignedToOther
- ? `Bed ${seatLabel} - already assigned to another passenger`
- : `${bedType} Berth ${seatLabel} - ${bed.status}`
- }
- className={`relative flex flex-col items-center justify-center gap-0.5 w-16 sm:w-[4.5rem] py-2.5 rounded-xl border shadow-sm transition-all duration-150 ${
- isDisabled ? "" : "hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 active:scale-95"
- } ${
- isSelected
- ? "bg-blue-50 border-2 border-blue-500 shadow-blue-200/60 dark:bg-blue-900/30 dark:border-blue-400 dark:shadow-none scale-[1.03]"
- : isAssignedToOther
- ? "bg-purple-50 border-purple-300 cursor-not-allowed dark:bg-purple-900/20 dark:border-purple-700"
- : bed.status === "AVAILABLE"
- ? "bg-green-50 border-green-300 hover:bg-green-100 hover:border-green-400 dark:bg-green-900/20 dark:border-green-700"
- : bed.status === "BOOKED" || bed.status === "BLOCKED"
- ? "bg-red-50 border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700"
- : "bg-gray-100 border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
- }`}
- >
- {/* bed.png is a portrait (headboard-to-footboard) silhouette; rotate it so the
- berth lies horizontally, matching the direction beds actually run in the coach. */}
-
-
-
-
- {seatLabel}
-
-
- {bedType}
-
-
- );
-});
-
-BedCard.displayName = "BedCard";
-
-// A real berth ladder is a single fixed rail mounted at the end of the bay that a
-// passenger climbs to reach every level — not a separate rung floating between each
-// pair of beds. So this renders once per bay, right after the last berth card, with
-// solid rounded rails/rungs (like a real metal ladder) rather than thin decorative lines.
-const LadderConnector = memo(() => (
-
-
- {/* Side rails */}
-
-
- {/* Rungs, evenly spaced top (upper) to bottom (lower) */}
-
-
-
-
-
-
-));
-
-LadderConnector.displayName = "LadderConnector";
-
-const SeatButton = memo(
- ({
- seat,
- isSelected,
- isAssignedToOther,
- onToggle,
- isBedCoach,
- bedLabel,
- coachSeatClass,
- }: any) => {
- const seatLabel = seat.number || seat.label || seat.seatNumber || "?";
- const bedWidth = "w-24";
- const width = isBedCoach ? bedWidth : "w-10";
- const isDisabled = seat.status !== "AVAILABLE" || isAssignedToOther;
-
- return (
-
-
onToggle(seat.id)}
- disabled={isDisabled}
- className={`${width} h-11 rounded flex items-center justify-center transition-all ${
- isSelected
- ? "bg-[rgb(20_113_76)] text-white shadow-md scale-105"
- : isAssignedToOther
- ? "bg-purple-400 text-white cursor-not-allowed opacity-75"
- : seat.status === "AVAILABLE"
- ? "bg-green-500 hover:bg-green-600 text-white cursor-pointer hover:shadow-md"
- : seat.status === "HELD"
- ? "bg-yellow-500 text-white cursor-not-allowed opacity-75"
- : "bg-gray-500 text-white cursor-not-allowed opacity-60"
- }`}
- title={
- isAssignedToOther
- ? `Seat ${seatLabel}${bedLabel} - already assigned to another passenger`
- : `Seat ${seatLabel}${bedLabel} - ${seat.status} - ${coachSeatClass}`
- }
- style={
- isBedCoach
- ? seat.row % 2 === 1
- ? { transform: "scaleY(-1)" }
- : undefined
- : seat.row % 2 === 0
- ? { transform: "scaleY(-1)" }
- : undefined
- }
- >
- {isBedCoach ? (
-
- ) : (
-
- )}
-
-
- );
- },
-);
-
-SeatButton.displayName = "SeatButton";
+import {
+ buildSeatLabel,
+ CoachSeatLayout,
+ getBedPosition,
+ getValidSeatsForCoach as getValidSeatsForCoachData,
+} from "@/components/SeatMap";
export default function SeatsPage() {
const router = useRouter();
@@ -701,72 +562,11 @@ export default function SeatsPage() {
[filteredCoaches, selectedCoach],
);
- const getBedPosition = (selectedClass: string): string | null => {
- const lowerClass = selectedClass.toLowerCase();
- if (lowerClass.includes("upper")) return "upper";
- if (lowerClass.includes("middle")) return "middle";
- if (lowerClass.includes("lower")) return "lower";
- return null;
- };
-
- // Extracted so it can be applied to ANY coach, not just the one currently expanded —
- // Auto Assign needs to look across every coach of this type, not just selectedCoachData.
+ // Berth-class narrowing lives in the shared SeatMap module so the booking and reschedule
+ // flows filter beds identically; this wrapper just binds the current leg's fare class.
const getValidSeatsForCoach = useCallback(
- (coachData: any): any[] => {
- if (!coachData) return [];
-
- // If coach has rooms, extract all beds from rooms
- if (coachData.rooms?.length > 0) {
- const allBeds: any[] = [];
- coachData.rooms.forEach((room: any) => {
- if (room.beds) {
- allBeds.push(...room.beds);
- }
- });
-
- let beds = allBeds.filter((s: any) => {
- const seatLabel = s.label || s.number || s.seatNumber || "";
- return seatLabel && !seatLabel.startsWith("-");
- });
-
- const isBedCoach =
- coachData.seatClass?.toLowerCase().includes("bed") ||
- coachData.mode?.toLowerCase().includes("bed");
-
- if (isBedCoach && currentSchedule?.selectedSeatClass) {
- const selectedBedPosition = getBedPosition(
- currentSchedule.selectedSeatClass,
- );
- if (selectedBedPosition) {
- beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition);
- }
- }
-
- return beds;
- }
-
- // Fallback to old seat structure
- let seats = (coachData.seats || []).filter((s: any) => {
- const seatLabel = s.label || s.number || s.seatNumber || "";
- return seatLabel && !seatLabel.startsWith("-");
- });
- const isBedCoach =
- coachData.isBedCoach === true ||
- seats.some((s: any) => s.bedPosition) ||
- coachData.seatClass?.toLowerCase().includes("bed") ||
- coachData.mode?.toLowerCase().includes("bed");
-
- if (isBedCoach && currentSchedule?.selectedSeatClass) {
- const selectedBedPosition = getBedPosition(
- currentSchedule.selectedSeatClass,
- );
- if (selectedBedPosition) {
- seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
- }
- }
-
- return seats;
- },
+ (coachData: any): any[] =>
+ getValidSeatsForCoachData(coachData, currentSchedule?.selectedSeatClass),
[currentSchedule?.selectedSeatClass],
);
@@ -1369,355 +1169,6 @@ export default function SeatsPage() {
}
}, [seatEligibility, seatEligibleIndices, activePassengerIndex]);
- const parseSeatArrangement = (
- arrangement: string | null,
- seatClasses?: string[],
- ): number[] => {
- if (!arrangement) return [2, 2];
-
- // Check if this is a bed coach based on seat classes
- const isBedCoach = seatClasses?.some((sc) =>
- sc?.toLowerCase().includes("bed"),
- );
-
- if (isBedCoach) {
- // For bed coaches, arrangement like "3+0" means 3 beds stacked vertically
- // We want to render them as single column, so return [1]
- const parts = arrangement
- .split("+")
- .map((p) => parseInt(p.trim()))
- .filter((n) => !isNaN(n) && n > 0);
- return parts.length > 0 ? [Math.max(...parts)] : [3];
- }
-
- // For regular seats, parse normally (e.g., "3+2" -> [3, 2])
- const parts = arrangement
- .split("+")
- .map((p) => parseInt(p.trim()))
- .filter((n) => !isNaN(n) && n > 0);
- return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2];
- };
-
- const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
- const arrangement = parseSeatArrangement(
- coach.seatArrangement,
- coach.seatClasses || [coach.seatClass],
- );
-
- if (validSeats.length === 0) {
- return No seats
;
- }
-
- const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
- const seatClassStr =
- typeof selectedCoachData?.seatClass === "string"
- ? selectedCoachData.seatClass
- : selectedCoachData?.seatClass?.name || "";
-
- // Indian-sleeper-style berth bay: Lower / Middle / Upper laid out horizontally, with
- // the single ladder that actually serves the whole bay shown once at the end.
- const renderBerthBay = (beds: any[], keyPrefix: string) => (
-
- {beds.map((bed: any) => (
-
- ))}
- {beds.length > 1 && }
-
- );
-
- // Two-side compartment: the left bay and right bay each get their own row (berths
- // still laid out horizontally within a row), stacked one above the other and split
- // by a dashed aisle divider — instead of squeezing both sides into a single row.
- const renderCompartment = (leftBay: any[], rightBay: any[], key: string) => (
-
-
- {leftBay.length > 0 && (
-
{renderBerthBay(leftBay, `${key}-left`)}
- )}
- {leftBay.length > 0 && rightBay.length > 0 && (
-
- )}
- {rightBay.length > 0 && (
-
{renderBerthBay(rightBay, `${key}-right`)}
- )}
-
-
- );
-
- // Bay position ordering + left/right side detection shared by both bed layouts below.
- const BERTH_ORDER = ["lower", "middle", "upper"];
- const bedSideIsLeft = (bed: any, leftColByPosition: Record) => {
- if (bed.position === "LEFT") return true;
- if (bed.position === "RIGHT") return false;
- const leftCol = leftColByPosition[bed.bedPosition];
- return leftCol ? bed.col === leftCol : true;
- };
-
- // Bed coach with bed positions (Upper, Middle, Lower)
- if (isBedCoach && hasBedPositionData) {
- // Check if this is VIP_BED or ECONOMY_BED based on room data
- const rooms = (coach as any).rooms || [];
- const hasRooms = rooms.length > 0;
-
- if (hasRooms) {
- // Room-based layout (VIP_BED with 4 beds, ECONOMY_BED with 6 beds)
- return (
-
- {rooms.map((room: any) => {
- const isVipBed =
- room.category === "VIP_BED" || room.totalBeds === 4;
- const isEconomyBed =
- room.category === "ECONOMY_BED" || room.totalBeds === 6;
-
- // Sort beds by position and column
- const sortedBeds = [...(room.beds || [])].sort((a, b) => {
- const posOrder = { upper: 3, middle: 2, lower: 1 };
- const posA =
- posOrder[a.bedPosition as keyof typeof posOrder] || 0;
- const posB =
- posOrder[b.bedPosition as keyof typeof posOrder] || 0;
- if (posA !== posB) return posA - posB;
- return (a.col || "").localeCompare(b.col || "");
- });
-
- return (
-
- {/* Room Header */}
-
-
-
- Room {room.roomNumber}
-
-
- {room.category === "VIP_BED"
- ? "VIP BED"
- : room.category === "ECONOMY_BED"
- ? "ECONOMY BED"
- : room.category}
-
-
-
- {room.totalBeds} beds
-
-
-
- {/* Legend */}
-
-
- {/* VIP BED Layout — 2-tier compartment (Lower/Upper), left + right of the aisle */}
- {isVipBed && (() => {
- const lowerBeds = sortedBeds.filter((b: any) => b.bedPosition === "lower");
- const upperBeds = sortedBeds.filter((b: any) => b.bedPosition === "upper");
- const isLeft = (bed: any, idx: number) =>
- bed.position === "LEFT" ? true : bed.position === "RIGHT" ? false : idx % 2 === 0;
-
- const leftBay = [lowerBeds, upperBeds]
- .map((arr) => arr.find((b: any, i: number) => isLeft(b, i)))
- .filter(Boolean);
- const rightBay = [lowerBeds, upperBeds]
- .map((arr) => arr.find((b: any, i: number) => !isLeft(b, i)))
- .filter(Boolean);
-
- return renderCompartment(leftBay, rightBay, `${room.room_id}-vip`);
- })()}
-
- {/* ECONOMY BED Layout — 3-tier compartment (Lower/Middle/Upper), left + right of the aisle */}
- {isEconomyBed && (() => {
- const leftColByPosition: Record
= { lower: "A", middle: "B", upper: "C" };
- const leftBay = BERTH_ORDER
- .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && bedSideIsLeft(b, leftColByPosition)))
- .filter(Boolean);
- const rightBay = BERTH_ORDER
- .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && !bedSideIsLeft(b, leftColByPosition)))
- .filter(Boolean);
-
- return renderCompartment(leftBay, rightBay, `${room.room_id}-eco`);
- })()}
-
- );
- })}
-
- );
- }
-
- // Fallback: beds without room data — group into numbered bays (Lower/Middle/Upper),
- // then pair adjacent bays into two-side compartments, same as the room-based layouts.
- const seatGroups = new Map();
-
- for (const seat of validSeats) {
- const baseNumber = seat.seatNumber || seat.number || seat.label || "";
- if (!seatGroups.has(baseNumber)) {
- seatGroups.set(baseNumber, []);
- }
- seatGroups.get(baseNumber)!.push(seat);
- }
-
- const sortedGroups = Array.from(seatGroups.entries()).sort(([a], [b]) => {
- const numA = parseInt(a) || 0;
- const numB = parseInt(b) || 0;
- return numA - numB;
- });
-
- const bays = sortedGroups
- .map(([, beds]) =>
- BERTH_ORDER.map((pos) => beds.find((seat: any) => seat.bedPosition === pos)).filter(Boolean),
- )
- .filter((bay) => bay.length > 0);
-
- return (
-
- {Array.from({ length: Math.ceil(bays.length / 2) }, (_, i) => {
- const leftBay = bays[i * 2] || [];
- const rightBay = bays[i * 2 + 1] || [];
- return renderCompartment(leftBay, rightBay, `bay-compartment-${i}`);
- })}
-
- );
- }
-
- // Regular seats with row/column arrangement
- const rowMap = new Map();
- for (const seat of validSeats) {
- if (!rowMap.has(seat.row)) {
- rowMap.set(seat.row, []);
- }
- rowMap.get(seat.row)!.push(seat);
- }
-
- const rows = Array.from(rowMap.entries())
- .sort(([a], [b]) => a - b)
- .map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col)));
-
- return (
-
- {rows.map((rowSeats: any[], rowIdx: number) => {
- const groups: any[][] = [];
-
- // Split seats into groups based on arrangement
- if (arrangement.length === 1) {
- // Single group (all seats together)
- groups.push(rowSeats);
- } else {
- // Multiple groups with aisle separation
- arrangement.forEach((_groupSize, groupIdx) => {
- const startIdx = arrangement
- .slice(0, groupIdx)
- .reduce((sum, size) => sum + size, 0);
- const endIdx = arrangement
- .slice(0, groupIdx + 1)
- .reduce((sum, size) => sum + size, 0);
- const currentGroup = rowSeats.slice(startIdx, endIdx);
- if (currentGroup.length > 0) groups.push(currentGroup);
- });
- }
-
- const rowNumber = rowSeats[0]?.row || 1;
- const shouldFlipArmchair = rowNumber % 2 === 0;
- const showSpacing = rowIdx % 2 === 1;
-
- return (
-
- {shouldFlipArmchair && (
-
- {groups.map((group, gIdx) => (
-
- {group.map((seat: any) => {
- const seatLabel =
- seat.label || seat.number || seat.seatNumber || "";
- return (
-
- {seatLabel}
-
- );
- })}
-
- ))}
-
- )}
-
- {groups.map((group, gIdx) => (
-
- {group.map((seat: any) => (
-
- ))}
-
- ))}
-
-
- {!shouldFlipArmchair && (
-
- {groups.map((group, gIdx) => (
-
- {group.map((seat: any) => {
- const seatLabel =
- seat.label || seat.number || seat.seatNumber || "";
- return (
-
- {seatLabel}
-
- );
- })}
-
- ))}
-
- )}
-
- {showSpacing && (
-
- )}
-
- );
- })}
-
- );
- };
-
if (
isRoundTrip
? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length
@@ -2414,7 +1865,14 @@ export default function SeatsPage() {
No seats in this coach
) : (
- renderCoachSeats(selectedCoachData, isBedCoach)
+
)}
diff --git a/apps/edr-passenger-web/portal/src/components/SeatMap.tsx b/apps/edr-passenger-web/portal/src/components/SeatMap.tsx
new file mode 100644
index 000000000..94fbe7c98
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/components/SeatMap.tsx
@@ -0,0 +1,706 @@
+"use client";
+
+import { memo } from "react";
+import Image from "next/image";
+import { Armchair, Bed } from "lucide-react";
+
+/**
+ * The seat map shared by the booking flow (`/booking/seats`) and the reschedule flow
+ * (`/booking/reschedule`). Everything here is presentational: it takes a coach from
+ * `GET /seats/seatmap/:scheduleId` and three callbacks, and knows nothing about bookings,
+ * passengers, holds or fares. Both pages must render seats identically, so this is the one
+ * copy — extend it rather than forking a second layout.
+ */
+
+export const BED_POSITION_SUFFIX: Record = {
+ lower: "L",
+ middle: "M",
+ upper: "U",
+};
+
+export const buildSeatLabel = (seat: any): string => {
+ const base: string = seat.number || seat.label || seat.seatNumber || "";
+ if (!base) return "";
+ const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? "") : "";
+ return suffix ? `${base}${suffix}` : base;
+};
+
+/** "Economy Bed - Upper" → "upper". Null when the class names no berth level. */
+export const getBedPosition = (selectedClass: string): string | null => {
+ const lowerClass = selectedClass.toLowerCase();
+ if (lowerClass.includes("upper")) return "upper";
+ if (lowerClass.includes("middle")) return "middle";
+ if (lowerClass.includes("lower")) return "lower";
+ return null;
+};
+
+export const isBedCoachData = (coachData: any): boolean =>
+ coachData?.isBedCoach === true ||
+ coachData?.rooms?.length > 0 ||
+ (coachData?.seats || []).some((s: any) => s.bedPosition) ||
+ coachData?.seatClass?.toLowerCase().includes("bed") ||
+ coachData?.mode?.toLowerCase().includes("bed");
+
+/**
+ * Flattens a coach into the seats that are actually selectable: beds out of `rooms` when the
+ * coach has them, otherwise `seats`. Placeholder rows (labels starting "-") are dropped, and
+ * on a bed coach a berth-specific fare class narrows the list to that level.
+ */
+export const getValidSeatsForCoach = (
+ coachData: any,
+ selectedSeatClass?: string | null,
+): any[] => {
+ if (!coachData) return [];
+
+ if (coachData.rooms?.length > 0) {
+ const allBeds: any[] = [];
+ coachData.rooms.forEach((room: any) => {
+ if (room.beds) allBeds.push(...room.beds);
+ });
+
+ let beds = allBeds.filter((s: any) => {
+ const seatLabel = s.label || s.number || s.seatNumber || "";
+ return seatLabel && !seatLabel.startsWith("-");
+ });
+
+ if (isBedCoachData(coachData) && selectedSeatClass) {
+ const selectedBedPosition = getBedPosition(selectedSeatClass);
+ if (selectedBedPosition) {
+ beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition);
+ }
+ }
+ return beds;
+ }
+
+ let seats = (coachData.seats || []).filter((s: any) => {
+ const seatLabel = s.label || s.number || s.seatNumber || "";
+ return seatLabel && !seatLabel.startsWith("-");
+ });
+
+ if (isBedCoachData(coachData) && selectedSeatClass) {
+ const selectedBedPosition = getBedPosition(selectedSeatClass);
+ if (selectedBedPosition) {
+ seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
+ }
+ }
+ return seats;
+};
+
+/** "3+2" → [3, 2] so the aisle gap lands between the groups. Bed coaches collapse to one column. */
+export const parseSeatArrangement = (
+ arrangement: string | null,
+ seatClasses?: (string | undefined)[],
+): number[] => {
+ if (!arrangement) return [2, 2];
+
+ const isBedCoach = seatClasses?.some((sc) => sc?.toLowerCase().includes("bed"));
+
+ if (isBedCoach) {
+ // For bed coaches, arrangement like "3+0" means 3 beds stacked vertically —
+ // render them as a single column.
+ const parts = arrangement
+ .split("+")
+ .map((p) => parseInt(p.trim()))
+ .filter((n) => !isNaN(n) && n > 0);
+ return parts.length > 0 ? [Math.max(...parts)] : [3];
+ }
+
+ const parts = arrangement
+ .split("+")
+ .map((p) => parseInt(p.trim()))
+ .filter((n) => !isNaN(n) && n > 0);
+ return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2];
+};
+
+export const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => {
+ const seatLabel = bed.label || bed.seatNumber || bed.number || "?";
+ const bedPosition = bed.bedPosition || "";
+ const bedType =
+ bedPosition === "upper" ? "Upper" : bedPosition === "middle" ? "Middle" : "Lower";
+ const isDisabled = bed.status !== "AVAILABLE" || isAssignedToOther;
+
+ return (
+ onToggle(bed.id)}
+ disabled={isDisabled}
+ title={
+ isAssignedToOther
+ ? `Bed ${seatLabel} - already assigned to another passenger`
+ : `${bedType} Berth ${seatLabel} - ${bed.status}`
+ }
+ className={`relative flex flex-col items-center justify-center gap-0.5 w-16 sm:w-[4.5rem] py-2.5 rounded-xl border shadow-sm transition-all duration-150 ${
+ isDisabled ? "" : "hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 active:scale-95"
+ } ${
+ isSelected
+ ? "bg-blue-50 border-2 border-blue-500 shadow-blue-200/60 dark:bg-blue-900/30 dark:border-blue-400 dark:shadow-none scale-[1.03]"
+ : isAssignedToOther
+ ? "bg-purple-50 border-purple-300 cursor-not-allowed dark:bg-purple-900/20 dark:border-purple-700"
+ : bed.status === "AVAILABLE"
+ ? "bg-green-50 border-green-300 hover:bg-green-100 hover:border-green-400 dark:bg-green-900/20 dark:border-green-700"
+ : bed.status === "BOOKED" || bed.status === "BLOCKED"
+ ? "bg-red-50 border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700"
+ : "bg-gray-100 border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
+ }`}
+ >
+ {/* bed.png is a portrait (headboard-to-footboard) silhouette; rotate it so the
+ berth lies horizontally, matching the direction beds actually run in the coach. */}
+
+
+
+ {seatLabel}
+ {bedType}
+
+ );
+});
+
+BedCard.displayName = "BedCard";
+
+// A real berth ladder is a single fixed rail mounted at the end of the bay that a
+// passenger climbs to reach every level — not a separate rung floating between each
+// pair of beds. So this renders once per bay, right after the last berth card, with
+// solid rounded rails/rungs (like a real metal ladder) rather than thin decorative lines.
+export const LadderConnector = memo(() => (
+
+
+ {/* Side rails */}
+
+
+ {/* Rungs, evenly spaced top (upper) to bottom (lower) */}
+
+
+
+
+
+
+));
+
+LadderConnector.displayName = "LadderConnector";
+
+export const SeatButton = memo(
+ ({ seat, isSelected, isAssignedToOther, onToggle, isBedCoach, bedLabel, coachSeatClass }: any) => {
+ const seatLabel = seat.number || seat.label || seat.seatNumber || "?";
+ const bedWidth = "w-24";
+ const width = isBedCoach ? bedWidth : "w-10";
+ const isDisabled = seat.status !== "AVAILABLE" || isAssignedToOther;
+
+ return (
+
+
onToggle(seat.id)}
+ disabled={isDisabled}
+ className={`${width} h-11 rounded flex items-center justify-center transition-all ${
+ isSelected
+ ? "bg-[rgb(20_113_76)] text-white shadow-md scale-105"
+ : isAssignedToOther
+ ? "bg-purple-400 text-white cursor-not-allowed opacity-75"
+ : seat.status === "AVAILABLE"
+ ? "bg-green-500 hover:bg-green-600 text-white cursor-pointer hover:shadow-md"
+ : seat.status === "HELD"
+ ? "bg-yellow-500 text-white cursor-not-allowed opacity-75"
+ : "bg-gray-500 text-white cursor-not-allowed opacity-60"
+ }`}
+ title={
+ isAssignedToOther
+ ? `Seat ${seatLabel}${bedLabel} - already assigned to another passenger`
+ : `Seat ${seatLabel}${bedLabel} - ${seat.status} - ${coachSeatClass}`
+ }
+ style={
+ isBedCoach
+ ? seat.row % 2 === 1
+ ? { transform: "scaleY(-1)" }
+ : undefined
+ : seat.row % 2 === 0
+ ? { transform: "scaleY(-1)" }
+ : undefined
+ }
+ >
+ {isBedCoach ? : }
+
+
+ );
+ },
+);
+
+SeatButton.displayName = "SeatButton";
+
+/** Available / Selected / Booked swatches, shown above every expanded coach. */
+export function SeatLegend() {
+ return (
+
+ {[
+ { color: "bg-green-50 border border-green-300", label: "Available" },
+ { color: "bg-blue-50 border-2 border-blue-500", label: "Selected" },
+ { color: "bg-red-50 border border-red-300", label: "Booked" },
+ ].map(({ color, label }) => (
+
+ ))}
+
+ );
+}
+
+export interface CoachSeatLayoutProps {
+ coach: any;
+ isBedCoach: boolean;
+ /** Already filtered by `getValidSeatsForCoach` — the caller owns berth-class narrowing. */
+ seats: any[];
+ isSeatSelected: (seatId: string) => boolean;
+ isSeatAssignedToOther: (seatId: string) => boolean;
+ onSeatToggle: (seatId: string) => void;
+}
+
+/**
+ * The seat grid for one coach. Four layouts, picked off the coach's own shape:
+ * room-based VIP (4 berths), room-based Economy (6 berths), room-less berth bays, and
+ * regular rows with the aisle gap from `seatArrangement`.
+ */
+export function CoachSeatLayout({
+ coach,
+ isBedCoach,
+ seats: validSeats,
+ isSeatSelected,
+ isSeatAssignedToOther,
+ onSeatToggle,
+}: CoachSeatLayoutProps) {
+ const arrangement = parseSeatArrangement(
+ coach?.seatArrangement,
+ coach?.seatClasses || [coach?.seatClass],
+ );
+
+ if (validSeats.length === 0) {
+ return No seats
;
+ }
+
+ const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
+ const seatClassStr =
+ typeof coach?.seatClass === "string" ? coach.seatClass : coach?.seatClass?.name || "";
+
+ // Indian-sleeper-style berth bay: Lower / Middle / Upper laid out horizontally, with
+ // the single ladder that actually serves the whole bay shown once at the end.
+ const renderBerthBay = (beds: any[], keyPrefix: string) => (
+
+ {beds.map((bed: any) => (
+
+ ))}
+ {beds.length > 1 && }
+
+ );
+
+ // Two-side compartment: the left bay and right bay each get their own row (berths
+ // still laid out horizontally within a row), stacked one above the other and split
+ // by a dashed aisle divider — instead of squeezing both sides into a single row.
+ const renderCompartment = (leftBay: any[], rightBay: any[], key: string) => (
+
+
+ {leftBay.length > 0 && (
+
{renderBerthBay(leftBay, `${key}-left`)}
+ )}
+ {leftBay.length > 0 && rightBay.length > 0 && (
+
+ )}
+ {rightBay.length > 0 && (
+
{renderBerthBay(rightBay, `${key}-right`)}
+ )}
+
+
+ );
+
+ // Bay position ordering + left/right side detection shared by both bed layouts below.
+ const BERTH_ORDER = ["lower", "middle", "upper"];
+ const bedSideIsLeft = (bed: any, leftColByPosition: Record) => {
+ if (bed.position === "LEFT") return true;
+ if (bed.position === "RIGHT") return false;
+ const leftCol = leftColByPosition[bed.bedPosition];
+ return leftCol ? bed.col === leftCol : true;
+ };
+
+ if (isBedCoach && hasBedPositionData) {
+ const rooms = (coach as any)?.rooms || [];
+
+ if (rooms.length > 0) {
+ // Room-based layout (VIP_BED with 4 beds, ECONOMY_BED with 6 beds)
+ return (
+
+ {rooms.map((room: any) => {
+ const isVipBed = room.category === "VIP_BED" || room.totalBeds === 4;
+ const isEconomyBed = room.category === "ECONOMY_BED" || room.totalBeds === 6;
+
+ const sortedBeds = [...(room.beds || [])].sort((a, b) => {
+ const posOrder = { upper: 3, middle: 2, lower: 1 };
+ const posA = posOrder[a.bedPosition as keyof typeof posOrder] || 0;
+ const posB = posOrder[b.bedPosition as keyof typeof posOrder] || 0;
+ if (posA !== posB) return posA - posB;
+ return (a.col || "").localeCompare(b.col || "");
+ });
+
+ return (
+
+ {/* Room Header */}
+
+
+
+ Room {room.roomNumber}
+
+
+ {room.category === "VIP_BED"
+ ? "VIP BED"
+ : room.category === "ECONOMY_BED"
+ ? "ECONOMY BED"
+ : room.category}
+
+
+
+ {room.totalBeds} beds
+
+
+
+ {/* Legend */}
+
+
+ {/* VIP BED Layout — 2-tier compartment (Lower/Upper), left + right of the aisle */}
+ {isVipBed && (() => {
+ const lowerBeds = sortedBeds.filter((b: any) => b.bedPosition === "lower");
+ const upperBeds = sortedBeds.filter((b: any) => b.bedPosition === "upper");
+ const isLeft = (bed: any, idx: number) =>
+ bed.position === "LEFT" ? true : bed.position === "RIGHT" ? false : idx % 2 === 0;
+
+ const leftBay = [lowerBeds, upperBeds]
+ .map((arr) => arr.find((b: any, i: number) => isLeft(b, i)))
+ .filter(Boolean);
+ const rightBay = [lowerBeds, upperBeds]
+ .map((arr) => arr.find((b: any, i: number) => !isLeft(b, i)))
+ .filter(Boolean);
+
+ return renderCompartment(leftBay, rightBay, `${room.room_id}-vip`);
+ })()}
+
+ {/* ECONOMY BED Layout — 3-tier compartment (Lower/Middle/Upper), left + right of the aisle */}
+ {isEconomyBed && (() => {
+ const leftColByPosition: Record
= { lower: "A", middle: "B", upper: "C" };
+ const leftBay = BERTH_ORDER
+ .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && bedSideIsLeft(b, leftColByPosition)))
+ .filter(Boolean);
+ const rightBay = BERTH_ORDER
+ .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && !bedSideIsLeft(b, leftColByPosition)))
+ .filter(Boolean);
+
+ return renderCompartment(leftBay, rightBay, `${room.room_id}-eco`);
+ })()}
+
+ );
+ })}
+
+ );
+ }
+
+ // Fallback: beds without room data — group into numbered bays (Lower/Middle/Upper),
+ // then pair adjacent bays into two-side compartments, same as the room-based layouts.
+ const seatGroups = new Map();
+ for (const seat of validSeats) {
+ const baseNumber = seat.seatNumber || seat.number || seat.label || "";
+ if (!seatGroups.has(baseNumber)) seatGroups.set(baseNumber, []);
+ seatGroups.get(baseNumber)!.push(seat);
+ }
+
+ const sortedGroups = Array.from(seatGroups.entries()).sort(([a], [b]) => {
+ const numA = parseInt(a) || 0;
+ const numB = parseInt(b) || 0;
+ return numA - numB;
+ });
+
+ const bays = sortedGroups
+ .map(([, beds]) =>
+ BERTH_ORDER.map((pos) => beds.find((seat: any) => seat.bedPosition === pos)).filter(Boolean),
+ )
+ .filter((bay) => bay.length > 0);
+
+ return (
+
+ {Array.from({ length: Math.ceil(bays.length / 2) }, (_, i) => {
+ const leftBay = bays[i * 2] || [];
+ const rightBay = bays[i * 2 + 1] || [];
+ return renderCompartment(leftBay, rightBay, `bay-compartment-${i}`);
+ })}
+
+ );
+ }
+
+ // Regular seats with row/column arrangement
+ const rowMap = new Map();
+ for (const seat of validSeats) {
+ if (!rowMap.has(seat.row)) rowMap.set(seat.row, []);
+ rowMap.get(seat.row)!.push(seat);
+ }
+
+ const rows = Array.from(rowMap.entries())
+ .sort(([a], [b]) => a - b)
+ .map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col)));
+
+ const renderSeatNumberStrip = (groups: any[][], keyPrefix: string) => (
+
+ {groups.map((group, gIdx) => (
+
+ {group.map((seat: any) => (
+
+ {seat.label || seat.number || seat.seatNumber || ""}
+
+ ))}
+
+ ))}
+
+ );
+
+ return (
+
+ {rows.map((rowSeats: any[], rowIdx: number) => {
+ const groups: any[][] = [];
+
+ if (arrangement.length === 1) {
+ groups.push(rowSeats);
+ } else {
+ arrangement.forEach((_groupSize, groupIdx) => {
+ const startIdx = arrangement.slice(0, groupIdx).reduce((sum, size) => sum + size, 0);
+ const endIdx = arrangement.slice(0, groupIdx + 1).reduce((sum, size) => sum + size, 0);
+ const currentGroup = rowSeats.slice(startIdx, endIdx);
+ if (currentGroup.length > 0) groups.push(currentGroup);
+ });
+ }
+
+ const rowNumber = rowSeats[0]?.row || 1;
+ const shouldFlipArmchair = rowNumber % 2 === 0;
+ const showSpacing = rowIdx % 2 === 1;
+
+ return (
+
+ {shouldFlipArmchair && renderSeatNumberStrip(groups, `before-${rowNumber}`)}
+
+
+ {groups.map((group, gIdx) => (
+
+ {group.map((seat: any) => (
+
+ ))}
+
+ ))}
+
+
+ {!shouldFlipArmchair && renderSeatNumberStrip(groups, `after-${rowNumber}`)}
+
+ {showSpacing &&
}
+
+ );
+ })}
+
+ );
+}
+
+export interface SeatMapProps {
+ /** Coaches straight off `GET /seats/seatmap/:scheduleId`. */
+ coaches: any[];
+ selectedCoachId: string | null;
+ onSelectCoach: (coachId: string | null) => void;
+ /** Berth-level fare class, e.g. "Economy Bed - Upper". Narrows a bed coach to one level. */
+ selectedSeatClass?: string | null;
+ isSeatSelected: (seatId: string) => boolean;
+ isSeatAssignedToOther: (seatId: string) => boolean;
+ onSeatToggle: (seatId: string) => void;
+ emptyLabel?: string;
+}
+
+/**
+ * Coach accordion + legend + seat grid, styled as the train itself: coupling joints between
+ * cars, a brand stripe top and bottom, and per-coach availability bars.
+ */
+export default function SeatMap({
+ coaches,
+ selectedCoachId,
+ onSelectCoach,
+ selectedSeatClass,
+ isSeatSelected,
+ isSeatAssignedToOther,
+ onSeatToggle,
+ emptyLabel = "No coach of your class on this train.",
+}: SeatMapProps) {
+ if (!coaches || coaches.length === 0) {
+ return {emptyLabel}
;
+ }
+
+ return (
+
+ {coaches.map((coach: any, index: number) => {
+ const coachSeats = getValidSeatsForCoach(coach, selectedSeatClass);
+ const available = coachSeats.filter((s: any) => s.status === "AVAILABLE").length;
+ const total = coachSeats.length;
+ const isExpanded = selectedCoachId === coach.id;
+ const isBedCoach = isBedCoachData(coach);
+ const coachLabel = coach.label || coach.name || coach.coachNumber || `Coach ${index + 1}`;
+
+ return (
+
+ {/* Coupling joint */}
+
+
+ {/* Coach car */}
+
+ {/* Top colour stripe — brand rail */}
+
+
+
onSelectCoach(isExpanded ? null : coach.id)}
+ className={`w-full flex items-center justify-between px-4 py-3 transition-colors ${
+ isExpanded
+ ? "bg-[rgb(20,113,76)]/5 dark:bg-[rgb(20,113,76)]/10"
+ : "bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900/30"
+ }`}
+ >
+
+
+ {index + 1}
+
+
+
+ {coachLabel}
+
+
+ {available} of {total} seats available
+
+
+
+
+ {/* Mini availability bars */}
+
+ {Array.from({ length: Math.min(total, 12) }).map((_, i) => (
+
+ ))}
+
+
+
+
+
+ {/* Expanded seat map */}
+ {isExpanded && (
+
+
+
+
+ {coachSeats.length === 0 ? (
+
No seats in this coach
+ ) : (
+
+ )}
+
+
+
+ )}
+
+ {/* Bottom colour stripe */}
+
+
+
+ );
+ })}
+
+ );
+}
+
+function ChevronDownIcon({ isExpanded }: { isExpanded: boolean }) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/edr-passenger-web/portal/src/components/StationDropdown.tsx b/apps/edr-passenger-web/portal/src/components/StationDropdown.tsx
new file mode 100644
index 000000000..8b0fb0073
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/components/StationDropdown.tsx
@@ -0,0 +1,215 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { Clock, MapPin, X } from "lucide-react";
+import { Station } from "@/types";
+
+// Shared by the search widget (home page) and the reschedule flow so both pick stations the
+// same way. Recents live under one localStorage key, so a station picked on the home page is
+// still offered as "Recent" when rescheduling.
+export const RECENT_STATIONS_KEY = "edr_recent_stations";
+export const MAX_RECENT_STATIONS = 5;
+
+export function readRecentStationIds(): string[] {
+ try {
+ const raw = JSON.parse(localStorage.getItem(RECENT_STATIONS_KEY) || "[]");
+ return Array.isArray(raw) ? raw : [];
+ } catch {
+ return [];
+ }
+}
+
+/** Prepends `id`, de-duplicates, caps the list, persists it, and returns the new list. */
+export function pushRecentStation(id: string, prev: string[]): string[] {
+ const next = [id, ...prev.filter((x) => x !== id)].slice(0, MAX_RECENT_STATIONS);
+ try {
+ localStorage.setItem(RECENT_STATIONS_KEY, JSON.stringify(next));
+ } catch {
+ /* private mode / storage disabled — recents are a convenience, never a requirement */
+ }
+ return next;
+}
+
+// ─── Station Autocomplete ─────────────────────────────────────────────────────
+export default function StationDropdown({
+ stations,
+ value,
+ excludeId,
+ placeholder,
+ onSelect,
+ error,
+ recentIds,
+ onOpen,
+ disabled = false,
+}: {
+ stations: Station[];
+ value: string;
+ excludeId?: string;
+ placeholder: string;
+ onSelect: (s: Station) => void;
+ error?: string;
+ recentIds: string[];
+ onOpen?: () => void;
+ /**
+ * Read-only: shows the selection but refuses to open. Used where the route is fixed —
+ * a fare class whose policy sets `routeChangeAllowed: false` cannot change stations, and
+ * a dropdown that opens only to reject the pick is worse than one that plainly can't.
+ */
+ disabled?: boolean;
+}) {
+ const [query, setQuery] = useState("");
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ const inputRef = useRef(null);
+ const selectedStation = stations.find((s) => s.id === value);
+
+ useEffect(() => {
+ if (selectedStation && !open) setQuery("");
+ }, [selectedStation, open]);
+
+ // Close if the control is disabled while open (e.g. switching to a route-locked leg).
+ useEffect(() => {
+ if (disabled) setOpen(false);
+ }, [disabled]);
+
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node))
+ setOpen(false);
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, []);
+
+ const filtered = query.trim()
+ ? stations.filter(
+ (s) =>
+ s.id !== excludeId &&
+ (s.name.toLowerCase().includes(query.toLowerCase()) ||
+ s.code?.toLowerCase().includes(query.toLowerCase())),
+ )
+ : stations.filter((s) => s.id !== excludeId).slice(0, 20);
+
+ const displayValue = open ? query : (selectedStation?.name ?? "");
+
+ return (
+
+
+
+ {
+ setQuery(e.target.value);
+ setOpen(true);
+ }}
+ onFocus={() => {
+ if (disabled) return;
+ setQuery("");
+ setOpen(true);
+ onOpen?.();
+ }}
+ placeholder={placeholder}
+ className={`w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm placeholder-gray-400 ${
+ disabled
+ ? "text-gray-500 dark:text-gray-400 cursor-not-allowed"
+ : "text-gray-900 dark:text-white"
+ }`}
+ />
+ {value && !disabled && (
+ {
+ onSelect({ id: "", name: "", code: "", country: "" });
+ setQuery("");
+ }}
+ className="absolute right-3 p-0.5"
+ >
+
+
+ )}
+
+
+ {open && !disabled && (
+
+ {!query && recentIds.length > 0 && (
+
+
+ Recent
+
+ {recentIds
+ .map((id) => stations.find((s) => s.id === id))
+ .filter(Boolean)
+ .map((s) => (
+
{
+ onSelect(s!);
+ setOpen(false);
+ setQuery("");
+ }}
+ className="w-full flex items-center gap-2 px-2 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-left"
+ >
+
+
+ {s!.name}
+
+
+ ))}
+
+
+ )}
+ {filtered.length === 0 ? (
+
+ No stations found
+
+ ) : (
+ filtered.map((s) => (
+
{
+ onSelect(s);
+ setOpen(false);
+ setQuery("");
+ }}
+ className="w-full flex items-center gap-2 px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700 text-left transition-colors"
+ >
+
+
+
+ {s.name}
+
+ {s.code && (
+
+ {s.code}
+
+ )}
+
+
+ ))
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
index de306da8f..c0e1eacbe 100644
--- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
+++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
@@ -40,6 +40,8 @@ interface PassengerVoucherData {
// payment.amountMinor straight from the API) and must NOT be divided by 100 — as
// opposed to the normal case where fareMinor is genuine minor units (cents).
fareIsMajorUnits?: boolean;
+ /** Itemises the total when a booking was paid more than once (e.g. after a reschedule). */
+ paymentLines?: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>;
}
// ─── palette ───────────────────────────────────────────────────────────────
@@ -365,6 +367,43 @@ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: num
return y + cardH + 6;
}
+function drawPaymentBreakdown(
+ doc: jsPDF,
+ lines: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>,
+ y: number,
+ margin: number,
+ pageWidth: number,
+): number {
+ if (lines.length < 2) return y;
+
+ const rowH = 6;
+ const padX = 7;
+ const headerH = 7;
+ const cardH = headerH + lines.length * rowH + 3;
+
+ doc.setDrawColor(...HAIRLINE);
+ doc.setLineWidth(0.2);
+ doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'S');
+
+ doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...MUTED);
+ doc.text('PAYMENT BREAKDOWN', margin + padX, y + 5, { charSpace: 0.2 });
+
+ lines.forEach((line, i) => {
+ const rowY = y + headerH + i * rowH;
+ doc.setFontSize(8.5); doc.setFont('helvetica', 'normal'); doc.setTextColor(...BODY);
+ // No status suffix: the caller passes settled lines only, so every row here is paid.
+ doc.text(line.label, margin + padX, rowY + 4);
+ doc.setFont('helvetica', 'bold'); doc.setTextColor(...INK);
+ doc.text(
+ `${line.currency} ${(line.amountMinor / 100).toFixed(2)}`,
+ pageWidth - margin - padX,
+ rowY + 4,
+ { align: 'right' },
+ );
+ });
+
+ return y + cardH + 6;
+}
// ─── instructions ──────────────────────────────────────────────────────────
@@ -425,6 +464,7 @@ async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData):
y = drawPassengerDetails(doc, data, y, margin, pageW);
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW, data.fareIsMajorUnits);
+ y = drawPaymentBreakdown(doc, data.paymentLines ?? [], y, margin, pageW);
y = drawInstructions(doc, y, margin, pageW);
drawFooter(doc, data.createdAt, y);
}
@@ -474,15 +514,32 @@ interface VoucherData {
tickets?: Array<{ passengerName?: string; leg?: number; barcodePayload?: string }>;
// The actual settled amount/currency for this booking's payment — preferred over the
// ETB booking total once available, since it reflects what was really charged.
- payment?: { amountMinor?: number; currency?: string };
+ payment?: {
+ amountMinor?: number;
+ currency?: string;
+ /** Every line collected for this booking — see the API's payment-breakdown.util.ts. */
+ breakdown?: {
+ lines: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>;
+ totalPaidMinor: number | null;
+ totalPaidCurrency: string | null;
+ };
+ };
}
export const generateVoucherPDF = async (booking: VoucherData): Promise => {
// Currency always comes straight from the booking/payment data, never hardcoded — the
// settled payment currency when a payment has settled, otherwise the booking's own
// display currency (falling back to the internal ETB currency field).
- const settledAmountMinor = booking.payment?.amountMinor;
- const settledCurrency = booking.payment?.currency;
+ // `payment.amountMinor` is the ORIGINAL intent only — it never moves when a booking is
+ // rescheduled, so a voucher built from it reports the pre-reschedule amount forever (a 51.85
+ // booking moved to a 1752.34 journey still printed 51.85). `payment.breakdown` counts every
+ // line collected, including the reschedule fee and fare difference. It arrives in true minor
+ // units, so it is divided down to the major-unit basis the rest of this function expects.
+ const breakdown = booking.payment?.breakdown;
+ const settledAmountMinor =
+ breakdown?.totalPaidMinor != null ? breakdown.totalPaidMinor / 100 : booking.payment?.amountMinor;
+ const settledCurrency =
+ (breakdown?.totalPaidMinor != null ? breakdown.totalPaidCurrency : booking.payment?.currency) ?? undefined;
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
// Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
@@ -541,7 +598,10 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise =>
// This passenger's own fare, scaled onto the same currency/amount basis as the rest of
// the voucher — not the booking's overall total, and not an equal share of it.
- const passengerFareMinor = Math.round(p.fareMinor * fareScaleFactor);
+ const scaledFare = p.fareMinor * fareScaleFactor;
+ const passengerFareMinor = useSettledAmount
+ ? Math.round(scaledFare * 100) / 100
+ : Math.round(scaledFare);
await generatePassengerVoucherPDF({
bookingRef: booking.bookingRef,
@@ -560,6 +620,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise =>
fareMinor: passengerFareMinor,
currency: voucherCurrency,
fareIsMajorUnits: useSettledAmount,
+ paymentLines: grouped.size === 1 ? breakdown?.lines.filter((l) => l.settled) : undefined,
createdAt: booking.createdAt,
});
}
diff --git a/apps/edr-passenger-web/portal/src/utils/phone.ts b/apps/edr-passenger-web/portal/src/utils/phone.ts
new file mode 100644
index 000000000..d30ff28ea
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/utils/phone.ts
@@ -0,0 +1,16 @@
+
+export function normalizePhone(phone?: string | null): string | null {
+ if (!phone) return null;
+ const digits = phone.replace(/\D/g, '');
+ if (!digits) return null;
+ if (digits.startsWith('251')) return `+${digits}`;
+ if (digits.startsWith('0')) return `+251${digits.slice(1)}`;
+ if (digits.length === 9) return `+251${digits}`;
+ return `+${digits}`;
+}
+
+export function samePhone(a?: string | null, b?: string | null): boolean {
+ const left = normalizePhone(a);
+ const right = normalizePhone(b);
+ return !!left && !!right && left === right;
+}