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 26f93bd43..0c3d8f73a 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") } @@ -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 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 bd8372b29..1b80d253b 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -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 }, 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/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 482c34268..29d69c94d 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 { @@ -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") 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 fc958be70..fc3328605 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -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'); 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 714b02170..534f6df08 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: { @@ -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!; 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..8024556a9 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts @@ -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); + } +} 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..d41fbc9a8 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts @@ -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; +} 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..4b2019938 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -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 { + 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; + } +} 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 00aff331b..0e0bbb0fc 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -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() { 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/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index 873e81578..a97254898 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -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('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() { )} + {activeTab === 'reschedule' && } + {activeTab === 'configurations' && (

Rate Limiting (requests / minute / IP)

@@ -224,3 +227,125 @@ export default function SettingsPage() {
); } + +type PolicyForm = NonNullable; + +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([]); + const [forms, setForms] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(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) => + 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

Loading...

; + + return ( +
+
+

Rescheduling rules per fare class

+

+ 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. +

+
+ {rows.map((r) => { + const f = forms[r.coachTypeId]; + return ( +
+
+
+ {r.code} + — {r.name} + {!r.policy && no policy yet (rescheduling disabled)} +
+ +
+
+
+ + setField(r.coachTypeId, { feePercent: Number(e.target.value) })} /> +
+
+ + setField(r.coachTypeId, { feeMinMinor: minor(e.target.value) })} /> +
+
+ + setField(r.coachTypeId, { cutoffMinutes: Number(e.target.value) })} /> +
+
+ + +
+
+ + +
+
+ + setField(r.coachTypeId, { sameDayFeePercent: Number(e.target.value) })} /> +
+
+ + setField(r.coachTypeId, { sameDayFeeMinMinor: minor(e.target.value) })} /> +
+
+ +
+
+
+ ); + })} + {rows.length === 0 &&

No passenger coach types found.

} + {message && {message}} +
+ ); +} 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 7350928b3..b9b7d41f1 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -527,6 +527,28 @@ export const systemConfigApi = { update: (data: Record) => apiClient.patch>('/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('/reschedule/policies'), + update: (coachTypeId: string, data: Partial>) => + apiClient.patch(`/reschedule/policies/${coachTypeId}`, data), +}; + // 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..34f5b59ec 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 @@ -1022,6 +1022,15 @@ function BookingDetailContent() { )} + {!booking.isPackageBooking && ["ONE_WAY", "ROUND_TRIP"].includes(booking.bookingType) && !booking.outboundBoardedAt && ( + + )} )} 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..85cdc5786 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx @@ -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(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); + const [seatIds, setSeatIds] = useState([]); + const [done, setDone] = useState<{ status: string } | null>(null); + const [error, setError] = useState(null); + + const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery({ + queryKey: ["reschedule-options", ref], + queryFn: () => apiClient.get(`/bookings/${ref}/reschedule`), + enabled: !!ref, + 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); + 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({ + 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 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({ + 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: seatIds.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 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

Missing booking reference.

; + 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}.

+ +
+
+ ); + } + + 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 && ( + + )} +
+
+ ); + } + + const routeLocked = !leg.policy?.routeChangeAllowed; + const canSearch = !!date && !!originId && !!destinationId && originId !== destinationId; + + return ( + + +

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) => ( + + ))} +
+ )} + + {leg.policy && ( +
+
Your fare rules
+
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.
+
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"}.
+
Changes close {leg.policy.cutoffMinutes} minutes before departure.
+
+ )} + + {!leg.canReschedule && ( +
+ +
{leg.blockers.map((b) =>
{b}
)}
+
+ )} + + {leg.canReschedule && ( + <> + {/* Step 1: route + date */} +
+ + + + +
+ + {/* 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 ( + + ); + })} +
+ )} + + {/* Step 3: seats (same class as booked) */} + {scheduleId && ( +
+

Pick {leg.seatCount} seat{leg.seatCount > 1 ? "s" : ""} ({seatIds.length}/{leg.seatCount})

+ {loadingSeats && } + {(seatMap?.coaches ?? []).map((coach: any) => ( +
+
{coach.name} · {coach.coachTypeName}
+
+ {(coach.seats ?? []).map((seat: any) => { + const picked = seatIds.includes(seat.id); + const free = seat.status === "AVAILABLE"; + return ( + + ); + })} +
+
+ ))} + {seatMap && (seatMap.coaches ?? []).length === 0 &&

No coach of your class on this train.

} +
+ )} + + {/* Step 4: quote + confirm */} + {quoteBody && ( +
+ {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}
} + + + )} +
+ )} + + )} +
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return
{label}{value}
; +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
+
{children}
+
+
+ ); +} + +export default function ReschedulePage() { + return ( + }> + + + ); +}