diff --git a/apps/edr-passenger-api/prisma/migrations/20260821000001_add_reschedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260821000001_add_reschedule/migration.sql new file mode 100644 index 000000000..0d98e6d0e --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260821000001_add_reschedule/migration.sql @@ -0,0 +1,82 @@ +-- Rescheduling (passenger policy §3). One policy row per fare class — fare classes map 1:1 onto +-- coach types (HSC = Standard, HBC = Flex, SBC = Premium) — plus a per-leg request table. +-- Additive and idempotent; the seed below only inserts for coach types that exist. + +-- CreateTable +CREATE TABLE IF NOT EXISTS "passenger"."ReschedulePolicy" ( + "id" TEXT NOT NULL, + "coachTypeId" TEXT NOT NULL, + "feePercent" INTEGER NOT NULL DEFAULT 0, + "feeMinMinor" INTEGER NOT NULL DEFAULT 0, + "routeChangeAllowed" BOOLEAN NOT NULL DEFAULT true, + "sameDayAllowed" BOOLEAN NOT NULL DEFAULT true, + "sameDayFeePercent" INTEGER NOT NULL DEFAULT 0, + "sameDayFeeMinMinor" INTEGER NOT NULL DEFAULT 0, + "cutoffMinutes" INTEGER NOT NULL DEFAULT 60, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ReschedulePolicy_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "ReschedulePolicy_coachTypeId_key" ON "passenger"."ReschedulePolicy"("coachTypeId"); + +DO $$ BEGIN + ALTER TABLE "passenger"."ReschedulePolicy" + ADD CONSTRAINT "ReschedulePolicy_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") + REFERENCES "passenger"."CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "passenger"."BookingReschedule" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "leg" INTEGER NOT NULL DEFAULT 1, + "status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT', + "requestedBy" TEXT NOT NULL, + "oldScheduleId" TEXT NOT NULL, + "newScheduleId" TEXT NOT NULL, + "oldOriginStationId" TEXT, + "oldDestinationStationId" TEXT, + "newOriginStationId" TEXT NOT NULL, + "newDestinationStationId" TEXT NOT NULL, + "oldSeatIds" TEXT[], + "newSeatIds" TEXT[], + "holdId" TEXT, + "oldFareMinor" INTEGER NOT NULL, + "newFareMinor" INTEGER NOT NULL, + "fareDifferenceMinor" INTEGER NOT NULL, + "feeMinor" INTEGER NOT NULL, + "amountDueMinor" INTEGER NOT NULL, + "isSameDay" BOOLEAN NOT NULL DEFAULT false, + "isRouteChange" BOOLEAN NOT NULL DEFAULT false, + "supplementaryChargeId" TEXT, + "expiresAt" TIMESTAMP(3), + "appliedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BookingReschedule_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "BookingReschedule_supplementaryChargeId_key" ON "passenger"."BookingReschedule"("supplementaryChargeId"); +CREATE INDEX IF NOT EXISTS "BookingReschedule_bookingId_status_idx" ON "passenger"."BookingReschedule"("bookingId", "status"); + +DO $$ BEGIN + ALTER TABLE "passenger"."BookingReschedule" + ADD CONSTRAINT "BookingReschedule_bookingId_fkey" FOREIGN KEY ("bookingId") + REFERENCES "passenger"."Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- Seed the policy doc's §3 values. Same-day fee is stored as an absolute rule per class: +-- Flex's "50% of the Standard fee" (30% / min 500 ETB) is 15% / min 250 ETB. +INSERT INTO "passenger"."ReschedulePolicy" + ("id", "coachTypeId", "feePercent", "feeMinMinor", "routeChangeAllowed", "sameDayAllowed", "sameDayFeePercent", "sameDayFeeMinMinor", "cutoffMinutes", "updatedAt") +SELECT gen_random_uuid()::text, ct."id", v."feePercent", v."feeMinMinor", v."routeChangeAllowed", v."sameDayAllowed", v."sameDayFeePercent", v."sameDayFeeMinMinor", v."cutoffMinutes", CURRENT_TIMESTAMP +FROM (VALUES + ('HSC', 30, 50000, false, false, 0, 0, 120), + ('HBC', 0, 0, true, true, 15, 25000, 60), + ('SBC', 0, 0, true, true, 0, 0, 60) +) AS v("code", "feePercent", "feeMinMinor", "routeChangeAllowed", "sameDayAllowed", "sameDayFeePercent", "sameDayFeeMinMinor", "cutoffMinutes") +JOIN "passenger"."CoachType" ct ON ct."code" = v."code" +ON CONFLICT ("coachTypeId") DO NOTHING; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 22bfa8f40..693bf1fb2 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -80,6 +80,7 @@ model CoachType { updatedAt DateTime @updatedAt coaches Coach[] seatClasses SeatClass[] + reschedulePolicy ReschedulePolicy? @@schema("passenger") } @@ -568,6 +569,7 @@ model Booking { foodOrders FoodOrder[] agentBooking AgentBooking? modifications BookingModification[] + reschedules BookingReschedule[] cancellation BookingCancellation? baggage BaggageBooking[] excessBaggageCharges ExcessBaggageCharge[] @@ -1205,6 +1207,61 @@ model AgentCommission { @@schema("passenger") } +/// Rescheduling rule per fare class. Fare families from the passenger policy map 1:1 onto +/// coach types (HSC = Standard, HBC = Flex, SBC = Premium). Seeded by migration from the policy +/// doc; edited in backoffice Settings → Reschedule Policy. +model ReschedulePolicy { + id String @id @default(uuid()) + coachTypeId String @unique + feePercent Int @default(0) // % of the leg's original fare + feeMinMinor Int @default(0) // fee floor, ETB minor units + routeChangeAllowed Boolean @default(true) + sameDayAllowed Boolean @default(true) + sameDayFeePercent Int @default(0) // same-day change: % of the leg's original fare (replaces feePercent) + sameDayFeeMinMinor Int @default(0) // same-day change: fee floor, ETB minor units + cutoffMinutes Int @default(60) // reject when departure - now < this + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + coachType CoachType @relation(fields: [coachTypeId], references: [id]) + + @@schema("passenger") +} + +/// One reschedule request for one leg of a booking. PENDING_PAYMENT while the supplementary +/// charge is unpaid; APPLIED once the booking was moved; EXPIRED when the payment deadline passed. +model BookingReschedule { + id String @id @default(uuid()) + bookingId String + leg Int @default(1) // 1 = outbound, 2 = return + status String @default("PENDING_PAYMENT") // PENDING_PAYMENT | APPLIED | EXPIRED + requestedBy String // passengerId or IAM user id + oldScheduleId String + newScheduleId String + oldOriginStationId String? + oldDestinationStationId String? + newOriginStationId String + newDestinationStationId String + oldSeatIds String[] + newSeatIds String[] + holdId String? + oldFareMinor Int + newFareMinor Int + fareDifferenceMinor Int // new - old; negative = forfeited for now + feeMinor Int + amountDueMinor Int // fee + max(0, difference) + isSameDay Boolean @default(false) + isRouteChange Boolean @default(false) + supplementaryChargeId String? @unique + expiresAt DateTime? + appliedAt DateTime? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + + @@index([bookingId, status]) + @@schema("passenger") +} + model BookingModification { id String @id @default(uuid()) bookingId String diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 313ce34b0..8768ba899 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -657,6 +657,7 @@ async function seedNotificationTemplates() { { id: uuidv4(), code: 'payment.succeeded', channel: 'SMS', subject: 'Payment Received', bodyTemplate: 'Payment of {{amount}} {{currency}} received for booking {{bookingRef}}.' }, { id: uuidv4(), code: 'payment.failed', channel: 'SMS', subject: 'Payment Failed', bodyTemplate: 'Payment for booking {{bookingRef}} could not be completed. Please try again.' }, { id: uuidv4(), code: 'booking.cancelled', channel: 'EMAIL', subject: 'Booking Cancelled', bodyTemplate: 'Your booking {{bookingRef}} has been cancelled. Refund: {{refundAmount}} {{currency}}.' }, + { id: uuidv4(), code: 'booking.rescheduled', channel: 'EMAIL', subject: 'Booking Rescheduled', bodyTemplate: 'Your {{leg}} journey on booking {{bookingRef}} has been rescheduled. New tickets have been issued. Change fee: {{feeAmount}} {{currency}}.' }, // Templates below are not wired to handlers yet (Phase 2 — full event coverage). { id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' }, { id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' }, diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index e3b85c21f..ff77b8b07 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -64,6 +64,7 @@ import { ConfigurableFareModule } from "./modules/configurable-fare/configurable import { SegmentFareSeeder } from "./seed/segment-fare.seeder"; import { EOtpType } from "@tria-plc/iamapi-common"; +import { RescheduleModule } from './modules/reschedule/reschedule.module'; @Module({ imports: [ @@ -164,6 +165,7 @@ import { EOtpType } from "@tria-plc/iamapi-common"; TasksModule, AppReleasesModule, ConfigurableFareModule, + RescheduleModule, ], providers: [ { provide: APP_FILTER, useClass: DeleteExceptionFilter }, diff --git a/apps/edr-passenger-api/src/common/audit.actions.ts b/apps/edr-passenger-api/src/common/audit.actions.ts index f9ee389db..8ac818e48 100644 --- a/apps/edr-passenger-api/src/common/audit.actions.ts +++ b/apps/edr-passenger-api/src/common/audit.actions.ts @@ -86,6 +86,8 @@ export const AUDIT_ENTITIES = { // Operations Ticket: 'Ticket', Booking: 'Booking', + BookingReschedule: 'BookingReschedule', + ReschedulePolicy: 'ReschedulePolicy', } as const; export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES]; diff --git a/apps/edr-passenger-api/src/common/passenger-permission.util.ts b/apps/edr-passenger-api/src/common/passenger-permission.util.ts index cc94b0e0c..628d39dfe 100644 --- a/apps/edr-passenger-api/src/common/passenger-permission.util.ts +++ b/apps/edr-passenger-api/src/common/passenger-permission.util.ts @@ -28,7 +28,7 @@ type EmployeeLike = { delegatedPositions?: PositionLike[]; }; -type MeLikeUser = { +export type MeLikeUser = { roles?: { key?: string }[]; permissions?: PermissionLike[]; employee?: EmployeeLike | EmployeeLike[] | null; diff --git a/apps/edr-passenger-api/src/common/utils/phone.utils.ts b/apps/edr-passenger-api/src/common/utils/phone.utils.ts index 73ca8dc16..501d86427 100644 --- a/apps/edr-passenger-api/src/common/utils/phone.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/phone.utils.ts @@ -1,15 +1,3 @@ -/** - * Phone normalisation shared by any lookup that has to match a number a customer typed - * against one already stored. Ethiopian numbers reach us in three interchangeable shapes - * (+2519…, 2519…, 09…) depending on whether they came from IAM, a guest booking form or a - * saved profile, so an exact-string match silently misses. - */ - -/** - * Returns all plausible normalised variants of a raw phone string so that the - * DB query matches regardless of how the number was stored (local 09… vs international +251…). - * Returns an empty array when the input is clearly invalid (< 7 digits). - */ export function normalizePhoneVariants(raw: string): string[] { // Strip whitespace, dashes, dots, parentheses — keep digits and a leading + const stripped = raw.replace(/[^\d+]/g, ''); @@ -38,13 +26,6 @@ export function normalizePhoneVariants(raw: string): string[] { return [...variants]; } -/** - * A sign-in identifier is a single free-text field: the passenger types either an email - * address or a phone number and the server works out which. Phone is the default reading — - * an email must contain an `@` with something either side of it, everything else is treated - * as a number so that malformed emails don't silently fall through to a phone lookup that - * can never match. - */ export type ResolvedIdentifier = { kind: 'email' | 'phone'; /** Lower-cased email, or null when the input is a phone number. */ @@ -75,3 +56,27 @@ export function maskPhone(phone: string): string { const tail = stripped.slice(-3); return `${head}${'*'.repeat(4)}${tail}`; } + +/** + * Collapses a number to a single canonical E.164 form so two values can be compared directly. + * Mirrors `PassengerAuthService.standardizePhone`, plus the bare-9-digit case the passenger + * form produces (its input sits behind a fixed `+251` prefix control). + * + */ +export function normalizePhone(phone?: string | null): string | null { + if (!phone) return null; + const digits = phone.replace(/\D/g, ''); + if (!digits) return null; + if (digits.startsWith('251')) return `+${digits}`; + if (digits.startsWith('0')) return `+251${digits.slice(1)}`; + // A bare local subscriber number, e.g. "912345678" from the +251-prefixed input. + if (digits.length === 9) return `+251${digits}`; + return `+${digits}`; +} + +/** True only when both numbers are present and resolve to the same E.164 form. */ +export function samePhone(a?: string | null, b?: string | null): boolean { + const left = normalizePhone(a); + const right = normalizePhone(b); + return !!left && !!right && left === right; +} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 2aff404b8..ecd3b4043 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -26,7 +26,6 @@ import { BookingsService } from "./bookings.service"; import { GuestBookingService } from "./guest-booking.service"; import { CreateBookingDto, - ModifyBookingDto, CancelBookingDto, } from "./bookings.dto"; import { @@ -692,22 +691,6 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b return this.service.getByRef(ref); } - @Patch(":bookingRef/modify") - @UseGuards(JwtGuard) - @ApiBearerAuth("JWT-auth") - @ApiOperation({ - summary: "Modify booking seats or trip", - description: "Allows modification of confirmed bookings before departure", - }) - @ApiResponse({ status: 200, description: "Booking modified successfully" }) - @ApiResponse({ - status: 400, - description: "Cannot modify cancelled or past bookings", - }) - modify(@Req() req: any, @Body() dto: ModifyBookingDto) { - return this.service.modify(dto, req.user?.id); - } - @Delete(":id") @PassengerAdmin() @ApiBearerAuth("IAM-auth") diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 454339516..5e9d98d11 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -214,13 +214,6 @@ export class CreateBookingDto { @IsOptional() @IsString() returnLeg2SeatClassId?: string; } -export class ModifyBookingDto { - @ApiProperty() @IsString() bookingRef: string; - @ApiProperty({ example: 'schedule-uuid' }) @IsString() newScheduleId: string; - @ApiProperty({ type: [String] }) @IsArray() newSeatIds: string[]; - @ApiPropertyOptional() @IsOptional() @IsString() reason?: string; -} - export class CancelBookingDto { @ApiProperty() @IsString() bookingRef: string; @ApiPropertyOptional() @IsOptional() @IsString() reason?: string; diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 01367bdb1..7a447690a 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -5,8 +5,9 @@ import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { TicketsService } from '../tickets/tickets.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { CreateBookingDto, ModifyBookingDto } from './bookings.dto'; +import { CreateBookingDto } from './bookings.dto'; import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util'; +import { buildPaymentBreakdown } from './payment-breakdown.util'; import { Cron, CronExpression } from '@nestjs/schedule'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { CurrencyService } from '../currency/currency.service'; @@ -1873,7 +1874,7 @@ export class BookingsService { }; } - private async getBaseFare( + async getBaseFare( scheduleId: string, seatClassId: string, segmentRoute?: string, @@ -2005,6 +2006,8 @@ export class BookingsService { returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, paymentIntent: true, tickets: true, + // Money collected after the original payment (reschedule fees, underpayments). + supplementaryCharges: { orderBy: { createdAt: 'asc' } }, priceTier: { select: { priceMinor: true } }, }, }); @@ -2098,6 +2101,8 @@ export class BookingsService { returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, paymentIntent: true, tickets: true, + // Money collected after the original payment (reschedule fees, underpayments). + supplementaryCharges: { orderBy: { createdAt: 'asc' } }, priceTier: { select: { priceMinor: true } }, }, }); @@ -2176,12 +2181,20 @@ export class BookingsService { }, }; }), + // `amountMinor` stays exactly as it was — the original intent, in the major units that + // column actually stores — so existing callers keep working. Everything collected since + // (reschedule fees and fare differences) lives in `breakdown`, whose `totalPaidMinor` is + // the number to show as "Total paid". See payment-breakdown.util.ts. payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status, amountMinor: (booking as any).paymentIntent.amountMinor, currency: (booking as any).paymentIntent.currency, + breakdown: buildPaymentBreakdown( + (booking as any).paymentIntent, + (booking as any).supplementaryCharges ?? [], + ), } : undefined, // One ticket per passenger per leg (round trips have a separate ticket — and @@ -2199,22 +2212,6 @@ export class BookingsService { }; } - async modify(dto: ModifyBookingDto, iamUserId?: string) { - const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, schedule: true } }); - if (!booking) throw new NotFoundException('Booking not found'); - if (booking.status !== 'CONFIRMED') throw new BadRequestException('Only confirmed bookings can be modified'); - if (booking.schedule.departureAt < new Date()) throw new BadRequestException('Cannot modify past bookings'); - - const oldSeats = booking.seats.map(s => s.seatId); - await this.prisma.bookingModification.create({ - data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason }, - }); - await this.seatsService.releaseSeats(booking.id); - await this.seatsService.confirmSeats(dto.newSeatIds); - await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'UPDATE', entityType: 'Booking', entityId: booking.id, oldData: { seatIds: oldSeats }, newData: { seatIds: dto.newSeatIds, reason: dto.reason } }); - return { modified: true, bookingRef: dto.bookingRef }; - } - async cancel(bookingRef: string, reason?: string, iamUserId?: string) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } }); if (!booking) throw new NotFoundException('Booking not found'); diff --git a/apps/edr-passenger-api/src/modules/bookings/payment-breakdown.util.ts b/apps/edr-passenger-api/src/modules/bookings/payment-breakdown.util.ts new file mode 100644 index 000000000..0980fcc97 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/bookings/payment-breakdown.util.ts @@ -0,0 +1,101 @@ +export type PaymentLineKind = 'BOOKING' | 'SUPPLEMENTARY'; + +export interface PaymentLine { + kind: PaymentLineKind; + /** Human label — "Original booking", "Reschedule", "Excess baggage". */ + label: string; + /** Raw reason for supplementary lines (RESCHEDULE, UNDERPAYMENT, …); null for the booking line. */ + reason: string | null; + /** Always true minor units, whatever the source column stored. */ + amountMinor: number; + currency: string; + status: string; + paidAt: string | null; + /** Whether this line represents money actually collected. */ + settled: boolean; +} + +export interface PaymentBreakdown { + lines: PaymentLine[]; + /** Sum of settled lines, or null when they are not all in one currency. */ + totalPaidMinor: number | null; + totalPaidCurrency: string | null; + /** True when something is still owed (a charge raised but not yet paid). */ + hasOutstanding: boolean; + outstandingMinor: number; +} + +/** `PaymentIntent.amountMinor` is a Float in major units — bring it onto the minor-unit scale. */ +export function intentAmountToMinor(amount: number | null | undefined): number { + if (amount == null) return 0; + return Math.round(amount * 100); +} + +const SUPPLEMENTARY_LABELS: Record = { + RESCHEDULE: 'Reschedule', + UNDERPAYMENT: 'Underpayment', + FARE_CORRECTION: 'Fare correction', + EXCESS_BAGGAGE: 'Excess baggage', +}; + +function labelFor(reason: string): string { + return ( + SUPPLEMENTARY_LABELS[reason] ?? + // "SOME_OTHER_REASON" → "Some other reason" + reason.charAt(0).toUpperCase() + reason.slice(1).toLowerCase().replace(/_/g, ' ') + ); +} + +export function buildPaymentBreakdown( + paymentIntent: { status?: string | null; amountMinor?: number | null; currency?: string | null } | null | undefined, + supplementaryCharges: Array<{ + reason: string; + amountMinor: number; + currency: string; + status: string; + paidAt: Date | string | null; + }> = [], +): PaymentBreakdown { + const lines: PaymentLine[] = []; + + if (paymentIntent) { + lines.push({ + kind: 'BOOKING', + label: 'Original booking', + reason: null, + amountMinor: intentAmountToMinor(paymentIntent.amountMinor), + currency: paymentIntent.currency ?? 'ETB', + status: paymentIntent.status ?? 'UNKNOWN', + paidAt: null, + settled: paymentIntent.status === 'SUCCEEDED', + }); + } + + for (const charge of supplementaryCharges) { + if (charge.status === 'WAIVED' || charge.status === 'EXPIRED') continue; + lines.push({ + kind: 'SUPPLEMENTARY', + label: labelFor(charge.reason), + reason: charge.reason, + amountMinor: charge.amountMinor, + currency: charge.currency ?? 'ETB', + status: charge.status, + paidAt: charge.paidAt ? new Date(charge.paidAt).toISOString() : null, + settled: charge.status === 'PAID', + }); + } + + const settled = lines.filter((l) => l.settled); + const currencies = new Set(settled.map((l) => l.currency)); + const singleCurrency = currencies.size === 1 ? [...currencies][0] : null; + + const outstanding = lines.filter((l) => l.status === 'PENDING'); + + return { + lines, + totalPaidMinor: singleCurrency ? settled.reduce((sum, l) => sum + l.amountMinor, 0) : null, + totalPaidCurrency: singleCurrency, + hasOutstanding: outstanding.length > 0, + outstandingMinor: outstanding.reduce((sum, l) => sum + l.amountMinor, 0), + }; +} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 3b7e7be2f..44ba03dea 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -731,6 +731,24 @@ export class NotificationsService { ); } + @OnEvent('booking.rescheduled') + async onBookingRescheduled(payload: any) { + const { booking, reschedule } = payload; + await this.send( + 'booking.rescheduled', + booking.passengerId, + { + bookingRef: booking.bookingRef, + leg: reschedule?.leg === 2 ? 'return' : 'outbound', + feeAmount: ((reschedule?.feeMinor ?? 0) / 100).toFixed(2), + currency: 'ETB', + category: 'BOOKING', + deepLink: `edr://bookings/${booking.bookingRef}`, + }, + ['IN_APP', 'EMAIL', 'SMS'], + ); + } + @OnEvent('booking.cancelled') async onBookingCancelled(payload: any) { const booking = payload.booking; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 07f452e43..970983cb4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -74,6 +74,6 @@ function rabbitMQImport(): DynamicModule[] { PaymentSyncService, ServiceAuthGuard, ], - exports: [PaymentClientService, PaymentsService], + exports: [PaymentClientService, PaymentsService, SupplementaryChargesService], }) export class PaymentsModule {} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 7192c7fc7..ced758d98 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -1619,6 +1619,7 @@ export class PaymentsService { settledCurrency: event.currency, }, }); + this.eventEmitter.emit("supplementary-charge.paid", { chargeId: charge.id }); } return { processed: true, alreadyFinalized: count === 0 }; } @@ -1996,7 +1997,7 @@ export class PaymentsService { }); } - private async createJourneySegments( + async createJourneySegments( booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, ) { const b = booking as any; diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts index 28188f70f..24e112548 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts @@ -52,7 +52,7 @@ describe('SupplementaryChargesService — audit', () => { }; audit = { log: jest.fn().mockResolvedValue(undefined) }; - // Constructor order: prisma, audit, sms, email, paymentClient, currency. + // Constructor order: prisma, audit, sms, email, paymentClient, currency, eventEmitter. service = new SupplementaryChargesService( prisma as any, audit as any, @@ -60,6 +60,7 @@ describe('SupplementaryChargesService — audit', () => { { sendEmail: jest.fn() } as any, {} as any, {} as any, + { emit: jest.fn() } as any, ); return row; }; diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts index 184ff69ad..30460c8b8 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -14,6 +14,7 @@ import { } from '@edr/types'; import { PaymentPlatformDto } from './payments.dto'; import { PaymentMethodType } from '@prisma/client'; +import { EventEmitter2 } from '@nestjs/event-emitter'; const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours @@ -45,6 +46,7 @@ export class SupplementaryChargesService { private emailClient: EmailClientService, private paymentClient: PaymentClientService, private currencyService: CurrencyService, + private eventEmitter: EventEmitter2, ) {} async create(dto: { @@ -55,6 +57,8 @@ export class SupplementaryChargesService { contactPhone?: string; contactEmail?: string; createdBy: string; + /** Overrides the default 72h link lifetime (a reschedule charge must die with its seat hold). */ + expiresAt?: Date; }) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, @@ -66,7 +70,7 @@ export class SupplementaryChargesService { } if (dto.amountMinor <= 0) throw new BadRequestException('Amount must be positive'); - const expiresAt = new Date(Date.now() + CHARGE_TTL_MS); + const expiresAt = dto.expiresAt ?? new Date(Date.now() + CHARGE_TTL_MS); const charge = await this.prisma.supplementaryCharge.create({ data: { bookingId: booking.id, @@ -172,6 +176,7 @@ export class SupplementaryChargesService { providerTxnId: providerTxnId ?? null, }, }); + this.eventEmitter.emit('supplementary-charge.paid', { chargeId: id }); } return updated!; diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts index 043ec919c..bd72b649b 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts @@ -69,6 +69,7 @@ describe('SupplementaryChargesService — payment methods', () => { {} as any, paymentClient as any, new CurrencyService(prisma as any), + { emit: jest.fn() } as any, ); }; diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts new file mode 100644 index 000000000..0aba110d1 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts @@ -0,0 +1,82 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; +import { RescheduleService } from './reschedule.service'; +import { + CreateReschedulePolicyDto, + CreateRescheduleDto, + RescheduleQuoteDto, + UpdateReschedulePolicyDto, +} from './reschedule.dto'; + +@ApiTags('Reschedule') +@Controller() +export class RescheduleController { + constructor(private service: RescheduleService) {} + + @Get('reschedule/policies') + @PassengerStaff(PASSENGER_PERMS.bookings.view) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' }) + listPolicies() { + return this.service.listPolicies(); + } + + @Get('reschedule/policies/available-coach-types') + @PassengerStaff(PASSENGER_PERMS.bookings.view) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' }) + listUnconfiguredCoachTypes() { + return this.service.listUnconfiguredCoachTypes(); + } + + @Post('reschedule/policies') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' }) + createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) { + return this.service.createPolicy(dto, req.user?.id); + } + + @Patch('reschedule/policies/:coachTypeId') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' }) + updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) { + return this.service.updatePolicy(coachTypeId, dto, req.user?.id); + } + + @Delete('reschedule/policies/:coachTypeId') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' }) + deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) { + return this.service.deletePolicy(coachTypeId, req.user?.id); + } + + @Get('bookings/:bookingRef/reschedule') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Reschedule eligibility per leg, pending request, history' }) + options(@Req() req: any, @Param('bookingRef') bookingRef: string) { + return this.service.getOptions(bookingRef, req.user); + } + + @Post('bookings/:bookingRef/reschedule/quote') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Itemised quote (fee, fare difference, amount due) for a proposed change' }) + quote(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: RescheduleQuoteDto) { + return this.service.quote(bookingRef, dto, req.user); + } + + @Post('bookings/:bookingRef/reschedule') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Reschedule a leg. Applies immediately when nothing is due, otherwise returns a payment token' }) + create(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: CreateRescheduleDto) { + return this.service.create(bookingRef, dto, req.user); + } +} diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts new file mode 100644 index 000000000..479896adf --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts @@ -0,0 +1,77 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export class UpdateReschedulePolicyDto { + @ApiPropertyOptional({ example: 30, description: '% of the leg fare charged as a change fee' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) + feePercent?: number; + + @ApiPropertyOptional({ example: 50000, description: 'Fee floor in ETB minor units (500 ETB = 50000)' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) + feeMinMinor?: number; + + @ApiPropertyOptional({ example: false }) + @IsOptional() @IsBoolean() + routeChangeAllowed?: boolean; + + @ApiPropertyOptional({ example: false }) + @IsOptional() @IsBoolean() + sameDayAllowed?: boolean; + + @ApiPropertyOptional({ example: 15, description: 'Same-day change: % of the leg fare (replaces feePercent)' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) + sameDayFeePercent?: number; + + @ApiPropertyOptional({ example: 25000, description: 'Same-day change: fee floor in ETB minor units' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) + sameDayFeeMinMinor?: number; + + @ApiPropertyOptional({ example: 120, description: 'Changes are refused this many minutes before departure' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) + cutoffMinutes?: number; + + @ApiPropertyOptional({ example: true }) + @IsOptional() @IsBoolean() + isActive?: boolean; +} + +/** Same fields as the update DTO, plus the fare class the new policy attaches to. */ +export class CreateReschedulePolicyDto extends UpdateReschedulePolicyDto { + @ApiProperty({ example: 'coach-type-uuid', description: 'CoachType the policy applies to (one policy per fare class)' }) + @IsString() + coachTypeId: string; +} + +export class RescheduleQuoteDto { + @ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2) + leg?: number; + + @ApiProperty({ example: 'schedule-uuid' }) + @IsString() newScheduleId: string; + + @ApiProperty({ example: 'station-uuid' }) + @IsString() newOriginStationId: string; + + @ApiProperty({ example: 'station-uuid' }) + @IsString() newDestinationStationId: string; + + @ApiProperty({ type: [String], description: 'One seat per seated passenger of the leg, in BookingSeat order' }) + @IsArray() @ArrayMinSize(1) @IsString({ each: true }) + newSeatIds: string[]; +} + +export class CreateRescheduleDto extends RescheduleQuoteDto { + @ApiProperty({ example: 'hold-uuid', description: 'SeatHold on the new schedule covering newSeatIds (POST /seats/hold)' }) + @IsString() holdId: string; +} diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts new file mode 100644 index 000000000..ffb2c23bc --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts @@ -0,0 +1,40 @@ +import { Injectable, Logger, Module } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { OnEvent } from '@nestjs/event-emitter'; +import { AuditModule } from '../../common/audit.module'; +import { BookingsModule } from '../bookings/bookings.module'; +import { SeatsModule } from '../seats/seats.module'; +import { TicketsModule } from '../tickets/tickets.module'; +import { PaymentsModule } from '../payments/payments.module'; +import { CurrencyModule } from '../currency/currency.module'; +import { RescheduleController } from './reschedule.controller'; +import { RescheduleService, SUPPLEMENTARY_CHARGE_PAID_EVENT } from './reschedule.service'; + +/** + * RescheduleService is request-scoped by transitivity (AuditService injects REQUEST), and Nest + * never fires @OnEvent on request-scoped providers — so the listener lives on this singleton and + * resolves the service per event, the same way TasksService reaches PaymentsService. + */ +@Injectable() +export class RescheduleEventsListener { + private readonly logger = new Logger(RescheduleEventsListener.name); + constructor(private readonly moduleRef: ModuleRef) {} + + @OnEvent(SUPPLEMENTARY_CHARGE_PAID_EVENT, { async: true }) + async onChargePaid(payload: { chargeId: string }) { + try { + const service = await this.moduleRef.resolve(RescheduleService, undefined, { strict: false }); + await service.applyForCharge(payload.chargeId); + } catch (err) { + this.logger.error(`Failed to apply reschedule for charge ${payload.chargeId}: ${err instanceof Error ? err.message : err}`); + } + } +} + +@Module({ + imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule], + controllers: [RescheduleController], + providers: [RescheduleService, RescheduleEventsListener], + exports: [RescheduleService], +}) +export class RescheduleModule {} diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.spec.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.spec.ts new file mode 100644 index 000000000..d9dca0157 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.spec.ts @@ -0,0 +1,37 @@ +import { addisDay, computeRescheduleAmounts } from './reschedule.service'; + +const standard = { feePercent: 30, feeMinMinor: 50000, sameDayFeePercent: 0, sameDayFeeMinMinor: 0 }; +const flex = { feePercent: 0, feeMinMinor: 0, sameDayFeePercent: 15, sameDayFeeMinMinor: 25000 }; +const premium = { feePercent: 0, feeMinMinor: 0, sameDayFeePercent: 0, sameDayFeeMinMinor: 0 }; + +describe('computeRescheduleAmounts (policy §3)', () => { + it('Standard: 30% of fare, floored at 500 ETB, plus positive fare difference', () => { + // 1000 ETB fare → 30% = 300 < 500 floor + expect(computeRescheduleAmounts(standard, 100000, 120000, false)).toEqual({ feeMinor: 50000, fareDifferenceMinor: 20000, amountDueMinor: 70000 }); + // 3000 ETB fare → 30% = 900 > floor + expect(computeRescheduleAmounts(standard, 300000, 300000, false)).toEqual({ feeMinor: 90000, fareDifferenceMinor: 0, amountDueMinor: 90000 }); + }); + + it('negative fare difference is recorded but never paid out', () => { + expect(computeRescheduleAmounts(standard, 300000, 200000, false)).toEqual({ feeMinor: 90000, fareDifferenceMinor: -100000, amountDueMinor: 90000 }); + expect(computeRescheduleAmounts(flex, 300000, 200000, false)).toEqual({ feeMinor: 0, fareDifferenceMinor: -100000, amountDueMinor: 0 }); + }); + + it('Flex: free, fare difference only; same-day is 15% min 250 ETB', () => { + expect(computeRescheduleAmounts(flex, 100000, 150000, false)).toEqual({ feeMinor: 0, fareDifferenceMinor: 50000, amountDueMinor: 50000 }); + expect(computeRescheduleAmounts(flex, 100000, 100000, true)).toEqual({ feeMinor: 25000, fareDifferenceMinor: 0, amountDueMinor: 25000 }); + expect(computeRescheduleAmounts(flex, 400000, 400000, true)).toEqual({ feeMinor: 60000, fareDifferenceMinor: 0, amountDueMinor: 60000 }); + }); + + it('Premium: always free, even same-day', () => { + expect(computeRescheduleAmounts(premium, 500000, 500000, true).amountDueMinor).toBe(0); + expect(computeRescheduleAmounts(premium, 500000, 560000, true).amountDueMinor).toBe(60000); + }); +}); + +describe('addisDay', () => { + it('compares calendar days in Africa/Addis_Ababa (UTC+3), not UTC', () => { + expect(addisDay(new Date('2026-09-01T21:30:00Z'))).toBe('2026-09-02'); + expect(addisDay(new Date('2026-09-01T20:30:00Z'))).toBe('2026-09-01'); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts new file mode 100644 index 000000000..fdf10ca0a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -0,0 +1,643 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma.service'; +import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; +import { MeLikeUser } from '../../common/passenger-permission.util'; +import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; +import { normalizePhone, samePhone } from '../../common/utils/phone.utils'; +import { BookingsService } from '../bookings/bookings.service'; +import { SeatsService } from '../seats/seats.service'; +import { TicketsService } from '../tickets/tickets.service'; +import { PaymentsService } from '../payments/payments.service'; +import { SupplementaryChargesService } from '../payments/supplementary-charges.service'; +import { CurrencyService } from '../currency/currency.service'; +import { + CreateReschedulePolicyDto, + CreateRescheduleDto, + RescheduleQuoteDto, + UpdateReschedulePolicyDto, +} from './reschedule.dto'; + +export const RESCHEDULE_CHARGE_REASON = 'RESCHEDULE'; +export const SUPPLEMENTARY_CHARGE_PAID_EVENT = 'supplementary-charge.paid'; + +/** + * Coaches nobody buys a seat in, so they can never carry a reschedule policy. + * + * Matched loosely on purpose: `CoachType.type` is documented as 'passenger' | 'sleeper' | + * 'dining' | 'baggage', but the live data holds display labels ('Dining Coach ', trailing space + * included). A `notIn: ['dining','baggage']` filter therefore matches nothing and offers the + * dining coach as a fare class. This mirrors the portal's own test (`/dining|dpc/i`, + * booking/seats/page.tsx) and checks `code` as well as `type`. + */ +const NON_FARE_COACH_TERMS = ['dining', 'dpc', 'baggage']; +const NOT_A_FARE_CLASS = { + NOT: NON_FARE_COACH_TERMS.flatMap((term) => [ + { type: { contains: term, mode: 'insensitive' as const } }, + { code: { contains: term, mode: 'insensitive' as const } }, + ]), +}; + +type PolicyNumbers = { + feePercent: number; + feeMinMinor: number; + sameDayFeePercent: number; + sameDayFeeMinMinor: number; +}; + +/** + * Pure fee arithmetic — policy §3. Negative fare differences are recorded but NOT paid out + * (credit/refund handling is a later step), so amountDue never goes below the fee. + */ +export function computeRescheduleAmounts( + policy: PolicyNumbers, + oldFareMinor: number, + newFareMinor: number, + isSameDay: boolean, +): { feeMinor: number; fareDifferenceMinor: number; amountDueMinor: number } { + const pct = isSameDay ? policy.sameDayFeePercent : policy.feePercent; + const min = isSameDay ? policy.sameDayFeeMinMinor : policy.feeMinMinor; + const feeMinor = pct > 0 || min > 0 ? Math.max(Math.round((oldFareMinor * pct) / 100), min) : 0; + const fareDifferenceMinor = newFareMinor - oldFareMinor; + return { feeMinor, fareDifferenceMinor, amountDueMinor: feeMinor + Math.max(0, fareDifferenceMinor) }; +} + +export function addisDay(d: Date): string { + return d.toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' }); +} + +type ActingUser = MeLikeUser & { id?: string; sub?: string; phoneNumber?: string }; + +type LegView = { + leg: number; + scheduleId: string; + originStationId: string | null; + destinationStationId: string | null; + departureAt: Date; + seats: Array<{ id: string; seatId: string; passengerName: string; fareMinor: number | null; passengerCategory: string }>; + coachTypeId: string; +}; + +// Seats are ordered by passenger name so getOptions(), quote() and create() all see the same +// sequence — the client submits newSeatIds in that order (BookingSeat has no creation order). +const bookingInclude: Prisma.BookingInclude = { + schedule: { select: { id: true, departureAt: true, arrivalAt: true, originStationId: true, destinationStationId: true } }, + returnSchedule: { select: { id: true, departureAt: true, arrivalAt: true, originStationId: true, destinationStationId: true } }, + seats: { include: { seat: { include: { coach: { select: { coachTypeId: true } } } } }, orderBy: [{ passengerName: 'asc' }, { id: 'asc' }] }, +}; + +@Injectable() +export class RescheduleService { + private readonly logger = new Logger(RescheduleService.name); + + constructor( + private prisma: PrismaService, + private bookingsService: BookingsService, + private seatsService: SeatsService, + private ticketsService: TicketsService, + private paymentsService: PaymentsService, + private supplementaryCharges: SupplementaryChargesService, + private currencyService: CurrencyService, + private auditService: AuditService, + private eventEmitter: EventEmitter2, + ) {} + + // ── Policy admin ───────────────────────────────────────────────────────── + + /** The policies that exist, each carrying its fare class. A coach type with no policy is simply absent. */ + async listPolicies() { + return this.prisma.reschedulePolicy.findMany({ + include: { coachType: { select: { id: true, code: true, name: true, type: true } } }, + orderBy: { coachType: { code: 'asc' } }, + }); + } + + /** Fare classes still available to attach a policy to — the "add" dialog's dropdown. */ + async listUnconfiguredCoachTypes() { + return this.prisma.coachType.findMany({ + where: { ...NOT_A_FARE_CLASS, reschedulePolicy: { is: null } }, + select: { id: true, code: true, name: true, type: true }, + orderBy: { code: 'asc' }, + }); + } + + async createPolicy(dto: CreateReschedulePolicyDto, actorId?: string) { + const { coachTypeId, ...values } = dto; + const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } }); + if (!coachType) throw new NotFoundException('Coach type not found'); + if (NON_FARE_COACH_TERMS.some((t) => `${coachType.type} ${coachType.code}`.toLowerCase().includes(t))) { + throw new BadRequestException(`${coachType.code} is not a fare class — no seats are sold in it.`); + } + const existing = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId } }); + if (existing) throw new ConflictException(`${coachType.code} already has a reschedule policy — edit it instead.`); + + const policy = await this.prisma.reschedulePolicy.create({ data: { coachTypeId, ...values } }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.ReschedulePolicy, + entityId: policy.id, + newData: { coachTypeCode: coachType.code, ...values }, + }); + return policy; + } + + async deletePolicy(coachTypeId: string, actorId?: string) { + const policy = await this.prisma.reschedulePolicy.findUnique({ + where: { coachTypeId }, + include: { coachType: { select: { code: true } } }, + }); + if (!policy) throw new NotFoundException('Reschedule policy not found'); + + await this.prisma.reschedulePolicy.delete({ where: { coachTypeId } }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.ReschedulePolicy, + entityId: policy.id, + oldData: policy, + }); + // Rescheduling for this fare class is now refused outright (legBlockers treats a missing + // policy the same as an inactive one), which is the intended effect of deleting it. + return { deleted: true, coachTypeId }; + } + + async updatePolicy(coachTypeId: string, dto: UpdateReschedulePolicyDto, actorId?: string) { + const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } }); + if (!coachType) throw new NotFoundException('Coach type not found'); + const before = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId } }); + const policy = await this.prisma.reschedulePolicy.upsert({ + where: { coachTypeId }, + update: dto, + create: { coachTypeId, ...dto }, + }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.ReschedulePolicy, + entityId: policy.id, + oldData: before ?? undefined, + newData: { coachTypeCode: coachType.code, ...dto }, + }); + return policy; + } + + // ── Reads ──────────────────────────────────────────────────────────────── + + /** What the portal needs before picking a new schedule: per-leg eligibility + the rule set. */ + async getOptions(bookingRef: string, user: ActingUser) { + const booking = await this.loadOwnedBooking(bookingRef, user); + const legs = this.legsOf(booking); + const out = []; + for (const leg of legs) { + const policy = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId: leg.coachTypeId } }); + const blockers = this.legBlockers(booking, leg, policy); + out.push({ + leg: leg.leg, + scheduleId: leg.scheduleId, + originStationId: leg.originStationId, + destinationStationId: leg.destinationStationId, + departureAt: leg.departureAt, + coachTypeId: leg.coachTypeId, + seatCount: leg.seats.length, + passengerNames: leg.seats.map((s) => s.passengerName), + oldFareMinor: this.legFare(booking, leg), + policy: policy && { + feePercent: policy.feePercent, + feeMinMinor: policy.feeMinMinor, + routeChangeAllowed: policy.routeChangeAllowed, + sameDayAllowed: policy.sameDayAllowed, + sameDayFeePercent: policy.sameDayFeePercent, + sameDayFeeMinMinor: policy.sameDayFeeMinMinor, + cutoffMinutes: policy.cutoffMinutes, + }, + canReschedule: blockers.length === 0, + blockers, + }); + } + const reschedules = await this.prisma.bookingReschedule.findMany({ + where: { bookingId: booking.id }, + orderBy: { createdAt: 'desc' }, + }); + const pending = reschedules.find((r) => r.status === 'PENDING_PAYMENT'); + const charge = pending?.supplementaryChargeId + ? await this.prisma.supplementaryCharge.findUnique({ + where: { id: pending.supplementaryChargeId }, + select: { paymentToken: true, status: true, expiresAt: true }, + }) + : null; + return { + bookingRef: booking.bookingRef, + bookingType: booking.bookingType, + legs: out, + pending: pending ? { ...pending, paymentToken: charge?.paymentToken ?? null } : null, + history: reschedules.filter((r) => r.status !== 'PENDING_PAYMENT'), + }; + } + + // ── Quote / create / apply ─────────────────────────────────────────────── + + async quote(bookingRef: string, dto: RescheduleQuoteDto, user: ActingUser) { + const booking = await this.loadOwnedBooking(bookingRef, user); + return this.buildQuote(booking, dto); + } + + async create(bookingRef: string, dto: CreateRescheduleDto, user: ActingUser) { + const booking = await this.loadOwnedBooking(bookingRef, user); + const q = await this.buildQuote(booking, dto); + if (!q.allowed) throw new BadRequestException(q.blockers.join(' ')); + + const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); + if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired'); + if (hold.scheduleId !== dto.newScheduleId) throw new BadRequestException('Seat hold is for a different schedule'); + const held = new Set(hold.seatIds); + if (!dto.newSeatIds.every((id) => held.has(id))) throw new BadRequestException('Selected seats are not covered by the hold'); + + // Availability was enforced when the hold was taken (holdSeats checks holds + booked + // segments for the leg); tickets.generate() re-checks at apply time. + + const requestedBy = user.id ?? user.sub ?? booking.passengerId; + const newDeparture = q.newDepartureAt; + const expiresAt = computePaymentDeadline(new Date(), newDeparture); + + const reschedule = await this.prisma.bookingReschedule.create({ + data: { + bookingId: booking.id, + leg: q.leg, + status: 'PENDING_PAYMENT', + requestedBy, + oldScheduleId: q.oldScheduleId, + newScheduleId: dto.newScheduleId, + oldOriginStationId: q.oldOriginStationId, + oldDestinationStationId: q.oldDestinationStationId, + newOriginStationId: dto.newOriginStationId, + newDestinationStationId: dto.newDestinationStationId, + oldSeatIds: q.oldSeatIds, + newSeatIds: dto.newSeatIds, + holdId: dto.holdId, + oldFareMinor: q.oldFareMinor, + newFareMinor: q.newFareMinor, + fareDifferenceMinor: q.fareDifferenceMinor, + feeMinor: q.feeMinor, + amountDueMinor: q.amountDueMinor, + isSameDay: q.isSameDay, + isRouteChange: q.isRouteChange, + expiresAt: q.amountDueMinor > 0 ? expiresAt : null, + }, + }); + + if (q.amountDueMinor === 0) { + await this.apply(reschedule.id); + return { rescheduleId: reschedule.id, status: 'APPLIED', amountDueMinor: 0, paymentToken: null, quote: q }; + } + + // Money owed: raise a supplementary charge (pay page /pay-balance/:token, SMS+email link) and + // keep the new seats held until the same deadline the charge carries. + const charge = await this.supplementaryCharges.create({ + bookingRef: booking.bookingRef, + amountMinor: q.amountDueMinor, + reason: RESCHEDULE_CHARGE_REASON, + notes: `Reschedule leg ${q.leg} → schedule ${dto.newScheduleId}`, + createdBy: requestedBy, + expiresAt, + }); + await this.prisma.bookingReschedule.update({ + where: { id: reschedule.id }, + data: { supplementaryChargeId: charge.id }, + }); + await this.seatsService.confirmSeats(dto.newSeatIds); + + await this.auditService.log({ + userId: requestedBy, + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.BookingReschedule, + entityId: reschedule.id, + newData: { bookingRef: booking.bookingRef, leg: q.leg, amountDueMinor: q.amountDueMinor, chargeId: charge.id }, + }); + return { rescheduleId: reschedule.id, status: 'PENDING_PAYMENT', amountDueMinor: q.amountDueMinor, paymentToken: charge.paymentToken, expiresAt, quote: q }; + } + + /** Entry point for the paid-charge event. Idempotent: only a PENDING_PAYMENT row is applied. */ + async applyForCharge(supplementaryChargeId: string) { + const r = await this.prisma.bookingReschedule.findUnique({ where: { supplementaryChargeId } }); + if (!r || r.status !== 'PENDING_PAYMENT') return; + await this.apply(r.id); + } + + /** Moves the booking leg: booking fields, seats, journey segments, tickets. */ + async apply(rescheduleId: string) { + const r = await this.prisma.bookingReschedule.findUnique({ where: { id: rescheduleId } }); + if (!r) throw new NotFoundException('Reschedule not found'); + if (r.status !== 'PENDING_PAYMENT') return r; + + const booking = await this.prisma.booking.findUnique({ where: { id: r.bookingId }, include: bookingInclude }); + if (!booking) throw new NotFoundException('Booking not found'); + const leg = this.legsOf(booking).find((l) => l.leg === r.leg); + if (!leg) throw new BadRequestException('Leg no longer exists on booking'); + if (leg.seats.length !== r.newSeatIds.length) throw new BadRequestException('Seat count changed since quote'); + + const newTotal = Math.max(0, booking.totalMinor + r.fareDifferenceMinor); + const displayTotal = + booking.displayCurrency && booking.displayCurrency !== 'ETB' + ? await this.currencyService.convertAmount(newTotal, 'ETB' as any, booking.displayCurrency as any) + : newTotal; + const perSeatNew = this.splitFare(r.newFareMinor, leg.seats); + + await this.prisma.$transaction(async (tx) => { + await tx.booking.update({ + where: { id: booking.id }, + data: { + ...(r.leg === 1 + ? { scheduleId: r.newScheduleId, originStationId: r.newOriginStationId, destinationStationId: r.newDestinationStationId } + : { returnScheduleId: r.newScheduleId, returnOriginStationId: r.newOriginStationId, returnDestinationStationId: r.newDestinationStationId }), + totalMinor: newTotal, + displayTotalMinor: displayTotal, + }, + }); + // Two passes so the (scheduleId, seatId) unique key never collides mid-update when a + // passenger takes a seat another passenger of the same booking is leaving. + for (const s of leg.seats) { + await tx.bookingSeat.update({ where: { id: s.id }, data: { scheduleId: `moving-${s.id}` } }); + } + for (let i = 0; i < leg.seats.length; i++) { + await tx.bookingSeat.update({ + where: { id: leg.seats[i].id }, + data: { seatId: r.newSeatIds[i], scheduleId: r.newScheduleId, fareMinor: perSeatNew[i], seatLabelSnapshot: null }, + }); + } + await tx.bookingModification.create({ + data: { + bookingId: booking.id, + modifiedBy: r.requestedBy, + modificationType: 'RESCHEDULE', + oldData: { leg: r.leg, scheduleId: r.oldScheduleId, originStationId: r.oldOriginStationId, destinationStationId: r.oldDestinationStationId, seatIds: r.oldSeatIds, fareMinor: r.oldFareMinor }, + newData: { leg: r.leg, scheduleId: r.newScheduleId, originStationId: r.newOriginStationId, destinationStationId: r.newDestinationStationId, seatIds: r.newSeatIds, fareMinor: r.newFareMinor, feeMinor: r.feeMinor }, + fareAdjustment: r.fareDifferenceMinor, + }, + }); + await tx.bookingReschedule.update({ where: { id: r.id }, data: { status: 'APPLIED', appliedAt: new Date() } }); + }); + + // Occupancy + tickets are rebuilt from the (now updated) booking, outside the transaction. + const fresh = await this.prisma.booking.findUnique({ where: { id: booking.id }, include: { seats: true, tickets: { select: { id: true } } } }); + if (fresh) { + try { + await this.seatsService.releaseSeats(fresh.id); + await this.paymentsService.createJourneySegments(fresh as any); + } catch (err) { + this.logger.error(`Reschedule ${r.id}: journey segments failed: ${err instanceof Error ? err.message : err}`); + } + // Old tickets' SYSTEM seat blocks reference ticket ids that generate() is about to delete. + for (const t of fresh.tickets) { + await this.prisma.seatBlock.deleteMany({ where: { reason: { contains: t.id }, blockedBy: 'SYSTEM' } }); + } + try { await this.ticketsService.generate(fresh.id); } catch (err) { + this.logger.error(`Reschedule ${r.id}: ticket generation failed: ${err instanceof Error ? err.message : err}`); + } + } + // The new seats are owned by the journey now; the old seats' own booking-time hold (holds + // outlive confirmation until the payment deadline) would otherwise keep them HELD on the old + // schedule. Nobody else can hold a booked seat, so any hold there is this booking's. + await this.prisma.seatHold.deleteMany({ + where: { OR: [{ id: r.holdId ?? '' }, { scheduleId: r.oldScheduleId, seatIds: { hasSome: r.oldSeatIds } }] }, + }); + + await this.auditService.log({ + userId: r.requestedBy, + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Booking, + entityId: booking.id, + oldData: { leg: r.leg, scheduleId: r.oldScheduleId, seatIds: r.oldSeatIds }, + newData: { leg: r.leg, scheduleId: r.newScheduleId, seatIds: r.newSeatIds, feeMinor: r.feeMinor, fareDifferenceMinor: r.fareDifferenceMinor, rescheduleId: r.id }, + }); + this.eventEmitter.emit('booking.rescheduled', { booking: fresh ?? booking, reschedule: r }); + return { ...r, status: 'APPLIED' }; + } + + /** Cron hook: unpaid reschedules past their payment deadline. The seat hold lapses by itself. */ + async expireStale(now = new Date()): Promise { + const stale = await this.prisma.bookingReschedule.findMany({ + where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } }, + select: { id: true, supplementaryChargeId: true }, + }); + for (const r of stale) { + await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } }); + if (r.supplementaryChargeId) { + await this.prisma.supplementaryCharge.updateMany({ + where: { id: r.supplementaryChargeId, status: 'PENDING' }, + data: { status: 'EXPIRED' }, + }); + } + } + return stale.length; + } + + // ── Internals ──────────────────────────────────────────────────────────── + + /** + * Who may act on this booking: only the person who made it, proven by their account's phone + * number matching the booking's `contactPhone`. Being merely *named* on the booking is not + * enough — a passenger travelling on someone else's booking cannot move it. + * + * There is deliberately no staff override. The `bookings:reschedule` permission still exists in + * the registry (and on the stationMaster preset) but is not honoured here, so a station master + * cannot reschedule on a customer's behalf yet. To restore it, re-import + * `hasPassengerPermission` / `PASSENGER_PERMS` and return the booking early when the caller + * holds `PASSENGER_PERMS.bookings.reschedule`. + */ + private async loadOwnedBooking(bookingRef: string, user: ActingUser) { + const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: bookingInclude }); + if (!booking) throw new NotFoundException('Booking not found'); + const iamUserId = user.id ?? user.sub; + if (!iamUserId) throw new ForbiddenException(); + + if (booking.contactPhone) { + const callerPhone = await this.resolveUserPhone(iamUserId, user); + if (samePhone(callerPhone, booking.contactPhone)) return booking; + throw new ForbiddenException( + 'Only the person who made this booking can reschedule it. Sign in with the phone number used to book.', + ); + } + + // ~0.3% of bookings (72 of 24.7k on dev) carry no contactPhone at all, so there is nothing to + // match against. Fall back to the account link rather than locking their owner out entirely. + const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } }); + if (!passenger || passenger.id !== booking.passengerId) throw new ForbiddenException('Not your booking'); + return booking; + } + + /** + * The signed-in user's phone. The session snapshot (`userInfo.phoneNumber`) is frequently an + * empty string, so `iam.users` is the source of truth — and reading it live also means a user + * who changed their number does not have to sign out before the new one counts. + */ + private async resolveUserPhone(iamUserId: string, user: ActingUser): Promise { + const fromSession = normalizePhone(user.phoneNumber); + if (fromSession) return fromSession; + const rows = await this.prisma.$queryRaw<{ phone_number: string | null }[]>` + SELECT phone_number FROM iam.users WHERE id = ${iamUserId}::uuid LIMIT 1 + `; + return normalizePhone(rows[0]?.phone_number); + } + + private legsOf(booking: any): LegView[] { + const legs: LegView[] = []; + const seatsOf = (n: number) => + (booking.seats as any[]) + .filter((s) => (s.leg ?? 1) === n) + .map((s) => ({ id: s.id, seatId: s.seatId, passengerName: s.passengerName, fareMinor: s.fareMinor, passengerCategory: s.passengerCategory, coachTypeId: s.seat?.coach?.coachTypeId })); + const l1 = seatsOf(1); + if (l1.length && booking.schedule) { + legs.push({ leg: 1, scheduleId: booking.scheduleId, originStationId: booking.originStationId, destinationStationId: booking.destinationStationId, departureAt: booking.schedule.departureAt, seats: l1, coachTypeId: l1[0].coachTypeId }); + } + const l2 = seatsOf(2); + if (booking.bookingType === 'ROUND_TRIP' && l2.length && booking.returnSchedule) { + legs.push({ leg: 2, scheduleId: booking.returnScheduleId, originStationId: booking.returnOriginStationId, destinationStationId: booking.returnDestinationStationId, departureAt: booking.returnSchedule.departureAt, seats: l2, coachTypeId: l2[0].coachTypeId }); + } + return legs; + } + + /** The leg's original fare: per-seat amounts when recorded, else the whole booking (one-way). */ + private legFare(booking: any, leg: LegView): number { + const recorded = leg.seats.reduce((sum, s) => sum + (s.fareMinor ?? 0), 0); + if (recorded > 0) return recorded; + return booking.bookingType === 'ONE_WAY' ? booking.totalMinor : Math.round(booking.totalMinor / 2); + } + + private legBlockers(booking: any, leg: LegView, policy: any, now = new Date()): string[] { + const blockers: string[] = []; + if (!['ONE_WAY', 'ROUND_TRIP'].includes(booking.bookingType)) blockers.push('Only one-way and round-trip bookings can be rescheduled.'); + if (booking.status !== 'CONFIRMED') blockers.push('Only confirmed bookings can be rescheduled.'); + // ponytail: a boarded leg can't be moved and tickets.generate() rebuilds every leg, so a + // round trip whose outbound was already used can't change its return yet — needs leg-scoped + // ticket regeneration. + if (booking.outboundBoardedAt || booking.returnBoardedAt) blockers.push('This booking has already been used for travel.'); + if (!policy || !policy.isActive) blockers.push('Rescheduling is not available for this fare class.'); + else if (leg.departureAt.getTime() - now.getTime() < policy.cutoffMinutes * 60_000) { + blockers.push(`Changes must be made at least ${policy.cutoffMinutes} minutes before departure.`); + } + return blockers; + } + + private async buildQuote(booking: any, dto: RescheduleQuoteDto) { + const legNo = dto.leg ?? 1; + const leg = this.legsOf(booking).find((l) => l.leg === legNo); + if (!leg) throw new BadRequestException(`Booking has no leg ${legNo}`); + const policy = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId: leg.coachTypeId } }); + const blockers = this.legBlockers(booking, leg, policy); + + const pending = await this.prisma.bookingReschedule.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } }); + if (pending) blockers.push('A reschedule is already awaiting payment for this booking.'); + if (dto.newSeatIds.length !== leg.seats.length) blockers.push(`Select exactly ${leg.seats.length} seat(s).`); + if (new Set(dto.newSeatIds).size !== dto.newSeatIds.length) blockers.push('Duplicate seats selected.'); + + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: dto.newScheduleId }, + include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + }); + if (!schedule) throw new NotFoundException('New schedule not found'); + const now = new Date(); + if (schedule.departureAt <= now || schedule.status !== 'SCHEDULED') blockers.push('The selected departure is no longer bookable.'); + if (schedule.id === leg.scheduleId && dto.newOriginStationId === leg.originStationId && dto.newDestinationStationId === leg.destinationStationId) { + blockers.push('Pick a different departure, route or date.'); + } + const originStop = schedule.stopTimes.find((s) => s.stationId === dto.newOriginStationId); + const destStop = schedule.stopTimes.find((s) => s.stationId === dto.newDestinationStationId); + if (!originStop || !destStop || originStop.sequence >= destStop.sequence) blockers.push('Origin/destination are not valid for this schedule.'); + + const isRouteChange = dto.newOriginStationId !== leg.originStationId || dto.newDestinationStationId !== leg.destinationStationId; + if (isRouteChange && policy && !policy.routeChangeAllowed) blockers.push('Route changes are not permitted for this fare class.'); + const isSameDay = addisDay(schedule.departureAt) === addisDay(leg.departureAt); + if (isSameDay && policy && !policy.sameDayAllowed) blockers.push('Same-day changes are not permitted for this fare class.'); + + // Keep the round trip chronologically sane. + if (booking.bookingType === 'ROUND_TRIP') { + if (legNo === 1 && booking.returnSchedule && schedule.arrivalAt >= booking.returnSchedule.departureAt) blockers.push('New outbound must arrive before the return departs.'); + if (legNo === 2 && booking.schedule && schedule.departureAt <= booking.schedule.arrivalAt) blockers.push('New return must depart after the outbound arrives.'); + } + + // New seats: same coach type as booked (no class change in this step), priced per seat. + const seats = await this.prisma.seat.findMany({ + where: { id: { in: dto.newSeatIds } }, + include: { coach: { include: { coachType: { include: { seatClasses: { where: { isActive: true } } } } } } }, + }); + const seatById = new Map(seats.map((s) => [s.id, s])); + let newFareMinor = 0; + if (originStop && destStop && seats.length === dto.newSeatIds.length) { + const nationalityType = booking.displayCurrency === 'USD' ? 'INTERNATIONAL' : 'LOCAL'; + // ponytail: passenger nationality isn't stored on the booking; currency is the proxy the + // search/fare code already uses (ETB/DJF = local, USD = international). + const nationality = booking.displayCurrency === 'DJF' ? 'Djiboutian' : booking.displayCurrency === 'ETB' ? 'Ethiopian' : undefined; + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; + for (let i = 0; i < dto.newSeatIds.length; i++) { + const seat = seatById.get(dto.newSeatIds[i])!; + if (seat.coach.coachTypeId !== leg.coachTypeId) { blockers.push('New seats must be in the same class as the original booking.'); break; } + const oldSeat = leg.seats[i]; + if (oldSeat.fareMinor === 0) continue; // free child keeps riding free + const seatClass = this.pickSeatClass(seat.coach.coachType.seatClasses, seat.bedPosition, nationalityType); + if (!seatClass) { blockers.push('No fare is configured for the selected seat.'); break; } + newFareMinor += await this.bookingsService.getBaseFare( + schedule.id, seatClass.id, segmentRoute, undefined, nationality, + originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId, + ); + } + } else if (seats.length !== dto.newSeatIds.length) { + blockers.push('One or more selected seats do not exist.'); + } + + const oldFareMinor = this.legFare(booking, leg); + const amounts = policy + ? computeRescheduleAmounts(policy, oldFareMinor, newFareMinor, isSameDay) + : { feeMinor: 0, fareDifferenceMinor: newFareMinor - oldFareMinor, amountDueMinor: 0 }; + + return { + allowed: blockers.length === 0, + blockers: Array.from(new Set(blockers)), + leg: legNo, + oldScheduleId: leg.scheduleId, + oldOriginStationId: leg.originStationId, + oldDestinationStationId: leg.destinationStationId, + oldSeatIds: leg.seats.map((s) => s.seatId), + newScheduleId: schedule.id, + newDepartureAt: schedule.departureAt, + isSameDay, + isRouteChange, + oldFareMinor, + newFareMinor, + ...amounts, + currency: 'ETB', + cutoffAt: policy ? new Date(leg.departureAt.getTime() - policy.cutoffMinutes * 60_000) : null, + policy: policy && { feePercent: policy.feePercent, feeMinMinor: policy.feeMinMinor, sameDayFeePercent: policy.sameDayFeePercent, sameDayFeeMinMinor: policy.sameDayFeeMinMinor, routeChangeAllowed: policy.routeChangeAllowed, sameDayAllowed: policy.sameDayAllowed, cutoffMinutes: policy.cutoffMinutes }, + }; + } + + /** Mirrors SearchService's class matching: nationality filter, then bed position. */ + private pickSeatClass(classes: any[], bedPosition: string | null, nationalityType: string) { + const byNat = classes.filter((c) => !c.nationalityType || c.nationalityType === nationalityType); + const pool = byNat.length ? byNat : classes; + const bed = bedPosition?.toLowerCase() ?? null; + const exact = pool.find((c) => (c.bedPosition?.toLowerCase() ?? null) === bed); + return exact ?? pool.find((c) => !c.bedPosition) ?? pool[0] ?? null; + } + + /** Distributes the leg fare over seats, free children (fare 0) stay 0; rounding lands on the last paid seat. */ + private splitFare(total: number, seats: LegView['seats']): number[] { + const paid = seats.map((s) => s.fareMinor !== 0); + const n = paid.filter(Boolean).length || 1; + const each = Math.floor(total / n); + let remaining = total; + let lastPaid = -1; + const out = seats.map((_, i) => { if (!paid[i]) return 0; lastPaid = i; remaining -= each; return each; }); + if (lastPaid >= 0) out[lastPaid] += remaining; + return out; + } +} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index f39da4e80..c678dcf44 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -739,9 +739,11 @@ export class SeatsService { } } - // Delete the Journey (and its JourneySegments) scoped to this booking. + // Delete the Journey (and its JourneySegments) scoped to this booking. The segment FK has no + // ON DELETE CASCADE, so segments go first or the journey delete fails on a ticketed booking. async releaseSeats(bookingId: string) { - await this.prisma.journey.deleteMany({ where: { bookingId } as any }); + await this.prisma.journeySegment.deleteMany({ where: { journey: { bookingId } } }); + await this.prisma.journey.deleteMany({ where: { bookingId } }); } async getBlockedSeats() { diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index fe4207a50..27a94f4e9 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CurrencyService } from '../currency/currency.service'; import { PaymentsService } from '../payments/payments.service'; +import { RescheduleService } from '../reschedule/reschedule.service'; import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; // Retention windows @@ -510,6 +511,21 @@ export class TasksService { // ───────────────────────────────────────────────────────────────────────── // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. // ───────────────────────────────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────────────── + // Every 1 min: reschedule requests whose payment deadline passed → EXPIRED + // (their supplementary charge too). The new-seat hold lapses on its own. + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/1 * * * *') + async expireStaleReschedules() { + try { + const reschedule = await this.moduleRef.resolve(RescheduleService, undefined, { strict: false }); + const n = await reschedule.expireStale(); + if (n > 0) this.logger.log(`Expired ${n} unpaid reschedule request(s)`); + } catch (err) { + this.logger.error(`expireStaleReschedules failed: ${err instanceof Error ? err.message : err}`); + } + } + @Cron('0 2 * * *') async purgeExpiredData() { const now = new Date(); diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts index 82cc62ef6..d30f37d94 100644 --- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -18,6 +18,7 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [ perm('40f1b49c-c33d-4563-a6bb-9373eabbde9b', 'edr_passenger_app:bookings:view', 'View bookings'), perm('62810ae5-315e-4ae5-8ed1-33cead51b95a', 'edr_passenger_app:bookings:manage', 'Manage bookings'), perm('b593adf3-2060-48b0-b35d-ff9ff5d72bc4', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'), + perm('0c5e7a2b-9d41-4f7e-8b36-2a1c6d9e4f50', 'edr_passenger_app:bookings:reschedule', 'Reschedule bookings'), perm('566c968f-71f1-462d-9824-4b7cd33cecbb', 'edr_passenger_app:passengers:view', 'View passengers'), perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'), perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'), @@ -77,6 +78,7 @@ export const PASSENGER_PERMS = { view: 'edr_passenger_app:bookings:view', manage: 'edr_passenger_app:bookings:manage', cancel: 'edr_passenger_app:bookings:cancel', + reschedule: 'edr_passenger_app:bookings:reschedule', }, passengers: { view: 'edr_passenger_app:passengers:view', @@ -181,6 +183,7 @@ export const ROLE_PERMISSION_PRESETS = { stationMaster: [ PASSENGER_PERMS.bookings.view, PASSENGER_PERMS.bookings.manage, + PASSENGER_PERMS.bookings.reschedule, PASSENGER_PERMS.tickets.view, PASSENGER_PERMS.tickets.manage, PASSENGER_PERMS.tickets.generate, diff --git a/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/layout.tsx new file mode 100644 index 000000000..8e7a60fce --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function ReschedulePoliciesLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/page.tsx new file mode 100644 index 000000000..31f265b6c --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/page.tsx @@ -0,0 +1,26 @@ +'use client'; + +import ReschedulePolicyManager from '@/components/reschedule/ReschedulePolicyManager'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; + +/** + * Master Data → Reschedule Policies. One policy per fare class (coach type); a class with no + * policy cannot be rescheduled at all. Gated on bookings:view because that is what + * `GET /reschedule/policies` requires; creating, editing and deleting are admin-only server-side. + */ +export default function ReschedulePoliciesPage() { + return ( + +
+
+

