feat: ( reschedule ) implement booking reschedule

This commit is contained in:
Abubeker
2026-08-21 08:18:03 +00:00
parent e174efb8e0
commit 80a7a2f702
27 changed files with 1450 additions and 51 deletions

View File

@@ -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;

View File

@@ -80,6 +80,7 @@ model CoachType {
updatedAt DateTime @updatedAt
coaches Coach[]
seatClasses SeatClass[]
reschedulePolicy ReschedulePolicy?
@@schema("passenger")
}
@@ -567,6 +568,7 @@ model Booking {
foodOrders FoodOrder[]
agentBooking AgentBooking?
modifications BookingModification[]
reschedules BookingReschedule[]
cancellation BookingCancellation?
baggage BaggageBooking[]
excessBaggageCharges ExcessBaggageCharge[]
@@ -1204,6 +1206,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

View File

@@ -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' },

View File

@@ -63,6 +63,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: [
@@ -149,6 +150,7 @@ import { EOtpType } from "@tria-plc/iamapi-common";
TasksModule,
AppReleasesModule,
ConfigurableFareModule,
RescheduleModule,
],
providers: [
{ provide: APP_FILTER, useClass: DeleteExceptionFilter },

View File

@@ -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];

View File

@@ -28,7 +28,7 @@ type EmployeeLike = {
delegatedPositions?: PositionLike[];
};
type MeLikeUser = {
export type MeLikeUser = {
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?: EmployeeLike | EmployeeLike[] | null;

View File

@@ -26,7 +26,6 @@ import { BookingsService } from "./bookings.service";
import { GuestBookingService } from "./guest-booking.service";
import {
CreateBookingDto,
ModifyBookingDto,
CancelBookingDto,
} from "./bookings.dto";
import {
@@ -663,22 +662,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")

View File

@@ -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;

View File

@@ -5,7 +5,7 @@ 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 { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service';
@@ -1905,7 +1905,7 @@ export class BookingsService {
};
}
private async getBaseFare(
async getBaseFare(
scheduleId: string,
seatClassId: string,
segmentRoute?: string,
@@ -2231,22 +2231,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');

View File

@@ -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;

View File

@@ -74,6 +74,6 @@ function rabbitMQImport(): DynamicModule[] {
PaymentSyncService,
ServiceAuthGuard,
],
exports: [PaymentClientService, PaymentsService],
exports: [PaymentClientService, PaymentsService, SupplementaryChargesService],
})
export class PaymentsModule {}

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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: {
@@ -53,6 +55,8 @@ export class SupplementaryChargesService {
reason: string;
notes?: 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 },
@@ -64,7 +68,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,
@@ -170,6 +174,7 @@ export class SupplementaryChargesService {
providerTxnId: providerTxnId ?? null,
},
});
this.eventEmitter.emit('supplementary-charge.paid', { chargeId: id });
}
return updated!;

View File

@@ -69,6 +69,7 @@ describe('SupplementaryChargesService — payment methods', () => {
{} as any,
paymentClient as any,
new CurrencyService(prisma as any),
{ emit: jest.fn() } as any,
);
};

View File

@@ -0,0 +1,53 @@
import { Body, Controller, 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 { 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: 'Reschedule policy per coach type (fare class)' })
listPolicies() {
return this.service.listPolicies();
}
@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);
}
@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);
}
}

View File

@@ -0,0 +1,70 @@
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;
}
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;
}

View File

@@ -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 {}

View File

@@ -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');
});
});

View File

@@ -0,0 +1,541 @@
import {
BadRequestException,
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 { hasPassengerPermission, MeLikeUser } from '../../common/passenger-permission.util';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.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 { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto';
export const RESCHEDULE_CHARGE_REASON = 'RESCHEDULE';
export const SUPPLEMENTARY_CHARGE_PAID_EVENT = 'supplementary-charge.paid';
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 };
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 ─────────────────────────────────────────────────────────
async listPolicies() {
const coachTypes = await this.prisma.coachType.findMany({
where: { type: { notIn: ['dining', 'baggage'] } },
include: { reschedulePolicy: true },
orderBy: { code: 'asc' },
});
return coachTypes.map((ct) => ({
coachTypeId: ct.id,
code: ct.code,
name: ct.name,
policy: ct.reschedulePolicy,
}));
}
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<number> {
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 ────────────────────────────────────────────────────────────
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 (hasPassengerPermission(user, PASSENGER_PERMS.bookings.reschedule)) return booking;
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;
}
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;
}
}

View File