Reschedule Policies

+

+ Rules that decide whether a booked journey can be moved, and what the change costs +

+
+ +
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index 873e81578..90725b683 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -111,6 +111,7 @@ export default function SettingsPage() { )} + {activeTab === 'configurations' && (

Rate Limiting (requests / minute / IP)

diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 909361c46..99c24be9a 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -28,6 +28,7 @@ import { FileText, Briefcase, Calendar, + CalendarClock, Utensils, Package, Moon, @@ -90,6 +91,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view }, { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, + { name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx b/apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx new file mode 100644 index 000000000..efc1ca5d2 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx @@ -0,0 +1,376 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Edit, Plus, Save, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { + reschedulePolicyApi, + type ReschedulePolicyCoachType, + type ReschedulePolicyRow, + type ReschedulePolicyValues, +} from '@/lib/api'; + +const EMPTY_POLICY: ReschedulePolicyValues = { + feePercent: 0, + feeMinMinor: 0, + routeChangeAllowed: true, + sameDayAllowed: true, + sameDayFeePercent: 0, + sameDayFeeMinMinor: 0, + cutoffMinutes: 60, + isActive: true, +}; + +// Money is entered in ETB and stored in minor units. +const etb = (minor: number) => String(minor / 100); +const toMinor = (value: string) => Math.round(Number(value || 0) * 100); +const feeLabel = (percent: number, minMinor: number) => + percent > 0 || minMinor > 0 ? `${percent}% · min ETB ${etb(minMinor)}` : 'Free'; + +/** + * Policy §3 - one policy per fare class (coach type), listed as a table and edited in a dialog, + * the same shape as Coach Management. A fare class with no row here cannot be rescheduled at all. + */ +export default function ReschedulePolicyManager() { + const [rows, setRows] = useState([]); + const [available, setAvailable] = useState([]); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(''); + + const [showModal, setShowModal] = useState(false); + const [editing, setEditing] = useState(null); + const [coachTypeId, setCoachTypeId] = useState(''); + const [form, setForm] = useState(EMPTY_POLICY); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(''); + + const [deleting, setDeleting] = useState(null); + const [deleteBusy, setDeleteBusy] = useState(false); + + const load = async () => { + setLoading(true); + try { + const [policies, coachTypes] = await Promise.all([ + reschedulePolicyApi.list(), + reschedulePolicyApi.availableCoachTypes(), + ]); + setRows(Array.isArray(policies) ? policies : []); + setAvailable(Array.isArray(coachTypes) ? coachTypes : []); + } catch { + setMessage('Failed to load reschedule policies.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const openCreate = () => { + setEditing(null); + setCoachTypeId(''); + setForm(EMPTY_POLICY); + setFormError(''); + setShowModal(true); + }; + + const openEdit = (row: ReschedulePolicyRow) => { + setEditing(row); + setCoachTypeId(row.coachTypeId); + setForm({ + feePercent: row.feePercent, + feeMinMinor: row.feeMinMinor, + routeChangeAllowed: row.routeChangeAllowed, + sameDayAllowed: row.sameDayAllowed, + sameDayFeePercent: row.sameDayFeePercent, + sameDayFeeMinMinor: row.sameDayFeeMinMinor, + cutoffMinutes: row.cutoffMinutes, + isActive: row.isActive, + }); + setFormError(''); + setShowModal(true); + }; + + const setField = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); + + const submit = async () => { + if (!editing && !coachTypeId) { + setFormError('Pick a fare class.'); + return; + } + setSaving(true); + setFormError(''); + try { + if (editing) await reschedulePolicyApi.update(editing.coachTypeId, form); + else await reschedulePolicyApi.create({ coachTypeId, ...form }); + setShowModal(false); + setMessage(editing ? 'Policy updated.' : 'Policy created.'); + await load(); + } catch (err: any) { + setFormError(err?.response?.data?.message || err?.message || 'Failed to save the policy.'); + } finally { + setSaving(false); + } + }; + + const confirmDelete = async () => { + if (!deleting) return; + setDeleteBusy(true); + try { + await reschedulePolicyApi.remove(deleting.coachTypeId); + setDeleting(null); + setMessage('Policy deleted.'); + await load(); + } catch { + setMessage('Failed to delete the policy.'); + } finally { + setDeleteBusy(false); + } + }; + + const columns = [ + { + key: 'coachType', + label: 'Fare class', + render: (row: ReschedulePolicyRow) => ( +
+ {row.coachType?.code} + - {row.coachType?.name} +
+ ), + }, + { + key: 'fee', + label: 'Change fee', + render: (row: ReschedulePolicyRow) => ( + {feeLabel(row.feePercent, row.feeMinMinor)} + ), + }, + { + key: 'routeChangeAllowed', + label: 'Route change', + render: (row: ReschedulePolicyRow) => ( + + {row.routeChangeAllowed ? 'Allowed' : 'Not permitted'} + + ), + }, + { + key: 'sameDay', + label: 'Same-day change', + render: (row: ReschedulePolicyRow) => + row.sameDayAllowed ? ( + {feeLabel(row.sameDayFeePercent, row.sameDayFeeMinMinor)} + ) : ( + Not permitted + ), + }, + { + key: 'cutoffMinutes', + label: 'Cutoff', + render: (row: ReschedulePolicyRow) => ( + {row.cutoffMinutes} min + ), + }, + { + key: 'isActive', + label: 'Status', + render: (row: ReschedulePolicyRow) => ( + + {row.isActive ? 'Active' : 'Disabled'} + + ), + }, + ]; + + const actions = [ + { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, + { + label: 'Delete', + onClick: (row: ReschedulePolicyRow) => setDeleting(row), + variant: 'danger' as const, + icon: Trash2, + }, + ]; + + return ( +
+
+ {/* The page supplies the title; this is the rule-of-thumb the table's numbers mean. */} +

+ Fee = max(fee % × original leg fare, minimum). A higher new fare is always charged on top; a lower one is + not refunded. Same-day = new departure on the same calendar day as the original. A fare class with no + policy here cannot be rescheduled at all. +

+ + Add Reschedule Policy + +
+ + {!loading && available.length === 0 && ( +

Every fare class already has a policy.

+ )} + {message &&

{message}

} + + + + setShowModal(false)} + title={editing ? `Edit Reschedule Policy - ${editing.coachType?.code}` : 'Add Reschedule Policy'} + size="lg" + > +
+
+ + {editing ? ( + <> + + {/* One policy per fare class, so editing never re-points a row at another class. */} +

A policy stays attached to its fare class.

+ + ) : ( + + )} +
+ +
+
+ + setField({ feePercent: Number(e.target.value) })} + /> +
+
+ + setField({ feeMinMinor: toMinor(e.target.value) })} + /> +
+
+ + setField({ cutoffMinutes: Number(e.target.value) })} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + setField({ sameDayFeePercent: Number(e.target.value) })} + /> +
+
+ + setField({ sameDayFeeMinMinor: toMinor(e.target.value) })} + /> +
+
+ + {formError &&

{formError}

} + +
+ setShowModal(false)}> + Cancel + + + {editing ? 'Update Policy' : 'Create Policy'} + +
+
+
+ + setDeleting(null)} + onConfirm={confirmDelete} + title="Delete reschedule policy" + message={`Delete the reschedule policy for ${deleting?.coachType?.code ?? ''}?`} + warning="Passengers on this fare class will no longer be able to reschedule. Bookings already rescheduled are unaffected." + confirmText="Delete" + isDanger + isLoading={deleteBusy} + /> +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index cede0b966..f444adda8 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -542,6 +542,40 @@ export const systemConfigApi = { update: (data: Record) => apiClient.patch>('/config', data), }; +// Reschedule Policy API — one policy per coach type (fare class). A coach type with no policy +// simply has no row, and rescheduling is refused for it. +export interface ReschedulePolicyValues { + feePercent: number; + feeMinMinor: number; + routeChangeAllowed: boolean; + sameDayAllowed: boolean; + sameDayFeePercent: number; + sameDayFeeMinMinor: number; + cutoffMinutes: number; + isActive: boolean; +} +export interface ReschedulePolicyCoachType { + id: string; + code: string; + name: string; + type: string; +} +export interface ReschedulePolicyRow extends ReschedulePolicyValues { + id: string; + coachTypeId: string; + coachType: ReschedulePolicyCoachType; +} +export const reschedulePolicyApi = { + list: () => apiClient.get('/reschedule/policies'), + availableCoachTypes: () => + apiClient.get('/reschedule/policies/available-coach-types'), + create: (data: ReschedulePolicyValues & { coachTypeId: string }) => + apiClient.post('/reschedule/policies', data), + update: (coachTypeId: string, data: Partial) => + apiClient.patch(`/reschedule/policies/${coachTypeId}`, data), + remove: (coachTypeId: string) => apiClient.delete(`/reschedule/policies/${coachTypeId}`), +}; + // App Releases API export const appReleasesApi = { getAll: async () => { diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index d42ab0dc3..ab3a6376f 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -4,6 +4,8 @@ import { Suspense } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; +import { useAuthStore } from "@/lib/auth-store"; +import { samePhone } from "@/utils/phone"; import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; import { useEffect, useState } from "react"; import { @@ -78,6 +80,13 @@ function BookingDetailContent() { searchParams.get("bookingRef") || searchParams.get("pnr"); + // `isInitialized` gates on the auth store having read localStorage. Without it a signed-in + // user watches the Reschedule button appear a beat after the page, because the store starts + // every render as logged-out. AppSidebar calls initialize() from the root layout. + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isAuthInitialized = useAuthStore((s) => s.isInitialized); + const currentUserPhone = useAuthStore((s) => s.user?.phone); + // Mirrors /booking/payment's state shape: selectedMethod is the PaymentMethod `type` // (used both for lookup and to decide provider-specific redirect handling), not the id. const [selectedMethod, setSelectedMethod] = useState(null); @@ -329,6 +338,20 @@ function BookingDetailContent() { const isExpired = booking.status === "EXPIRED"; const isCancelled = booking.status === "CANCELLED"; + // Whether this booking is the kind that can be rescheduled at all. The per-leg rules + // (fare-class policy, cutoff, already-boarded) are the API's call and are shown on the + // reschedule page itself; this is only the coarse shape test. + const bookingSupportsReschedule = + !booking.isPackageBooking && + ["ONE_WAY", "ROUND_TRIP"].includes(booking.bookingType) && + !booking.outboundBoardedAt; + + const isBooker = samePhone(currentUserPhone, booking.contactPhone); + + const canReschedule = isAuthenticated && (isBooker || !booking.contactPhone); + + const reschedulePath = `/booking/reschedule?ref=${booking.bookingRef}`; + const StatusBadge = () => { const statusConfig = { PENDING_PAYMENT: { @@ -1022,6 +1045,31 @@ function BookingDetailContent() { )} + {/* Rescheduling is account-only: every /bookings/:ref/reschedule route sits behind + JwtGuard and resolves ownership from the signed-in IAM user. A guest who got + here through booking lookup (ref + phone) has no session, so instead of hiding + the option we name the blocker and send them somewhere that fixes it. */} + {bookingSupportsReschedule && (!isAuthInitialized || !isAuthenticated || canReschedule) && ( + + )} )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx new file mode 100644 index 000000000..816d2547d --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx @@ -0,0 +1,772 @@ +"use client"; + +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { AlertCircle, ArrowRight, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react"; +import { apiClient } from "@/lib/api-client"; +import { useAuthStore } from "@/lib/auth-store"; +import ModernDatePicker from "@/components/ModernDatePicker"; +import StationDropdown, { + pushRecentStation, + readRecentStationIds, +} from "@/components/StationDropdown"; +import SeatMap, { buildSeatLabel, getValidSeatsForCoach } from "@/components/SeatMap"; +import { formatTime } from "@/utils/format"; +import { Station } from "@/types"; + +// Same horizon the search widget uses — /search/available-dates is server-clamped to 90 days, +// so the picker's maxDate has to match or unchecked future months render as pickable again. +const AVAILABLE_DATES_RANGE_DAYS = 90; + +const toDateStr = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + +interface AvailableDatesResponse { + routeExists: boolean; + dates: { date: string; available: boolean }[]; +} + +type LegOption = { + leg: number; + scheduleId: string; + originStationId: string | null; + destinationStationId: string | null; + departureAt: string; + coachTypeId: string; + seatCount: number; + passengerNames: string[]; + oldFareMinor: number; + policy: { + feePercent: number; + feeMinMinor: number; + routeChangeAllowed: boolean; + sameDayAllowed: boolean; + sameDayFeePercent: number; + sameDayFeeMinMinor: number; + cutoffMinutes: number; + } | null; + canReschedule: boolean; + blockers: string[]; +}; + +type Options = { + bookingRef: string; + bookingType: string; + legs: LegOption[]; + pending: { id: string; amountDueMinor: number; paymentToken: string | null; expiresAt: string | null } | null; +}; + +type Quote = { + allowed: boolean; + blockers: string[]; + oldFareMinor: number; + newFareMinor: number; + feeMinor: number; + fareDifferenceMinor: number; + amountDueMinor: number; + isSameDay: boolean; + isRouteChange: boolean; +}; + +const etb = (minor: number) => `ETB ${(minor / 100).toFixed(2)}`; + +/** "Sat 29 Aug, 21:00" — the summary's departure line. Null when the value isn't a real date. */ +const formatDepartureDay = (value: string | Date | undefined | null): string | null => { + if (!value) return null; + const d = value instanceof Date ? value : new Date(value); + return isNaN(d.getTime()) ? null : format(d, "EEE dd MMM, HH:mm"); +}; + +function ReschedulePageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const ref = searchParams.get("ref") || ""; + + // Every endpoint this page calls is behind JwtGuard, so a guest who deep-links here would + // otherwise watch the options request 401 and land on "This booking cannot be rescheduled" — + // which blames the booking for what is really a missing session. Send them to sign in and + // bring them straight back instead. Waits for isInitialized: the store starts logged-out. + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isAuthInitialized = useAuthStore((s) => s.isInitialized); + const needsLogin = isAuthInitialized && !isAuthenticated; + + useEffect(() => { + if (!needsLogin) return; + const back = ref ? `/booking/reschedule?ref=${ref}` : "/booking/lookup"; + router.replace(`/login?redirect=${encodeURIComponent(back)}`); + }, [needsLogin, ref, router]); + + const [legNo, setLegNo] = useState(1); + const [date, setDate] = useState(undefined); + const [originId, setOriginId] = useState(""); + const [destinationId, setDestinationId] = useState(""); + const [searched, setSearched] = useState<{ originId: string; destinationId: string; date: string } | null>(null); + const [schedule, setSchedule] = useState(null); + // Seat per passenger, keyed by index into leg.passengerNames. The API pairs newSeatIds[i] + // with the i-th BookingSeat in that same order, so an explicit map is the only way the + // right passenger keeps the right fare (a free child must not inherit an adult's seat). + const [passengerSeatMap, setPassengerSeatMap] = useState>({}); + const [activePassengerIndex, setActivePassengerIndex] = useState(0); + const [selectedCoach, setSelectedCoach] = useState(null); + const [done, setDone] = useState<{ status: string } | null>(null); + const [error, setError] = useState(null); + const [dateNotice, setDateNotice] = useState(null); + const [recentStationIds, setRecentStationIds] = useState( + readRecentStationIds, + ); + const saveRecent = (id: string) => + setRecentStationIds((prev) => pushRecentStation(id, prev)); + + const resetSeats = () => { + setPassengerSeatMap({}); + setActivePassengerIndex(0); + setSelectedCoach(null); + }; + + const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery({ + queryKey: ["reschedule-options", ref], + queryFn: () => apiClient.get(`/bookings/${ref}/reschedule`), + // Never fire before the session is known — an unauthenticated call only 401s. + enabled: !!ref && isAuthInitialized && isAuthenticated, + retry: false, + }); + const { data: stations = [] } = useQuery({ + queryKey: ["stations"], + queryFn: () => apiClient.get("/stations"), + }); + + const leg = useMemo(() => options?.legs.find((l) => l.leg === legNo) ?? options?.legs[0], [options, legNo]); + + // Prefill route from the leg being changed. + useEffect(() => { + if (!leg) return; + setOriginId(leg.originStationId ?? ""); + setDestinationId(leg.destinationStationId ?? ""); + setSchedule(null); + resetSeats(); + setSearched(null); + }, [leg?.leg, leg?.scheduleId]); + + const journeyDirection = leg?.leg === 2 ? "RETURN" : options?.bookingType === "ROUND_TRIP" ? "OUTBOUND" : "ONE_WAY"; + + // Which dates actually have a bookable train for the chosen From/To. Same endpoint and same + // shape the home-page search widget uses, so the reschedule calendar greys out the same days + // rather than letting someone pick a date that can only come back empty. + const { data: availableDates } = useQuery({ + queryKey: ["available-dates", originId, destinationId], + queryFn: async () => { + const from = new Date(); + const to = new Date(); + to.setDate(to.getDate() + AVAILABLE_DATES_RANGE_DAYS); + return (await apiClient.get("/search/available-dates", { + params: { + originStationId: originId, + destinationStationId: destinationId, + from: toDateStr(from), + to: toDateStr(to), + }, + })) as AvailableDatesResponse; + }, + enabled: !!originId && !!destinationId && originId !== destinationId, + staleTime: 5 * 60 * 1000, + }); + + const disabledDates = useMemo(() => { + const set = new Set(); + // routeExists === false is handled by disabling the control outright (noRouteForPair): + // the server returns an empty `dates` array in that case anyway. + if (!availableDates?.routeExists) return set; + for (const d of availableDates.dates) if (!d.available) set.add(d.date); + return set; + }, [availableDates]); + + const noRouteForPair = + !!originId && !!destinationId && availableDates?.routeExists === false; + + const maxSearchDate = useMemo(() => { + const d = new Date(); + d.setDate(d.getDate() + AVAILABLE_DATES_RANGE_DAYS); + return d; + }, []); + + // If the picked date turns out to have no train (stations changed, or the availability query + // just resolved), drop it and say why — rather than letting "Find trains" return nothing. + useEffect(() => { + if (date && disabledDates.has(toDateStr(date))) { + setDate(undefined); + setSearched(null); + setSchedule(null); + resetSeats(); + setDateNotice("No trains run this route on that date — please pick another."); + } + }, [date, disabledDates]); + + // Losing the route invalidates any date already chosen, so nothing stale can be submitted + // from behind a now-disabled control. + useEffect(() => { + if (noRouteForPair && date) { + setDate(undefined); + setSearched(null); + setSchedule(null); + resetSeats(); + } + }, [noRouteForPair, date]); + + const { data: schedules = [], isFetching: searching } = useQuery({ + queryKey: ["reschedule-search", searched], + queryFn: async () => { + const res: any = await apiClient.post("/search", { + originStationId: searched!.originId, + destinationStationId: searched!.destinationId, + date: searched!.date, + adultCount: leg?.seatCount ?? 1, + childCount: 0, + journeyType: "ONE_WAY", + }); + const list = Array.isArray(res) ? res : res?.outbound ?? res?.data ?? []; + return list.filter((s: any) => (s.scheduleId || s.id) !== leg?.scheduleId || searched!.originId !== leg?.originStationId || searched!.destinationId !== leg?.destinationStationId); + }, + enabled: !!searched && !!leg, + }); + + const scheduleId = schedule ? schedule.scheduleId || schedule.id : null; + const { data: seatMap, isLoading: loadingSeats } = useQuery({ + queryKey: ["reschedule-seatmap", scheduleId, leg?.coachTypeId, originId, destinationId], + queryFn: async () => { + const res: any = await apiClient.get( + `/seats/seatmap/${scheduleId}?coachTypeId=${leg!.coachTypeId}&journeyDirection=${journeyDirection}&originStationId=${originId}&destinationStationId=${destinationId}`, + ); + return res?.data || res; + }, + enabled: !!scheduleId && !!leg, + }); + + const coaches: any[] = useMemo(() => seatMap?.coaches ?? [], [seatMap]); + + // Expand the first coach automatically — with one coach of the booked class on most trains, + // making the user open it before any seat is visible is a click for nothing. Fires once per + // schedule: keying it on `selectedCoach` instead would re-open the coach the moment the user + // collapsed it, since collapsing sets selectedCoach back to null. + const autoExpandedFor = useRef(null); + useEffect(() => { + if (!scheduleId || coaches.length === 0) return; + if (autoExpandedFor.current === scheduleId) return; + autoExpandedFor.current = scheduleId; + setSelectedCoach(coaches[0].id); + }, [scheduleId, coaches]); + + // newSeatIds must line up with the leg's BookingSeats, which the API orders by passenger + // name — the same order `passengerNames` arrives in. Indexing by passenger builds that + // order by construction, so there is nothing for a click sequence to get wrong. + const orderedSeatIds = useMemo( + () => Array.from({ length: leg?.seatCount ?? 0 }, (_, i) => passengerSeatMap[i]).filter(Boolean) as string[], + [passengerSeatMap, leg?.seatCount], + ); + const allSeatsChosen = !!leg && orderedSeatIds.length === leg.seatCount; + + const isSeatSelected = (seatId: string) => passengerSeatMap[activePassengerIndex] === seatId; + const isSeatAssignedToOther = (seatId: string) => + Object.entries(passengerSeatMap).some( + ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId, + ); + + const handleSeatToggle = (seatId: string) => { + if (isSeatAssignedToOther(seatId)) return; + setPassengerSeatMap((prev) => { + const next = { ...prev }; + if (next[activePassengerIndex] === seatId) { + delete next[activePassengerIndex]; + return next; + } + next[activePassengerIndex] = seatId; + // Move to the next passenger still without a seat so a multi-passenger leg can be + // filled by clicking straight down the coach. + const total = leg?.seatCount ?? 1; + const nextUnassigned = Array.from({ length: total }, (_, i) => i).find((i) => !next[i]); + if (nextUnassigned !== undefined) setActivePassengerIndex(nextUnassigned); + return next; + }); + }; + + // No skipping ahead of a passenger who still needs a seat — same rule as /booking/seats. + const firstUnassignedIndex = Array.from({ length: leg?.seatCount ?? 0 }, (_, i) => i).find( + (i) => !passengerSeatMap[i], + ); + const maxSelectableIndex = + firstUnassignedIndex === undefined ? (leg?.seatCount ?? 1) - 1 : firstUnassignedIndex; + + const quoteBody = leg && scheduleId && allSeatsChosen + ? { leg: leg.leg, newScheduleId: scheduleId, newOriginStationId: originId, newDestinationStationId: destinationId, newSeatIds: orderedSeatIds } + : null; + const { data: quote, isFetching: quoting } = useQuery({ + queryKey: ["reschedule-quote", ref, quoteBody], + queryFn: () => apiClient.post(`/bookings/${ref}/reschedule/quote`, quoteBody), + enabled: !!quoteBody, + }); + + const confirm = useMutation({ + mutationFn: async () => { + const hold: any = await apiClient.post("/seats/hold", { + scheduleId, + originStationId: originId, + destinationStationId: destinationId, + journeyDirection, + passengers: orderedSeatIds.map((seatId, i) => ({ passengerId: `reschedule-${ref}-${i}`, seatId })), + }); + return apiClient.post(`/bookings/${ref}/reschedule`, { ...quoteBody, holdId: hold.holdId || hold.id }); + }, + onSuccess: (res) => { + if (res.paymentToken) router.push(`/pay-balance/${res.paymentToken}`); + else setDone({ status: res.status }); + }, + onError: (e: any) => setError(e?.response?.data?.message || e?.message || "Could not reschedule"), + }); + + const stationName = (id: string | null) => stations.find((s) => s.id === id)?.name ?? id ?? "—"; + + if (!ref) return

Missing booking reference.

; + // Hold the spinner through the redirect rather than flashing the booking's error state. + if (!isAuthInitialized || needsLogin) { + return ; + } + if (loadingOptions) return ; + if (optionsError || !options || !leg) { + return

{(optionsError as any)?.response?.data?.message || "This booking cannot be rescheduled."}

; + } + + if (done) { + return ( + +
+ +

Booking rescheduled

+

New tickets have been issued for booking {ref}.

+ +
+
+ ); + } + + 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; + + // Short label/value pairs rather than prose — the rules are scanned, not read. + const fareRules = leg.policy + ? [ + { + label: "Change fee", + value: + leg.policy.feePercent > 0 || leg.policy.feeMinMinor > 0 + ? `${leg.policy.feePercent}% of fare (min ${etb(leg.policy.feeMinMinor)})` + : "Free", + }, + { label: "Route change", value: leg.policy.routeChangeAllowed ? "Allowed" : "Not permitted" }, + { + label: "Same-day change", + value: !leg.policy.sameDayAllowed + ? "Not permitted" + : leg.policy.sameDayFeePercent > 0 || leg.policy.sameDayFeeMinMinor > 0 + ? `${leg.policy.sameDayFeePercent}% (min ${etb(leg.policy.sameDayFeeMinMinor)})` + : "Free", + }, + { label: "Changes close", value: `${leg.policy.cutoffMinutes} min before departure` }, + { label: "Higher new fare", value: "Payable" }, + { label: "Lower new fare", value: "Not refunded" }, + ] + : []; + + const canSearch = + !!date && !!originId && !!destinationId && originId !== destinationId && !noRouteForPair; + + // Mirrors the booking flow's FareSidebar: a sticky money card that is present from the + // start and fills in as choices are made, rather than a total that appears at the end. + // Rendered twice — inline under the content on mobile, sticky beside it on desktop. + const ChangeSummary = () => ( +
+