@@ -738,9 +738,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() {

View File

@@ -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();

View File

@@ -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,

View File

@@ -2,9 +2,9 @@
import { useState, useEffect } from 'react';
import { Save } from 'lucide-react';
import { systemConfigApi } from '@/lib/api';
import { systemConfigApi, reschedulePolicyApi, type ReschedulePolicyRow } from '@/lib/api';
type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
type Tab = 'general' | 'payment' | 'integrations' | 'configurations' | 'reschedule';
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>('general');
@@ -57,6 +57,7 @@ export default function SettingsPage() {
const tabs: { id: Tab; label: string }[] = [
{ id: 'general', label: 'General' },
{ id: 'configurations', label: 'Configurations' },
{ id: 'reschedule', label: 'Reschedule Policy' },
];
return (
@@ -111,6 +112,8 @@ export default function SettingsPage() {
</div>
)}
{activeTab === 'reschedule' && <ReschedulePolicyTab />}
{activeTab === 'configurations' && (
<div className="card space-y-6">
<h3 className="text-lg font-semibold text-foreground">Rate Limiting (requests / minute / IP)</h3>
@@ -224,3 +227,125 @@ export default function SettingsPage() {
</div>
);
}
type PolicyForm = NonNullable<ReschedulePolicyRow['policy']>;
const EMPTY_POLICY: PolicyForm = {
feePercent: 0, feeMinMinor: 0, routeChangeAllowed: true, sameDayAllowed: true,
sameDayFeePercent: 0, sameDayFeeMinMinor: 0, cutoffMinutes: 60, isActive: true,
};
/** Policy §3 — one editable row per fare class (HSC = Standard, HBC = Flex, SBC = Premium). Money is entered in ETB, stored in minor units. */
function ReschedulePolicyTab() {
const [rows, setRows] = useState<ReschedulePolicyRow[]>([]);
const [forms, setForms] = useState<Record<string, PolicyForm>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [message, setMessage] = useState('');
useEffect(() => {
reschedulePolicyApi.list()
.then((data) => {
const list = Array.isArray(data) ? data : [];
setRows(list);
setForms(Object.fromEntries(list.map((r) => [r.coachTypeId, { ...EMPTY_POLICY, ...(r.policy ?? {}) }])));
})
.catch(() => setMessage('Failed to load policies.'))
.finally(() => setLoading(false));
}, []);
const setField = (id: string, patch: Partial<PolicyForm>) =>
setForms((f) => ({ ...f, [id]: { ...f[id], ...patch } }));
const save = async (id: string) => {
setSaving(id);
setMessage('');
try {
await reschedulePolicyApi.update(id, forms[id]);
setMessage('Saved.');
} catch {
setMessage('Failed to save.');
} finally {
setSaving(null);
}
};
const etb = (minor: number) => String(minor / 100);
const minor = (etbValue: string) => Math.round(Number(etbValue || 0) * 100);
if (loading) return <div className="card"><p className="text-sm text-muted-foreground">Loading...</p></div>;
return (
<div className="card space-y-6">
<div>
<h3 className="text-lg font-semibold text-foreground">Rescheduling rules per fare class</h3>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
{rows.map((r) => {
const f = forms[r.coachTypeId];
return (
<div key={r.coachTypeId} className="border border-border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<span className="font-semibold text-foreground">{r.code}</span>
<span className="text-muted-foreground"> {r.name}</span>
{!r.policy && <span className="ml-2 text-xs text-amber-600">no policy yet (rescheduling disabled)</span>}
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={f.isActive} onChange={(e) => setField(r.coachTypeId, { isActive: e.target.checked })} />
Enabled
</label>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-1">
<label className="label">Fee (% of fare)</label>
<input type="number" min="0" max="100" className="input" value={f.feePercent} onChange={(e) => setField(r.coachTypeId, { feePercent: Number(e.target.value) })} />
</div>
<div className="space-y-1">
<label className="label">Minimum fee (ETB)</label>
<input type="number" min="0" step="0.01" className="input" value={etb(f.feeMinMinor)} onChange={(e) => setField(r.coachTypeId, { feeMinMinor: minor(e.target.value) })} />
</div>
<div className="space-y-1">
<label className="label">Cutoff before departure (min)</label>
<input type="number" min="0" className="input" value={f.cutoffMinutes} onChange={(e) => setField(r.coachTypeId, { cutoffMinutes: Number(e.target.value) })} />
</div>
<div className="space-y-1">
<label className="label">Route change</label>
<label className="flex items-center gap-2 text-sm h-10">
<input type="checkbox" checked={f.routeChangeAllowed} onChange={(e) => setField(r.coachTypeId, { routeChangeAllowed: e.target.checked })} />
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Same-day change</label>
<label className="flex items-center gap-2 text-sm h-10">
<input type="checkbox" checked={f.sameDayAllowed} onChange={(e) => setField(r.coachTypeId, { sameDayAllowed: e.target.checked })} />
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Same-day fee (% of fare)</label>
<input type="number" min="0" max="100" className="input" disabled={!f.sameDayAllowed} value={f.sameDayFeePercent} onChange={(e) => setField(r.coachTypeId, { sameDayFeePercent: Number(e.target.value) })} />
</div>
<div className="space-y-1">
<label className="label">Same-day minimum fee (ETB)</label>
<input type="number" min="0" step="0.01" className="input" disabled={!f.sameDayAllowed} value={etb(f.sameDayFeeMinMinor)} onChange={(e) => setField(r.coachTypeId, { sameDayFeeMinMinor: minor(e.target.value) })} />
</div>
<div className="flex items-end">
<button className="btn btn-primary flex items-center gap-2" disabled={saving === r.coachTypeId} onClick={() => save(r.coachTypeId)}>
<Save className="h-4 w-4" />
{saving === r.coachTypeId ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
);
})}
{rows.length === 0 && <p className="text-sm text-muted-foreground">No passenger coach types found.</p>}
{message && <span className="text-sm text-muted-foreground">{message}</span>}
</div>
);
}

View File

@@ -527,6 +527,28 @@ export const systemConfigApi = {
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/config', data),
};
// Reschedule Policy API (one row per coach type = fare class)
export interface ReschedulePolicyRow {
coachTypeId: string;
code: string;
name: string;
policy: {
feePercent: number;
feeMinMinor: number;
routeChangeAllowed: boolean;
sameDayAllowed: boolean;
sameDayFeePercent: number;
sameDayFeeMinMinor: number;
cutoffMinutes: number;
isActive: boolean;
} | null;
}
export const reschedulePolicyApi = {
list: () => apiClient.get<ReschedulePolicyRow[]>('/reschedule/policies'),
update: (coachTypeId: string, data: Partial<NonNullable<ReschedulePolicyRow['policy']>>) =>
apiClient.patch<any>(`/reschedule/policies/${coachTypeId}`, data),
};
// App Releases API
export const appReleasesApi = {
getAll: async () => {

View File

@@ -1022,6 +1022,15 @@ function BookingDetailContent() {
</>
)}
</button>
{!booking.isPackageBooking && ["ONE_WAY", "ROUND_TRIP"].includes(booking.bookingType) && !booking.outboundBoardedAt && (
<button
onClick={() => router.push(`/booking/reschedule?ref=${booking.bookingRef}`)}
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 flex items-center gap-2"
>
<Clock className="w-4 h-4" />
Reschedule
</button>
)}
</>
)}
</div>

View File

@@ -0,0 +1,351 @@
"use client";
import { Suspense, useEffect, useMemo, 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 ModernDatePicker from "@/components/ModernDatePicker";
import { formatTime } from "@/utils/format";
type Station = { id: string; name: string; code?: string };
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)}`;
function ReschedulePageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const ref = searchParams.get("ref") || "";
const [legNo, setLegNo] = useState(1);
const [date, setDate] = useState<Date | undefined>(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<any | null>(null);
const [seatIds, setSeatIds] = useState<string[]>([]);
const [done, setDone] = useState<{ status: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({
queryKey: ["reschedule-options", ref],
queryFn: () => apiClient.get<Options>(`/bookings/${ref}/reschedule`),
enabled: !!ref,
retry: false,
});
const { data: stations = [] } = useQuery<Station[]>({
queryKey: ["stations"],
queryFn: () => apiClient.get<Station[]>("/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);
setSeatIds([]);
setSearched(null);
}, [leg?.leg, leg?.scheduleId]);
const journeyDirection = leg?.leg === 2 ? "RETURN" : options?.bookingType === "ROUND_TRIP" ? "OUTBOUND" : "ONE_WAY";
const { data: schedules = [], isFetching: searching } = useQuery<any[]>({
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<any>({
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 quoteBody = leg && scheduleId && seatIds.length === leg.seatCount
? { leg: leg.leg, newScheduleId: scheduleId, newOriginStationId: originId, newDestinationStationId: destinationId, newSeatIds: seatIds }
: null;
const { data: quote, isFetching: quoting } = useQuery<Quote>({
queryKey: ["reschedule-quote", ref, quoteBody],
queryFn: () => apiClient.post<Quote>(`/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: seatIds.map((seatId, i) => ({ passengerId: `reschedule-${ref}-${i}`, seatId })),
});
return apiClient.post<any>(`/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 toggleSeat = (id: string) => {
setSeatIds((prev) => {
if (prev.includes(id)) return prev.filter((s) => s !== id);
if (prev.length >= (leg?.seatCount ?? 1)) return [...prev.slice(1), id];
return [...prev, id];
});
};
const stationName = (id: string | null) => stations.find((s) => s.id === id)?.name ?? id ?? "—";
if (!ref) return <Shell><p className="text-gray-600">Missing booking reference.</p></Shell>;
if (loadingOptions) return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
if (optionsError || !options || !leg) {
return <Shell><p className="text-red-600">{(optionsError as any)?.response?.data?.message || "This booking cannot be rescheduled."}</p></Shell>;
}
if (done) {
return (
<Shell>
<div className="text-center space-y-4">
<CheckCircle2 className="w-14 h-14 text-green-600 mx-auto" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Booking rescheduled</h1>
<p className="text-gray-600 dark:text-gray-400">New tickets have been issued for booking {ref}.</p>
<button className="btn-primary" onClick={() => router.push(`/booking/detail?ref=${ref}`)}>View booking</button>
</div>
</Shell>
);
}
if (options.pending) {
return (
<Shell>
<div className="space-y-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Reschedule awaiting payment</h1>
<p className="text-gray-600 dark:text-gray-400">
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.
</p>
{options.pending.paymentToken && (
<button className="btn-primary" onClick={() => router.push(`/pay-balance/${options.pending!.paymentToken}`)}>Pay now</button>
)}
</div>
</Shell>
);
}
const routeLocked = !leg.policy?.routeChangeAllowed;
const canSearch = !!date && !!originId && !!destinationId && originId !== destinationId;
return (
<Shell>
<button onClick={() => router.push(`/booking/detail?ref=${ref}`)} className="flex items-center gap-1 text-sm text-gray-500 mb-4">
<ChevronLeft className="w-4 h-4" /> Back to booking
</button>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-1">Reschedule {ref}</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{leg.passengerNames.join(", ")} · currently {format(new Date(leg.departureAt), "EEE dd MMM, HH:mm")} · {stationName(leg.originStationId)} {stationName(leg.destinationStationId)}
</p>
{options.legs.length > 1 && (
<div className="flex gap-2 mb-6">
{options.legs.map((l) => (
<button key={l.leg} onClick={() => setLegNo(l.leg)} className={`px-4 py-2 rounded-lg border text-sm ${legNo === l.leg ? "border-primary text-primary" : "border-gray-200 text-gray-600"}`}>
{l.leg === 1 ? "Outbound" : "Return"} · {format(new Date(l.departureAt), "dd MMM")}
</button>
))}
</div>
)}
{leg.policy && (
<div className="rounded-xl bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 text-sm mb-6 space-y-1">
<div className="font-semibold text-gray-900 dark:text-white">Your fare rules</div>
<div>Change fee: {leg.policy.feePercent > 0 || leg.policy.feeMinMinor > 0 ? `${leg.policy.feePercent}% of fare (min ${etb(leg.policy.feeMinMinor)})` : "Free"}. A higher new fare is payable; a lower one is not refunded.</div>
<div>Route change: {leg.policy.routeChangeAllowed ? "allowed" : "not permitted"}. Same-day change: {leg.policy.sameDayAllowed ? (leg.policy.sameDayFeePercent > 0 || leg.policy.sameDayFeeMinMinor > 0 ? `${leg.policy.sameDayFeePercent}% (min ${etb(leg.policy.sameDayFeeMinMinor)})` : "free") : "not permitted"}.</div>
<div>Changes close {leg.policy.cutoffMinutes} minutes before departure.</div>
</div>
)}
{!leg.canReschedule && (
<div className="rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 p-4 text-sm text-red-700 mb-6 flex gap-2">
<AlertCircle className="w-5 h-5 shrink-0" />
<div>{leg.blockers.map((b) => <div key={b}>{b}</div>)}</div>
</div>
)}
{leg.canReschedule && (
<>
{/* Step 1: route + date */}
<div className="grid md:grid-cols-4 gap-3 mb-4">
<select className="input" value={originId} disabled={routeLocked} onChange={(e) => setOriginId(e.target.value)}>
{stations.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<select className="input" value={destinationId} disabled={routeLocked} onChange={(e) => setDestinationId(e.target.value)}>
{stations.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<ModernDatePicker value={date} onChange={setDate} minDate={new Date()} />
<button className="btn-primary" disabled={!canSearch || searching} onClick={() => { setSchedule(null); setSeatIds([]); setSearched({ originId, destinationId, date: format(date!, "yyyy-MM-dd") }); }}>
{searching ? "Searching..." : "Find trains"}
</button>
</div>
{/* Step 2: schedule */}
{searched && !searching && schedules.length === 0 && <p className="text-sm text-gray-500 mb-4">No trains on that day.</p>}
{schedules.length > 0 && (
<div className="space-y-2 mb-6">
{schedules.map((s: any) => {
const id = s.scheduleId || s.id;
const selected = scheduleId === id;
return (
<button key={id} disabled={s.hasAvailability === false} onClick={() => { setSchedule(s); setSeatIds([]); }}
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`}>
<div>
<div className="font-semibold text-gray-900 dark:text-white">{s.trainName || s.trainNumber}</div>
<div className="text-sm text-gray-500">{formatTime(s.departureAt)} <ArrowRight className="inline w-3 h-3" /> {formatTime(s.arrivalAt)}</div>
</div>
<div className="text-xs text-gray-500">{s.hasAvailability === false ? "Sold out" : "Select"}</div>
</button>
);
})}
</div>
)}
{/* Step 3: seats (same class as booked) */}
{scheduleId && (
<div className="mb-6">
<h2 className="font-semibold text-gray-900 dark:text-white mb-2">Pick {leg.seatCount} seat{leg.seatCount > 1 ? "s" : ""} ({seatIds.length}/{leg.seatCount})</h2>
{loadingSeats && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
{(seatMap?.coaches ?? []).map((coach: any) => (
<div key={coach.id} className="mb-3">
<div className="text-xs text-gray-500 mb-1">{coach.name} · {coach.coachTypeName}</div>
<div className="flex flex-wrap gap-1">
{(coach.seats ?? []).map((seat: any) => {
const picked = seatIds.includes(seat.id);
const free = seat.status === "AVAILABLE";
return (
<button key={seat.id} disabled={!free} onClick={() => toggleSeat(seat.id)} title={`${seat.seatNumber} ${seat.bedPosition ?? ""} ${seat.status}`}
className={`w-11 h-9 rounded text-xs border ${picked ? "bg-primary text-white border-primary" : free ? "bg-white dark:bg-gray-800 border-gray-300" : "bg-gray-200 dark:bg-gray-700 text-gray-400 border-transparent"}`}>
{seat.seatNumber}{seat.bedPosition ? seat.bedPosition[0].toUpperCase() : ""}
</button>
);
})}
</div>
</div>
))}
{seatMap && (seatMap.coaches ?? []).length === 0 && <p className="text-sm text-gray-500">No coach of your class on this train.</p>}
</div>
)}
{/* Step 4: quote + confirm */}
{quoteBody && (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-2 text-sm">
{quoting && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
{quote && (
<>
<Row label="Original fare" value={etb(quote.oldFareMinor)} />
<Row label="New fare" value={etb(quote.newFareMinor)} />
<Row label={quote.fareDifferenceMinor >= 0 ? "Fare difference" : "Fare difference (not refunded)"} value={etb(Math.max(0, quote.fareDifferenceMinor))} />
<Row label={`Change fee${quote.isSameDay ? " (same-day)" : ""}`} value={etb(quote.feeMinor)} />
<div className="flex justify-between font-bold text-base pt-2 border-t border-gray-200 dark:border-gray-700"><span>Total due now</span><span>{etb(quote.amountDueMinor)}</span></div>
{quote.blockers.length > 0 && (
<div className="text-red-600 flex gap-2"><AlertCircle className="w-4 h-4 shrink-0 mt-0.5" /><div>{quote.blockers.map((b) => <div key={b}>{b}</div>)}</div></div>
)}
{error && <div className="text-red-600">{error}</div>}
<button className="btn-primary w-full mt-2" disabled={!quote.allowed || confirm.isPending} onClick={() => { setError(null); confirm.mutate(); }}>
{confirm.isPending ? "Processing..." : quote.amountDueMinor > 0 ? `Continue to payment · ${etb(quote.amountDueMinor)}` : "Confirm reschedule"}
</button>
</>
)}
</div>
)}
</>
)}
</Shell>
);
}
function Row({ label, value }: { label: string; value: string }) {
return <div className="flex justify-between text-gray-700 dark:text-gray-300"><span>{label}</span><span>{value}</span></div>;
}
function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
<div className="container mx-auto px-4">
<div className="max-w-3xl mx-auto bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">{children}</div>
</div>
</div>
);
}
export default function ReschedulePage() {
return (
<Suspense fallback={<Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>}>
<ReschedulePageContent />
</Suspense>
);
}