+ Change summary +

+ +
+
Currently
+
+ {formatDepartureDay(leg.departureAt)} +
+
+ {stationName(leg.originStationId)} → {stationName(leg.destinationStationId)} +
+
+ + {schedule && ( +
+
Changing to
+ {/* Same date+time line as "Currently" above, so the two are read side by side. */} +
+ {formatDepartureDay(schedule.departureAt) ?? formatTime(schedule.departureAt)} +
+
+ {schedule.trainName || schedule.trainNumber} · arrives {formatTime(schedule.arrivalAt)} +
+
+ {stationName(originId)} → {stationName(destinationId)} +
+
+ )} + +
+ {leg.passengerNames.map((name, i) => { + const seatId = passengerSeatMap[i]; + const seat = seatId + ? coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === seatId) + : null; + return ( +
+ {name} + + {seat ? `Seat ${buildSeatLabel(seat)}` : "—"} + +
+ ); + })} +
+ + {!quoteBody ? ( +

+ Pick a new train and a seat for {leg.seatCount > 1 ? "every passenger" : "the passenger"} to see what this change costs. +

+ ) : quoting ? ( +
+ +
+ ) : quote ? ( +
+ + + = 0 ? "Fare difference" : "Fare difference (not refunded)"} + value={etb(Math.max(0, quote.fareDifferenceMinor))} + /> + +
+ Total due now + {etb(quote.amountDueMinor)} +
+ {quote.blockers.length > 0 && ( +
+ +
{quote.blockers.map((b) =>
{b}
)}
+
+ )} + {error &&
{error}
} + +
+ ) : null} +
+ ); + + 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) => ( + + ))} +
+ )} + + {/* No `items-start` here on purpose: it shrinks each column to its own content height, and a + sticky child can only travel inside its containing block — so the summary would scroll + away like a normal card. The grid default (stretch) gives the right column the full row + height to stick within. */} +
+ {/* Left column — the choices */} +
+
+ {fareRules.length > 0 && ( +
+
Your fare rules
+
    + {fareRules.map((rule) => ( +
  • + + + {rule.label}:{" "} + {rule.value} + +
  • + ))} +
+
+ )} + + {!leg.canReschedule && ( +
+ +
{leg.blockers.map((b) =>
{b}
)}
+
+ )} + + {leg.canReschedule && ( + <> + {/* Step 1: route + date */} +
+ { + setOriginId(s.id); + if (s.id) saveRecent(s.id); + setDateNotice(null); + setSchedule(null); + resetSeats(); + setSearched(null); + }} + /> + { + setDestinationId(s.id); + if (s.id) saveRecent(s.id); + setDateNotice(null); + setSchedule(null); + resetSeats(); + setSearched(null); + }} + /> + { + setDateNotice(null); + setDate(d); + }} + minDate={new Date()} + maxDate={originId && destinationId ? maxSearchDate : undefined} + disabledDates={disabledDates} + disabled={noRouteForPair} + placeholder="New date" + /> + +
+ {routeLocked && ( +

+ Your fare class does not permit changing stations — only the date and train. +

+ )} + {noRouteForPair && ( +

+ No route connects these stations — pick a different destination. +

+ )} + {dateNotice && !noRouteForPair && ( +

{dateNotice}

+ )} + + {/* Step 2: schedule */} + {searched && !searching && schedules.length === 0 &&

No trains on that day.

} + {schedules.length > 0 && ( +
+ {schedules.map((s: any) => { + const id = s.scheduleId || s.id; + const selected = scheduleId === id; + return ( + + ); + })} +
+ )} + + {/* Step 3: seats (same class as booked) */} + {scheduleId && ( +
+

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

+

+ {allSeatsChosen + ? "Every passenger has a seat." + : `Choosing a seat for ${leg.passengerNames[activePassengerIndex] ?? `Passenger ${activePassengerIndex + 1}`}.`} +

+ + {/* Who gets which seat. The API pairs seats to passengers by position, so this + mapping is the payload — not a display convenience. */} +
+ {leg.passengerNames.map((name, i) => { + const assignedSeatId = passengerSeatMap[i]; + const assignedSeat = assignedSeatId + ? coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === assignedSeatId) + : null; + const isActive = i === activePassengerIndex; + const isClickable = i <= maxSelectableIndex; + return ( + + ); + })} +
+ + {loadingSeats ? ( + + ) : ( + + )} +
+ )} + + + )} +
{/* end left card */} + + {/* Mobile: the summary sits under the choices instead of beside them */} +
+ +
+
{/* end left column */} + + {/* Right column — sticky change summary (desktop only) */} +
+ {/* Caps at the viewport and scrolls inside itself, so a long passenger list can't push + the total and the confirm button off the bottom of the screen. */} +
+ +
+
+
+
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return
{label}{value}
; +} + +// `wide` switches to the booking flow's two-column width and hands card styling to the +// columns themselves; the narrow single-card form still carries the loading/error states. +function Shell({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) { + return ( +
+
+ {wide ? ( +
{children}
+ ) : ( +
+ {children} +
+ )} +
+
+ ); +} + +export default function ReschedulePage() { + return ( + }> + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index bb4e2cf12..3b6a17bb8 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -23,6 +23,10 @@ import { } from "lucide-react"; import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import ModernDatePicker from "@/components/ModernDatePicker"; +import StationDropdown, { + pushRecentStation, + readRecentStationIds, +} from "@/components/StationDropdown"; const AVAILABLE_DATES_RANGE_DAYS = 90; @@ -393,163 +397,6 @@ function PassengerModal({ ); } -// ─── Station Autocomplete (Desktop dropdown) ────────────────────────────────── -function StationDropdown({ - stations, - value, - excludeId, - placeholder, - onSelect, - error, - recentIds, - onOpen, -}: { - stations: Station[]; - value: string; - excludeId?: string; - placeholder: string; - onSelect: (s: Station) => void; - error?: string; - recentIds: string[]; - onOpen?: () => void; -}) { - const [query, setQuery] = useState(""); - const [open, setOpen] = useState(false); - const ref = useRef(null); - const inputRef = useRef(null); - const selectedStation = stations.find((s) => s.id === value); - - useEffect(() => { - if (selectedStation && !open) setQuery(""); - }, [selectedStation, open]); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) - setOpen(false); - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, []); - - const filtered = query.trim() - ? stations.filter( - (s) => - s.id !== excludeId && - (s.name.toLowerCase().includes(query.toLowerCase()) || - s.code?.toLowerCase().includes(query.toLowerCase())), - ) - : stations.filter((s) => s.id !== excludeId).slice(0, 20); - - const displayValue = open ? query : (selectedStation?.name ?? ""); - - return ( -
-
- - { - setQuery(e.target.value); - setOpen(true); - }} - onFocus={() => { - setQuery(""); - setOpen(true); - onOpen?.(); - }} - placeholder={placeholder} - className="w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm text-gray-900 dark:text-white placeholder-gray-400" - /> - {value && ( - - )} -
- - {open && ( -
- {!query && recentIds.length > 0 && ( -
-

- Recent -

- {recentIds - .map((id) => stations.find((s) => s.id === id)) - .filter(Boolean) - .map((s) => ( - - ))} -
-
- )} - {filtered.length === 0 ? ( -

- No stations found -

- ) : ( - filtered.map((s) => ( - - )) - )} -
- )} -
- ); -} - // ─── Main Page ──────────────────────────────────────────────────────────────── export default function SearchPage() { const router = useRouter(); @@ -581,13 +428,9 @@ export default function SearchPage() { useEffect(() => { router.prefetch("/booking/results"); }, [router]); - const [recentStationIds, setRecentStationIds] = useState(() => { - try { - return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]"); - } catch { - return []; - } - }); + const [recentStationIds, setRecentStationIds] = useState( + readRecentStationIds, + ); const passengerRef = useRef(null); const widgetRef = useRef(null); @@ -847,11 +690,7 @@ export default function SearchPage() { }, [noReturnRouteForPair, returnDate, setValue, clearErrors]); const saveRecent = useCallback((id: string) => { - setRecentStationIds((prev) => { - const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5); - localStorage.setItem("edr_recent_stations", JSON.stringify(next)); - return next; - }); + setRecentStationIds((prev) => pushRecentStation(id, prev)); }, []); const handleSwap = () => { diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index fa1a86524..e457c7d62 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -6,157 +6,18 @@ import { useRouter } from "next/navigation"; import { useBookingStore } from "@/lib/booking-store"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; -import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react"; -import { Armchair, Bed, ChevronLeft, ChevronDown, Train, TrainFront, X } from "lucide-react"; -import Image from "next/image"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { ChevronLeft, ChevronDown, Train, TrainFront, X } from "lucide-react"; import CustomModal from "@/components/CustomModal"; import { Skeleton } from "@/components/Skeleton"; import { isChild } from "@/utils/fare-utils"; - -const BED_POSITION_SUFFIX: Record = { lower: 'L', middle: 'M', upper: 'U' }; - -const buildSeatLabel = (seat: any): string => { - const base: string = seat.number || seat.label || seat.seatNumber || ''; - if (!base) return ''; - const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? '') : ''; - return suffix ? `${base}${suffix}` : base; -}; - -const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => { - const seatLabel = bed.label || bed.seatNumber || bed.number || "?"; - const bedPosition = bed.bedPosition || ""; - const bedType = - bedPosition === "upper" - ? "Upper" - : bedPosition === "middle" - ? "Middle" - : "Lower"; - const isDisabled = bed.status !== "AVAILABLE" || isAssignedToOther; - - return ( - - ); -}); - -BedCard.displayName = "BedCard"; - -// A real berth ladder is a single fixed rail mounted at the end of the bay that a -// passenger climbs to reach every level — not a separate rung floating between each -// pair of beds. So this renders once per bay, right after the last berth card, with -// solid rounded rails/rungs (like a real metal ladder) rather than thin decorative lines. -const LadderConnector = memo(() => ( - -)); - -LadderConnector.displayName = "LadderConnector"; - -const SeatButton = memo( - ({ - seat, - isSelected, - isAssignedToOther, - onToggle, - isBedCoach, - bedLabel, - coachSeatClass, - }: any) => { - const seatLabel = seat.number || seat.label || seat.seatNumber || "?"; - const bedWidth = "w-24"; - const width = isBedCoach ? bedWidth : "w-10"; - const isDisabled = seat.status !== "AVAILABLE" || isAssignedToOther; - - return ( -
- -
- ); - }, -); - -SeatButton.displayName = "SeatButton"; +import { + buildSeatLabel, + CoachSeatLayout, + getBedPosition, + getValidSeatsForCoach as getValidSeatsForCoachData, +} from "@/components/SeatMap"; export default function SeatsPage() { const router = useRouter(); @@ -701,72 +562,11 @@ export default function SeatsPage() { [filteredCoaches, selectedCoach], ); - const getBedPosition = (selectedClass: string): string | null => { - const lowerClass = selectedClass.toLowerCase(); - if (lowerClass.includes("upper")) return "upper"; - if (lowerClass.includes("middle")) return "middle"; - if (lowerClass.includes("lower")) return "lower"; - return null; - }; - - // Extracted so it can be applied to ANY coach, not just the one currently expanded — - // Auto Assign needs to look across every coach of this type, not just selectedCoachData. + // Berth-class narrowing lives in the shared SeatMap module so the booking and reschedule + // flows filter beds identically; this wrapper just binds the current leg's fare class. const getValidSeatsForCoach = useCallback( - (coachData: any): any[] => { - if (!coachData) return []; - - // If coach has rooms, extract all beds from rooms - if (coachData.rooms?.length > 0) { - const allBeds: any[] = []; - coachData.rooms.forEach((room: any) => { - if (room.beds) { - allBeds.push(...room.beds); - } - }); - - let beds = allBeds.filter((s: any) => { - const seatLabel = s.label || s.number || s.seatNumber || ""; - return seatLabel && !seatLabel.startsWith("-"); - }); - - const isBedCoach = - coachData.seatClass?.toLowerCase().includes("bed") || - coachData.mode?.toLowerCase().includes("bed"); - - if (isBedCoach && currentSchedule?.selectedSeatClass) { - const selectedBedPosition = getBedPosition( - currentSchedule.selectedSeatClass, - ); - if (selectedBedPosition) { - beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition); - } - } - - return beds; - } - - // Fallback to old seat structure - let seats = (coachData.seats || []).filter((s: any) => { - const seatLabel = s.label || s.number || s.seatNumber || ""; - return seatLabel && !seatLabel.startsWith("-"); - }); - const isBedCoach = - coachData.isBedCoach === true || - seats.some((s: any) => s.bedPosition) || - coachData.seatClass?.toLowerCase().includes("bed") || - coachData.mode?.toLowerCase().includes("bed"); - - if (isBedCoach && currentSchedule?.selectedSeatClass) { - const selectedBedPosition = getBedPosition( - currentSchedule.selectedSeatClass, - ); - if (selectedBedPosition) { - seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition); - } - } - - return seats; - }, + (coachData: any): any[] => + getValidSeatsForCoachData(coachData, currentSchedule?.selectedSeatClass), [currentSchedule?.selectedSeatClass], ); @@ -1369,355 +1169,6 @@ export default function SeatsPage() { } }, [seatEligibility, seatEligibleIndices, activePassengerIndex]); - const parseSeatArrangement = ( - arrangement: string | null, - seatClasses?: string[], - ): number[] => { - if (!arrangement) return [2, 2]; - - // Check if this is a bed coach based on seat classes - const isBedCoach = seatClasses?.some((sc) => - sc?.toLowerCase().includes("bed"), - ); - - if (isBedCoach) { - // For bed coaches, arrangement like "3+0" means 3 beds stacked vertically - // We want to render them as single column, so return [1] - const parts = arrangement - .split("+") - .map((p) => parseInt(p.trim())) - .filter((n) => !isNaN(n) && n > 0); - return parts.length > 0 ? [Math.max(...parts)] : [3]; - } - - // For regular seats, parse normally (e.g., "3+2" -> [3, 2]) - const parts = arrangement - .split("+") - .map((p) => parseInt(p.trim())) - .filter((n) => !isNaN(n) && n > 0); - return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2]; - }; - - const renderCoachSeats = (coach: any, isBedCoach: boolean) => { - const arrangement = parseSeatArrangement( - coach.seatArrangement, - coach.seatClasses || [coach.seatClass], - ); - - if (validSeats.length === 0) { - return
No seats
; - } - - const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); - const seatClassStr = - typeof selectedCoachData?.seatClass === "string" - ? selectedCoachData.seatClass - : selectedCoachData?.seatClass?.name || ""; - - // Indian-sleeper-style berth bay: Lower / Middle / Upper laid out horizontally, with - // the single ladder that actually serves the whole bay shown once at the end. - const renderBerthBay = (beds: any[], keyPrefix: string) => ( -
- {beds.map((bed: any) => ( - - ))} - {beds.length > 1 && } -
- ); - - // Two-side compartment: the left bay and right bay each get their own row (berths - // still laid out horizontally within a row), stacked one above the other and split - // by a dashed aisle divider — instead of squeezing both sides into a single row. - const renderCompartment = (leftBay: any[], rightBay: any[], key: string) => ( -
-
- {leftBay.length > 0 && ( -
{renderBerthBay(leftBay, `${key}-left`)}
- )} - {leftBay.length > 0 && rightBay.length > 0 && ( -
- )} - {rightBay.length > 0 && ( -
{renderBerthBay(rightBay, `${key}-right`)}
- )} -
-
- ); - - // Bay position ordering + left/right side detection shared by both bed layouts below. - const BERTH_ORDER = ["lower", "middle", "upper"]; - const bedSideIsLeft = (bed: any, leftColByPosition: Record) => { - if (bed.position === "LEFT") return true; - if (bed.position === "RIGHT") return false; - const leftCol = leftColByPosition[bed.bedPosition]; - return leftCol ? bed.col === leftCol : true; - }; - - // Bed coach with bed positions (Upper, Middle, Lower) - if (isBedCoach && hasBedPositionData) { - // Check if this is VIP_BED or ECONOMY_BED based on room data - const rooms = (coach as any).rooms || []; - const hasRooms = rooms.length > 0; - - if (hasRooms) { - // Room-based layout (VIP_BED with 4 beds, ECONOMY_BED with 6 beds) - return ( -
- {rooms.map((room: any) => { - const isVipBed = - room.category === "VIP_BED" || room.totalBeds === 4; - const isEconomyBed = - room.category === "ECONOMY_BED" || room.totalBeds === 6; - - // Sort beds by position and column - const sortedBeds = [...(room.beds || [])].sort((a, b) => { - const posOrder = { upper: 3, middle: 2, lower: 1 }; - const posA = - posOrder[a.bedPosition as keyof typeof posOrder] || 0; - const posB = - posOrder[b.bedPosition as keyof typeof posOrder] || 0; - if (posA !== posB) return posA - posB; - return (a.col || "").localeCompare(b.col || ""); - }); - - return ( -
- {/* Room Header */} -
-
-

- Room {room.roomNumber} -

-

- {room.category === "VIP_BED" - ? "VIP BED" - : room.category === "ECONOMY_BED" - ? "ECONOMY BED" - : room.category} -

-
-
- {room.totalBeds} beds -
-
- - {/* Legend */} -
-
-
- - Available - -
-
-
- - Booked - -
-
- - {/* VIP BED Layout — 2-tier compartment (Lower/Upper), left + right of the aisle */} - {isVipBed && (() => { - const lowerBeds = sortedBeds.filter((b: any) => b.bedPosition === "lower"); - const upperBeds = sortedBeds.filter((b: any) => b.bedPosition === "upper"); - const isLeft = (bed: any, idx: number) => - bed.position === "LEFT" ? true : bed.position === "RIGHT" ? false : idx % 2 === 0; - - const leftBay = [lowerBeds, upperBeds] - .map((arr) => arr.find((b: any, i: number) => isLeft(b, i))) - .filter(Boolean); - const rightBay = [lowerBeds, upperBeds] - .map((arr) => arr.find((b: any, i: number) => !isLeft(b, i))) - .filter(Boolean); - - return renderCompartment(leftBay, rightBay, `${room.room_id}-vip`); - })()} - - {/* ECONOMY BED Layout — 3-tier compartment (Lower/Middle/Upper), left + right of the aisle */} - {isEconomyBed && (() => { - const leftColByPosition: Record = { lower: "A", middle: "B", upper: "C" }; - const leftBay = BERTH_ORDER - .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && bedSideIsLeft(b, leftColByPosition))) - .filter(Boolean); - const rightBay = BERTH_ORDER - .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && !bedSideIsLeft(b, leftColByPosition))) - .filter(Boolean); - - return renderCompartment(leftBay, rightBay, `${room.room_id}-eco`); - })()} -
- ); - })} -
- ); - } - - // Fallback: beds without room data — group into numbered bays (Lower/Middle/Upper), - // then pair adjacent bays into two-side compartments, same as the room-based layouts. - const seatGroups = new Map(); - - for (const seat of validSeats) { - const baseNumber = seat.seatNumber || seat.number || seat.label || ""; - if (!seatGroups.has(baseNumber)) { - seatGroups.set(baseNumber, []); - } - seatGroups.get(baseNumber)!.push(seat); - } - - const sortedGroups = Array.from(seatGroups.entries()).sort(([a], [b]) => { - const numA = parseInt(a) || 0; - const numB = parseInt(b) || 0; - return numA - numB; - }); - - const bays = sortedGroups - .map(([, beds]) => - BERTH_ORDER.map((pos) => beds.find((seat: any) => seat.bedPosition === pos)).filter(Boolean), - ) - .filter((bay) => bay.length > 0); - - return ( -
- {Array.from({ length: Math.ceil(bays.length / 2) }, (_, i) => { - const leftBay = bays[i * 2] || []; - const rightBay = bays[i * 2 + 1] || []; - return renderCompartment(leftBay, rightBay, `bay-compartment-${i}`); - })} -
- ); - } - - // Regular seats with row/column arrangement - const rowMap = new Map(); - for (const seat of validSeats) { - if (!rowMap.has(seat.row)) { - rowMap.set(seat.row, []); - } - rowMap.get(seat.row)!.push(seat); - } - - const rows = Array.from(rowMap.entries()) - .sort(([a], [b]) => a - b) - .map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col))); - - return ( -
- {rows.map((rowSeats: any[], rowIdx: number) => { - const groups: any[][] = []; - - // Split seats into groups based on arrangement - if (arrangement.length === 1) { - // Single group (all seats together) - groups.push(rowSeats); - } else { - // Multiple groups with aisle separation - arrangement.forEach((_groupSize, groupIdx) => { - const startIdx = arrangement - .slice(0, groupIdx) - .reduce((sum, size) => sum + size, 0); - const endIdx = arrangement - .slice(0, groupIdx + 1) - .reduce((sum, size) => sum + size, 0); - const currentGroup = rowSeats.slice(startIdx, endIdx); - if (currentGroup.length > 0) groups.push(currentGroup); - }); - } - - const rowNumber = rowSeats[0]?.row || 1; - const shouldFlipArmchair = rowNumber % 2 === 0; - const showSpacing = rowIdx % 2 === 1; - - return ( -
- {shouldFlipArmchair && ( -
- {groups.map((group, gIdx) => ( -
- {group.map((seat: any) => { - const seatLabel = - seat.label || seat.number || seat.seatNumber || ""; - return ( -
- {seatLabel} -
- ); - })} -
- ))} -
- )} -
- {groups.map((group, gIdx) => ( -
- {group.map((seat: any) => ( - - ))} -
- ))} -
- - {!shouldFlipArmchair && ( -
- {groups.map((group, gIdx) => ( -
- {group.map((seat: any) => { - const seatLabel = - seat.label || seat.number || seat.seatNumber || ""; - return ( -
- {seatLabel} -
- ); - })} -
- ))} -
- )} - - {showSpacing && ( -
- )} -
- ); - })} -
- ); - }; - if ( isRoundTrip ? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length @@ -2414,7 +1865,14 @@ export default function SeatsPage() { No seats in this coach

) : ( - renderCoachSeats(selectedCoachData, isBedCoach) + )}
diff --git a/apps/edr-passenger-web/portal/src/components/SeatMap.tsx b/apps/edr-passenger-web/portal/src/components/SeatMap.tsx new file mode 100644 index 000000000..94fbe7c98 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/components/SeatMap.tsx @@ -0,0 +1,706 @@ +"use client"; + +import { memo } from "react"; +import Image from "next/image"; +import { Armchair, Bed } from "lucide-react"; + +/** + * The seat map shared by the booking flow (`/booking/seats`) and the reschedule flow + * (`/booking/reschedule`). Everything here is presentational: it takes a coach from + * `GET /seats/seatmap/:scheduleId` and three callbacks, and knows nothing about bookings, + * passengers, holds or fares. Both pages must render seats identically, so this is the one + * copy — extend it rather than forking a second layout. + */ + +export const BED_POSITION_SUFFIX: Record = { + lower: "L", + middle: "M", + upper: "U", +}; + +export const buildSeatLabel = (seat: any): string => { + const base: string = seat.number || seat.label || seat.seatNumber || ""; + if (!base) return ""; + const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? "") : ""; + return suffix ? `${base}${suffix}` : base; +}; + +/** "Economy Bed - Upper" → "upper". Null when the class names no berth level. */ +export const getBedPosition = (selectedClass: string): string | null => { + const lowerClass = selectedClass.toLowerCase(); + if (lowerClass.includes("upper")) return "upper"; + if (lowerClass.includes("middle")) return "middle"; + if (lowerClass.includes("lower")) return "lower"; + return null; +}; + +export const isBedCoachData = (coachData: any): boolean => + coachData?.isBedCoach === true || + coachData?.rooms?.length > 0 || + (coachData?.seats || []).some((s: any) => s.bedPosition) || + coachData?.seatClass?.toLowerCase().includes("bed") || + coachData?.mode?.toLowerCase().includes("bed"); + +/** + * Flattens a coach into the seats that are actually selectable: beds out of `rooms` when the + * coach has them, otherwise `seats`. Placeholder rows (labels starting "-") are dropped, and + * on a bed coach a berth-specific fare class narrows the list to that level. + */ +export const getValidSeatsForCoach = ( + coachData: any, + selectedSeatClass?: string | null, +): any[] => { + if (!coachData) return []; + + if (coachData.rooms?.length > 0) { + const allBeds: any[] = []; + coachData.rooms.forEach((room: any) => { + if (room.beds) allBeds.push(...room.beds); + }); + + let beds = allBeds.filter((s: any) => { + const seatLabel = s.label || s.number || s.seatNumber || ""; + return seatLabel && !seatLabel.startsWith("-"); + }); + + if (isBedCoachData(coachData) && selectedSeatClass) { + const selectedBedPosition = getBedPosition(selectedSeatClass); + if (selectedBedPosition) { + beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition); + } + } + return beds; + } + + let seats = (coachData.seats || []).filter((s: any) => { + const seatLabel = s.label || s.number || s.seatNumber || ""; + return seatLabel && !seatLabel.startsWith("-"); + }); + + if (isBedCoachData(coachData) && selectedSeatClass) { + const selectedBedPosition = getBedPosition(selectedSeatClass); + if (selectedBedPosition) { + seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition); + } + } + return seats; +}; + +/** "3+2" → [3, 2] so the aisle gap lands between the groups. Bed coaches collapse to one column. */ +export const parseSeatArrangement = ( + arrangement: string | null, + seatClasses?: (string | undefined)[], +): number[] => { + if (!arrangement) return [2, 2]; + + const isBedCoach = seatClasses?.some((sc) => sc?.toLowerCase().includes("bed")); + + if (isBedCoach) { + // For bed coaches, arrangement like "3+0" means 3 beds stacked vertically — + // render them as a single column. + const parts = arrangement + .split("+") + .map((p) => parseInt(p.trim())) + .filter((n) => !isNaN(n) && n > 0); + return parts.length > 0 ? [Math.max(...parts)] : [3]; + } + + const parts = arrangement + .split("+") + .map((p) => parseInt(p.trim())) + .filter((n) => !isNaN(n) && n > 0); + return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2]; +}; + +export const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => { + const seatLabel = bed.label || bed.seatNumber || bed.number || "?"; + const bedPosition = bed.bedPosition || ""; + const bedType = + bedPosition === "upper" ? "Upper" : bedPosition === "middle" ? "Middle" : "Lower"; + const isDisabled = bed.status !== "AVAILABLE" || isAssignedToOther; + + return ( + + ); +}); + +BedCard.displayName = "BedCard"; + +// A real berth ladder is a single fixed rail mounted at the end of the bay that a +// passenger climbs to reach every level — not a separate rung floating between each +// pair of beds. So this renders once per bay, right after the last berth card, with +// solid rounded rails/rungs (like a real metal ladder) rather than thin decorative lines. +export const LadderConnector = memo(() => ( + +)); + +LadderConnector.displayName = "LadderConnector"; + +export const SeatButton = memo( + ({ seat, isSelected, isAssignedToOther, onToggle, isBedCoach, bedLabel, coachSeatClass }: any) => { + const seatLabel = seat.number || seat.label || seat.seatNumber || "?"; + const bedWidth = "w-24"; + const width = isBedCoach ? bedWidth : "w-10"; + const isDisabled = seat.status !== "AVAILABLE" || isAssignedToOther; + + return ( +
+ +
+ ); + }, +); + +SeatButton.displayName = "SeatButton"; + +/** Available / Selected / Booked swatches, shown above every expanded coach. */ +export function SeatLegend() { + return ( +
+ {[ + { color: "bg-green-50 border border-green-300", label: "Available" }, + { color: "bg-blue-50 border-2 border-blue-500", label: "Selected" }, + { color: "bg-red-50 border border-red-300", label: "Booked" }, + ].map(({ color, label }) => ( +
+
+ {label} +
+ ))} +
+ ); +} + +export interface CoachSeatLayoutProps { + coach: any; + isBedCoach: boolean; + /** Already filtered by `getValidSeatsForCoach` — the caller owns berth-class narrowing. */ + seats: any[]; + isSeatSelected: (seatId: string) => boolean; + isSeatAssignedToOther: (seatId: string) => boolean; + onSeatToggle: (seatId: string) => void; +} + +/** + * The seat grid for one coach. Four layouts, picked off the coach's own shape: + * room-based VIP (4 berths), room-based Economy (6 berths), room-less berth bays, and + * regular rows with the aisle gap from `seatArrangement`. + */ +export function CoachSeatLayout({ + coach, + isBedCoach, + seats: validSeats, + isSeatSelected, + isSeatAssignedToOther, + onSeatToggle, +}: CoachSeatLayoutProps) { + const arrangement = parseSeatArrangement( + coach?.seatArrangement, + coach?.seatClasses || [coach?.seatClass], + ); + + if (validSeats.length === 0) { + return
No seats
; + } + + const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); + const seatClassStr = + typeof coach?.seatClass === "string" ? coach.seatClass : coach?.seatClass?.name || ""; + + // Indian-sleeper-style berth bay: Lower / Middle / Upper laid out horizontally, with + // the single ladder that actually serves the whole bay shown once at the end. + const renderBerthBay = (beds: any[], keyPrefix: string) => ( +
+ {beds.map((bed: any) => ( + + ))} + {beds.length > 1 && } +
+ ); + + // Two-side compartment: the left bay and right bay each get their own row (berths + // still laid out horizontally within a row), stacked one above the other and split + // by a dashed aisle divider — instead of squeezing both sides into a single row. + const renderCompartment = (leftBay: any[], rightBay: any[], key: string) => ( +
+
+ {leftBay.length > 0 && ( +
{renderBerthBay(leftBay, `${key}-left`)}
+ )} + {leftBay.length > 0 && rightBay.length > 0 && ( +
+ )} + {rightBay.length > 0 && ( +
{renderBerthBay(rightBay, `${key}-right`)}
+ )} +
+
+ ); + + // Bay position ordering + left/right side detection shared by both bed layouts below. + const BERTH_ORDER = ["lower", "middle", "upper"]; + const bedSideIsLeft = (bed: any, leftColByPosition: Record) => { + if (bed.position === "LEFT") return true; + if (bed.position === "RIGHT") return false; + const leftCol = leftColByPosition[bed.bedPosition]; + return leftCol ? bed.col === leftCol : true; + }; + + if (isBedCoach && hasBedPositionData) { + const rooms = (coach as any)?.rooms || []; + + if (rooms.length > 0) { + // Room-based layout (VIP_BED with 4 beds, ECONOMY_BED with 6 beds) + return ( +
+ {rooms.map((room: any) => { + const isVipBed = room.category === "VIP_BED" || room.totalBeds === 4; + const isEconomyBed = room.category === "ECONOMY_BED" || room.totalBeds === 6; + + const sortedBeds = [...(room.beds || [])].sort((a, b) => { + const posOrder = { upper: 3, middle: 2, lower: 1 }; + const posA = posOrder[a.bedPosition as keyof typeof posOrder] || 0; + const posB = posOrder[b.bedPosition as keyof typeof posOrder] || 0; + if (posA !== posB) return posA - posB; + return (a.col || "").localeCompare(b.col || ""); + }); + + return ( +
+ {/* Room Header */} +
+
+

+ Room {room.roomNumber} +

+

+ {room.category === "VIP_BED" + ? "VIP BED" + : room.category === "ECONOMY_BED" + ? "ECONOMY BED" + : room.category} +

+
+
+ {room.totalBeds} beds +
+
+ + {/* Legend */} +
+
+
+ Available +
+
+
+ Booked +
+
+ + {/* VIP BED Layout — 2-tier compartment (Lower/Upper), left + right of the aisle */} + {isVipBed && (() => { + const lowerBeds = sortedBeds.filter((b: any) => b.bedPosition === "lower"); + const upperBeds = sortedBeds.filter((b: any) => b.bedPosition === "upper"); + const isLeft = (bed: any, idx: number) => + bed.position === "LEFT" ? true : bed.position === "RIGHT" ? false : idx % 2 === 0; + + const leftBay = [lowerBeds, upperBeds] + .map((arr) => arr.find((b: any, i: number) => isLeft(b, i))) + .filter(Boolean); + const rightBay = [lowerBeds, upperBeds] + .map((arr) => arr.find((b: any, i: number) => !isLeft(b, i))) + .filter(Boolean); + + return renderCompartment(leftBay, rightBay, `${room.room_id}-vip`); + })()} + + {/* ECONOMY BED Layout — 3-tier compartment (Lower/Middle/Upper), left + right of the aisle */} + {isEconomyBed && (() => { + const leftColByPosition: Record = { lower: "A", middle: "B", upper: "C" }; + const leftBay = BERTH_ORDER + .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && bedSideIsLeft(b, leftColByPosition))) + .filter(Boolean); + const rightBay = BERTH_ORDER + .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && !bedSideIsLeft(b, leftColByPosition))) + .filter(Boolean); + + return renderCompartment(leftBay, rightBay, `${room.room_id}-eco`); + })()} +
+ ); + })} +
+ ); + } + + // Fallback: beds without room data — group into numbered bays (Lower/Middle/Upper), + // then pair adjacent bays into two-side compartments, same as the room-based layouts. + const seatGroups = new Map(); + for (const seat of validSeats) { + const baseNumber = seat.seatNumber || seat.number || seat.label || ""; + if (!seatGroups.has(baseNumber)) seatGroups.set(baseNumber, []); + seatGroups.get(baseNumber)!.push(seat); + } + + const sortedGroups = Array.from(seatGroups.entries()).sort(([a], [b]) => { + const numA = parseInt(a) || 0; + const numB = parseInt(b) || 0; + return numA - numB; + }); + + const bays = sortedGroups + .map(([, beds]) => + BERTH_ORDER.map((pos) => beds.find((seat: any) => seat.bedPosition === pos)).filter(Boolean), + ) + .filter((bay) => bay.length > 0); + + return ( +
+ {Array.from({ length: Math.ceil(bays.length / 2) }, (_, i) => { + const leftBay = bays[i * 2] || []; + const rightBay = bays[i * 2 + 1] || []; + return renderCompartment(leftBay, rightBay, `bay-compartment-${i}`); + })} +
+ ); + } + + // Regular seats with row/column arrangement + const rowMap = new Map(); + for (const seat of validSeats) { + if (!rowMap.has(seat.row)) rowMap.set(seat.row, []); + rowMap.get(seat.row)!.push(seat); + } + + const rows = Array.from(rowMap.entries()) + .sort(([a], [b]) => a - b) + .map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col))); + + const renderSeatNumberStrip = (groups: any[][], keyPrefix: string) => ( +
+ {groups.map((group, gIdx) => ( +
+ {group.map((seat: any) => ( +
+ {seat.label || seat.number || seat.seatNumber || ""} +
+ ))} +
+ ))} +
+ ); + + return ( +
+ {rows.map((rowSeats: any[], rowIdx: number) => { + const groups: any[][] = []; + + if (arrangement.length === 1) { + groups.push(rowSeats); + } else { + arrangement.forEach((_groupSize, groupIdx) => { + const startIdx = arrangement.slice(0, groupIdx).reduce((sum, size) => sum + size, 0); + const endIdx = arrangement.slice(0, groupIdx + 1).reduce((sum, size) => sum + size, 0); + const currentGroup = rowSeats.slice(startIdx, endIdx); + if (currentGroup.length > 0) groups.push(currentGroup); + }); + } + + const rowNumber = rowSeats[0]?.row || 1; + const shouldFlipArmchair = rowNumber % 2 === 0; + const showSpacing = rowIdx % 2 === 1; + + return ( +
+ {shouldFlipArmchair && renderSeatNumberStrip(groups, `before-${rowNumber}`)} + +
+ {groups.map((group, gIdx) => ( +
+ {group.map((seat: any) => ( + + ))} +
+ ))} +
+ + {!shouldFlipArmchair && renderSeatNumberStrip(groups, `after-${rowNumber}`)} + + {showSpacing &&
} +
+ ); + })} +
+ ); +} + +export interface SeatMapProps { + /** Coaches straight off `GET /seats/seatmap/:scheduleId`. */ + coaches: any[]; + selectedCoachId: string | null; + onSelectCoach: (coachId: string | null) => void; + /** Berth-level fare class, e.g. "Economy Bed - Upper". Narrows a bed coach to one level. */ + selectedSeatClass?: string | null; + isSeatSelected: (seatId: string) => boolean; + isSeatAssignedToOther: (seatId: string) => boolean; + onSeatToggle: (seatId: string) => void; + emptyLabel?: string; +} + +/** + * Coach accordion + legend + seat grid, styled as the train itself: coupling joints between + * cars, a brand stripe top and bottom, and per-coach availability bars. + */ +export default function SeatMap({ + coaches, + selectedCoachId, + onSelectCoach, + selectedSeatClass, + isSeatSelected, + isSeatAssignedToOther, + onSeatToggle, + emptyLabel = "No coach of your class on this train.", +}: SeatMapProps) { + if (!coaches || coaches.length === 0) { + return

{emptyLabel}

; + } + + return ( +
+ {coaches.map((coach: any, index: number) => { + const coachSeats = getValidSeatsForCoach(coach, selectedSeatClass); + const available = coachSeats.filter((s: any) => s.status === "AVAILABLE").length; + const total = coachSeats.length; + const isExpanded = selectedCoachId === coach.id; + const isBedCoach = isBedCoachData(coach); + const coachLabel = coach.label || coach.name || coach.coachNumber || `Coach ${index + 1}`; + + return ( +
+ {/* Coupling joint */} +
+
+
+
+
+
+
+ + {/* Coach car */} +
+ {/* Top colour stripe — brand rail */} +
+ + + + {/* Expanded seat map */} + {isExpanded && ( +
+ +
+
+ {coachSeats.length === 0 ? ( +

No seats in this coach

+ ) : ( + + )} +
+
+
+ )} + + {/* Bottom colour stripe */} +
+
+
+ ); + })} +
+ ); +} + +function ChevronDownIcon({ isExpanded }: { isExpanded: boolean }) { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/components/StationDropdown.tsx b/apps/edr-passenger-web/portal/src/components/StationDropdown.tsx new file mode 100644 index 000000000..8b0fb0073 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/components/StationDropdown.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Clock, MapPin, X } from "lucide-react"; +import { Station } from "@/types"; + +// Shared by the search widget (home page) and the reschedule flow so both pick stations the +// same way. Recents live under one localStorage key, so a station picked on the home page is +// still offered as "Recent" when rescheduling. +export const RECENT_STATIONS_KEY = "edr_recent_stations"; +export const MAX_RECENT_STATIONS = 5; + +export function readRecentStationIds(): string[] { + try { + const raw = JSON.parse(localStorage.getItem(RECENT_STATIONS_KEY) || "[]"); + return Array.isArray(raw) ? raw : []; + } catch { + return []; + } +} + +/** Prepends `id`, de-duplicates, caps the list, persists it, and returns the new list. */ +export function pushRecentStation(id: string, prev: string[]): string[] { + const next = [id, ...prev.filter((x) => x !== id)].slice(0, MAX_RECENT_STATIONS); + try { + localStorage.setItem(RECENT_STATIONS_KEY, JSON.stringify(next)); + } catch { + /* private mode / storage disabled — recents are a convenience, never a requirement */ + } + return next; +} + +// ─── Station Autocomplete ───────────────────────────────────────────────────── +export default function StationDropdown({ + stations, + value, + excludeId, + placeholder, + onSelect, + error, + recentIds, + onOpen, + disabled = false, +}: { + stations: Station[]; + value: string; + excludeId?: string; + placeholder: string; + onSelect: (s: Station) => void; + error?: string; + recentIds: string[]; + onOpen?: () => void; + /** + * Read-only: shows the selection but refuses to open. Used where the route is fixed — + * a fare class whose policy sets `routeChangeAllowed: false` cannot change stations, and + * a dropdown that opens only to reject the pick is worse than one that plainly can't. + */ + disabled?: boolean; +}) { + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const inputRef = useRef(null); + const selectedStation = stations.find((s) => s.id === value); + + useEffect(() => { + if (selectedStation && !open) setQuery(""); + }, [selectedStation, open]); + + // Close if the control is disabled while open (e.g. switching to a route-locked leg). + useEffect(() => { + if (disabled) setOpen(false); + }, [disabled]); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) + setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const filtered = query.trim() + ? stations.filter( + (s) => + s.id !== excludeId && + (s.name.toLowerCase().includes(query.toLowerCase()) || + s.code?.toLowerCase().includes(query.toLowerCase())), + ) + : stations.filter((s) => s.id !== excludeId).slice(0, 20); + + const displayValue = open ? query : (selectedStation?.name ?? ""); + + return ( +
+
+ + { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => { + if (disabled) return; + setQuery(""); + setOpen(true); + onOpen?.(); + }} + placeholder={placeholder} + className={`w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm placeholder-gray-400 ${ + disabled + ? "text-gray-500 dark:text-gray-400 cursor-not-allowed" + : "text-gray-900 dark:text-white" + }`} + /> + {value && !disabled && ( + + )} +
+ + {open && !disabled && ( +
+ {!query && recentIds.length > 0 && ( +
+

+ Recent +

+ {recentIds + .map((id) => stations.find((s) => s.id === id)) + .filter(Boolean) + .map((s) => ( + + ))} +
+
+ )} + {filtered.length === 0 ? ( +

+ No stations found +

+ ) : ( + filtered.map((s) => ( + + )) + )} +
+ )} +
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index de306da8f..c0e1eacbe 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -40,6 +40,8 @@ interface PassengerVoucherData { // payment.amountMinor straight from the API) and must NOT be divided by 100 — as // opposed to the normal case where fareMinor is genuine minor units (cents). fareIsMajorUnits?: boolean; + /** Itemises the total when a booking was paid more than once (e.g. after a reschedule). */ + paymentLines?: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>; } // ─── palette ─────────────────────────────────────────────────────────────── @@ -365,6 +367,43 @@ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: num return y + cardH + 6; } +function drawPaymentBreakdown( + doc: jsPDF, + lines: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>, + y: number, + margin: number, + pageWidth: number, +): number { + if (lines.length < 2) return y; + + const rowH = 6; + const padX = 7; + const headerH = 7; + const cardH = headerH + lines.length * rowH + 3; + + doc.setDrawColor(...HAIRLINE); + doc.setLineWidth(0.2); + doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'S'); + + doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...MUTED); + doc.text('PAYMENT BREAKDOWN', margin + padX, y + 5, { charSpace: 0.2 }); + + lines.forEach((line, i) => { + const rowY = y + headerH + i * rowH; + doc.setFontSize(8.5); doc.setFont('helvetica', 'normal'); doc.setTextColor(...BODY); + // No status suffix: the caller passes settled lines only, so every row here is paid. + doc.text(line.label, margin + padX, rowY + 4); + doc.setFont('helvetica', 'bold'); doc.setTextColor(...INK); + doc.text( + `${line.currency} ${(line.amountMinor / 100).toFixed(2)}`, + pageWidth - margin - padX, + rowY + 4, + { align: 'right' }, + ); + }); + + return y + cardH + 6; +} // ─── instructions ────────────────────────────────────────────────────────── @@ -425,6 +464,7 @@ async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): y = drawPassengerDetails(doc, data, y, margin, pageW); y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW, data.fareIsMajorUnits); + y = drawPaymentBreakdown(doc, data.paymentLines ?? [], y, margin, pageW); y = drawInstructions(doc, y, margin, pageW); drawFooter(doc, data.createdAt, y); } @@ -474,15 +514,32 @@ interface VoucherData { tickets?: Array<{ passengerName?: string; leg?: number; barcodePayload?: string }>; // The actual settled amount/currency for this booking's payment — preferred over the // ETB booking total once available, since it reflects what was really charged. - payment?: { amountMinor?: number; currency?: string }; + payment?: { + amountMinor?: number; + currency?: string; + /** Every line collected for this booking — see the API's payment-breakdown.util.ts. */ + breakdown?: { + lines: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>; + totalPaidMinor: number | null; + totalPaidCurrency: string | null; + }; + }; } export const generateVoucherPDF = async (booking: VoucherData): Promise => { // Currency always comes straight from the booking/payment data, never hardcoded — the // settled payment currency when a payment has settled, otherwise the booking's own // display currency (falling back to the internal ETB currency field). - const settledAmountMinor = booking.payment?.amountMinor; - const settledCurrency = booking.payment?.currency; + // `payment.amountMinor` is the ORIGINAL intent only — it never moves when a booking is + // rescheduled, so a voucher built from it reports the pre-reschedule amount forever (a 51.85 + // booking moved to a 1752.34 journey still printed 51.85). `payment.breakdown` counts every + // line collected, including the reschedule fee and fare difference. It arrives in true minor + // units, so it is divided down to the major-unit basis the rest of this function expects. + const breakdown = booking.payment?.breakdown; + const settledAmountMinor = + breakdown?.totalPaidMinor != null ? breakdown.totalPaidMinor / 100 : booking.payment?.amountMinor; + const settledCurrency = + (breakdown?.totalPaidMinor != null ? breakdown.totalPaidCurrency : booking.payment?.currency) ?? undefined; const useSettledAmount = settledAmountMinor != null && !!settledCurrency; // Prefer displayCurrency (passenger's home currency) over the internal ETB currency field. const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB'); @@ -541,7 +598,10 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => // This passenger's own fare, scaled onto the same currency/amount basis as the rest of // the voucher — not the booking's overall total, and not an equal share of it. - const passengerFareMinor = Math.round(p.fareMinor * fareScaleFactor); + const scaledFare = p.fareMinor * fareScaleFactor; + const passengerFareMinor = useSettledAmount + ? Math.round(scaledFare * 100) / 100 + : Math.round(scaledFare); await generatePassengerVoucherPDF({ bookingRef: booking.bookingRef, @@ -560,6 +620,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => fareMinor: passengerFareMinor, currency: voucherCurrency, fareIsMajorUnits: useSettledAmount, + paymentLines: grouped.size === 1 ? breakdown?.lines.filter((l) => l.settled) : undefined, createdAt: booking.createdAt, }); } diff --git a/apps/edr-passenger-web/portal/src/utils/phone.ts b/apps/edr-passenger-web/portal/src/utils/phone.ts new file mode 100644 index 000000000..d30ff28ea --- /dev/null +++ b/apps/edr-passenger-web/portal/src/utils/phone.ts @@ -0,0 +1,16 @@ + +export function normalizePhone(phone?: string | null): string | null { + if (!phone) return null; + const digits = phone.replace(/\D/g, ''); + if (!digits) return null; + if (digits.startsWith('251')) return `+${digits}`; + if (digits.startsWith('0')) return `+251${digits.slice(1)}`; + if (digits.length === 9) return `+251${digits}`; + return `+${digits}`; +} + +export function samePhone(a?: string | null, b?: string | null): boolean { + const left = normalizePhone(a); + const right = normalizePhone(b); + return !!left && !!right && left === right; +}