diff --git a/apps/edr-passenger-api/prisma/migrations/20260901090000_add_fare_class_upgrade/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260901090000_add_fare_class_upgrade/migration.sql new file mode 100644 index 000000000..db3b9d832 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260901090000_add_fare_class_upgrade/migration.sql @@ -0,0 +1,65 @@ +-- Fare-class upgrade (policy US-17). One policy row per fare class — fare classes map 1:1 onto +-- coach types — plus a per-request table recording the frozen quote. +-- +-- Structure only, and additive/idempotent. Policy data lives in prisma/seed.ts. + +-- CreateTable +CREATE TABLE IF NOT EXISTS "passenger"."UpgradePolicy" ( + "id" TEXT NOT NULL, + "coachTypeId" TEXT NOT NULL, + "rank" INTEGER NOT NULL DEFAULT 0, + "feePercent" INTEGER NOT NULL DEFAULT 0, + "feeMinMinor" INTEGER NOT NULL DEFAULT 0, + "feeWaived" BOOLEAN NOT NULL DEFAULT false, + "isUpgradable" BOOLEAN NOT NULL DEFAULT true, + "isTargetable" BOOLEAN NOT NULL DEFAULT true, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UpgradePolicy_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "UpgradePolicy_coachTypeId_key" ON "passenger"."UpgradePolicy"("coachTypeId"); + +DO $$ BEGIN + ALTER TABLE "passenger"."UpgradePolicy" + ADD CONSTRAINT "UpgradePolicy_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") + REFERENCES "passenger"."CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "passenger"."BookingUpgrade" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "leg" INTEGER NOT NULL DEFAULT 1, + "status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT', + "requestedBy" TEXT NOT NULL, + "scheduleId" TEXT NOT NULL, + "items" JSONB NOT NULL, + "holdId" TEXT, + "oldFareMinor" INTEGER NOT NULL, + "newFareMinor" INTEGER NOT NULL, + "fareDifferenceMinor" INTEGER NOT NULL, + "feeMinor" INTEGER NOT NULL, + "amountDueMinor" INTEGER NOT NULL, + "supplementaryChargeId" TEXT, + "expiresAt" TIMESTAMP(3), + "appliedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BookingUpgrade_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "BookingUpgrade_supplementaryChargeId_key" ON "passenger"."BookingUpgrade"("supplementaryChargeId"); +CREATE INDEX IF NOT EXISTS "BookingUpgrade_bookingId_status_idx" ON "passenger"."BookingUpgrade"("bookingId", "status"); + +DO $$ BEGIN + ALTER TABLE "passenger"."BookingUpgrade" + ADD CONSTRAINT "BookingUpgrade_bookingId_fkey" FOREIGN KEY ("bookingId") + REFERENCES "passenger"."Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- No data seeding here on purpose. This migration creates structure only; the ladder itself is +-- business policy and is seeded separately by `seedUpgradePolicies` in prisma/seed.ts +-- (`pnpm prisma:seed`), so a production deploy never silently writes fare rules nobody approved. diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 693bf1fb2..d5d2b891a 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -81,6 +81,7 @@ model CoachType { coaches Coach[] seatClasses SeatClass[] reschedulePolicy ReschedulePolicy? + upgradePolicy UpgradePolicy? @@schema("passenger") } @@ -570,6 +571,7 @@ model Booking { agentBooking AgentBooking? modifications BookingModification[] reschedules BookingReschedule[] + upgrades BookingUpgrade[] cancellation BookingCancellation? baggage BaggageBooking[] excessBaggageCharges ExcessBaggageCharge[] @@ -1207,6 +1209,64 @@ model AgentCommission { @@schema("passenger") } +/// Fare-class upgrade rule, one row per coach type (policy US-17). A coach type with no row here +/// can be neither upgraded from nor to — the same "no policy = not allowed" semantics +/// ReschedulePolicy uses. Edited in backoffice Master Data → Upgrade Policies. +model UpgradePolicy { + id String @id @default(uuid()) + coachTypeId String @unique + /// Position on the ladder — an upgrade requires target.rank > source.rank. An explicit column + /// rather than a price comparison: SeatClass.baseFareMinor is a per-km tariff, while the fare + /// actually charged resolves through SegmentFareRule/FareRule first, so on some segments the + /// price order differs from the class order. Which class is "higher" is a business decision + /// and must not flip because someone edited a tariff. + rank Int @default(0) + feePercent Int @default(0) // % of the passenger's original fare + feeMinMinor Int @default(0) // fee floor, ETB minor units + feeWaived Boolean @default(false) + isUpgradable Boolean @default(true) // passengers may leave this class + isTargetable Boolean @default(true) // passengers may arrive in this class + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + coachType CoachType @relation(fields: [coachTypeId], references: [id]) + + @@schema("passenger") +} + +/// One fare-class upgrade request for one leg. Same lifecycle as BookingReschedule +/// (PENDING_PAYMENT → APPLIED | EXPIRED) but the schedule never changes — only the seats, and +/// only for the passengers named in `items`. +model BookingUpgrade { + id String @id @default(uuid()) + bookingId String + leg Int @default(1) + status String @default("PENDING_PAYMENT") // PENDING_PAYMENT | APPLIED | EXPIRED + requestedBy String + scheduleId String // unchanged by the upgrade; recorded so the audit row reads standalone + /// Frozen per-passenger quote, keyed on bookingSeatId — NOT array position. Only some + /// passengers move, so a positional pairing (as BookingReschedule uses) would be fragile. + /// Each element: { bookingSeatId, passengerName, passengerCategory, + /// oldSeatId, oldSeatLabel, oldCoachTypeId, oldSeatClassId, oldFareMinor, + /// newSeatId, newSeatLabel, newCoachTypeId, newSeatClassId, newFareMinor, + /// feeMinor, fareDifferenceMinor } + items Json + holdId String? + oldFareMinor Int + newFareMinor Int + fareDifferenceMinor Int + feeMinor Int + amountDueMinor Int + supplementaryChargeId String? @unique + expiresAt DateTime? + appliedAt DateTime? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + + @@index([bookingId, status]) + @@schema("passenger") +} + /// Rescheduling rule per fare class. Fare families from the passenger policy map 1:1 onto /// coach types (HSC = Standard, HBC = Flex, SBC = Premium). Seeded by migration from the policy /// doc; edited in backoffice Settings → Reschedule Policy. diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 8768ba899..fb1261daf 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -658,6 +658,7 @@ async function seedNotificationTemplates() { { 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}}.' }, + { id: uuidv4(), code: 'booking.upgraded', channel: 'EMAIL', subject: 'Fare Class Upgraded', bodyTemplate: 'Booking {{bookingRef}}: {{passengerSummary}} upgraded on your {{leg}} journey. New tickets have been issued. Paid: {{amountPaid}} {{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 ff77b8b07..4cd1c767a 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -65,6 +65,7 @@ import { SegmentFareSeeder } from "./seed/segment-fare.seeder"; import { EOtpType } from "@tria-plc/iamapi-common"; import { RescheduleModule } from './modules/reschedule/reschedule.module'; +import { UpgradeModule } from './modules/upgrade/upgrade.module'; @Module({ imports: [ @@ -166,6 +167,7 @@ import { RescheduleModule } from './modules/reschedule/reschedule.module'; AppReleasesModule, ConfigurableFareModule, RescheduleModule, + UpgradeModule, ], providers: [ { provide: APP_FILTER, useClass: DeleteExceptionFilter }, diff --git a/apps/edr-passenger-api/src/common/audit.actions.ts b/apps/edr-passenger-api/src/common/audit.actions.ts index 8ac818e48..310191704 100644 --- a/apps/edr-passenger-api/src/common/audit.actions.ts +++ b/apps/edr-passenger-api/src/common/audit.actions.ts @@ -88,6 +88,8 @@ export const AUDIT_ENTITIES = { Booking: 'Booking', BookingReschedule: 'BookingReschedule', ReschedulePolicy: 'ReschedulePolicy', + BookingUpgrade: 'BookingUpgrade', + UpgradePolicy: 'UpgradePolicy', } as const; export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES]; diff --git a/apps/edr-passenger-api/src/common/utils/booking-change.utils.ts b/apps/edr-passenger-api/src/common/utils/booking-change.utils.ts new file mode 100644 index 000000000..bba635311 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/booking-change.utils.ts @@ -0,0 +1,144 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma.service'; +import { MeLikeUser } from '../passenger-permission.util'; +import { normalizePhone, samePhone } from './phone.utils'; + +/** + * Shared by every flow that lets a passenger change a confirmed booking — reschedule today, + * fare-class upgrade next. These were private to RescheduleService; they live here so the two + * features cannot drift apart on who is allowed to act or how a seat is priced. + * + * Plain functions rather than a provider on purpose: AuditService injects REQUEST, so anything + * made injectable here would drag request scope into whatever consumes it. + */ + +export type ActingUser = MeLikeUser & { id?: string; sub?: string; phoneNumber?: string }; + +/** + * The signed-in user's phone. The session snapshot (`userInfo.phoneNumber`) is frequently an + * empty string, so `iam.users` is the source of truth — and reading it live also means a user + * who changed their number does not have to sign out before the new one counts. + */ +export async function resolveUserPhone( + prisma: PrismaService, + iamUserId: string, + user: ActingUser, +): Promise { + const fromSession = normalizePhone(user.phoneNumber); + if (fromSession) return fromSession; + const rows = await prisma.$queryRaw<{ phone_number: string | null }[]>` + SELECT phone_number FROM iam.users WHERE id = ${iamUserId}::uuid LIMIT 1 + `; + return normalizePhone(rows[0]?.phone_number); +} + +/** + * Loads a booking only for the person who made it, proven by their account's phone number + * matching the booking's `contactPhone`. Being merely *named* on the booking is not enough — a + * passenger travelling on someone else's booking cannot change it. + * + * There is deliberately no staff override. `bookings:reschedule` exists in the registry (and on + * the stationMaster preset) but is not honoured, so a station master cannot act on a customer's + * behalf yet. + * + * `action` only shapes the error message ("reschedule it" / "upgrade it"). + */ +export async function loadOwnedBooking( + prisma: PrismaService, + bookingRef: string, + user: ActingUser, + include: T, + action = 'change it', +) { + const booking = await prisma.booking.findUnique({ where: { bookingRef }, include }); + if (!booking) throw new NotFoundException('Booking not found'); + const iamUserId = user.id ?? user.sub; + if (!iamUserId) throw new ForbiddenException(); + + const b = booking as any; + if (b.contactPhone) { + const callerPhone = await resolveUserPhone(prisma, iamUserId, user); + if (samePhone(callerPhone, b.contactPhone)) return booking; + throw new ForbiddenException( + `Only the person who made this booking can ${action}. Sign in with the phone number used to book.`, + ); + } + + // A small tail of bookings carry no contactPhone at all, so there is nothing to match against. + // Fall back to the account link rather than locking their owner out entirely. + const passenger = await prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } }); + if (!passenger || passenger.id !== b.passengerId) throw new ForbiddenException('Not your booking'); + return booking; +} + +/** + * Coaches nobody buys a seat in, so they can never carry a fare-class policy. + * + * Matched loosely on purpose: `CoachType.type` is documented as 'passenger' | 'sleeper' | + * 'dining' | 'baggage', but the live data holds display labels ('Dining Coach ', trailing space + * included). A `notIn: ['dining','baggage']` filter therefore matches nothing and offers the + * dining coach as a fare class. Mirrors the portal's own test (`/dining|dpc/i`). + */ +export const NON_FARE_COACH_TERMS = ['dining', 'dpc', 'baggage']; + +export const NOT_A_FARE_CLASS = { + NOT: NON_FARE_COACH_TERMS.flatMap((term) => [ + { type: { contains: term, mode: 'insensitive' as const } }, + { code: { contains: term, mode: 'insensitive' as const } }, + ]), +}; + +/** True when this coach type is a dining/baggage coach rather than a sellable fare class. */ +export function isNonFareCoachType(coachType: { type?: string | null; code?: string | null }): boolean { + const haystack = `${coachType.type ?? ''} ${coachType.code ?? ''}`.toLowerCase(); + return NON_FARE_COACH_TERMS.some((t) => haystack.includes(t)); +} + +/** + * Nationality is not stored on the booking, so the display currency is the proxy the search and + * fare code already use: ETB/DJF are local tariffs, USD is the international one. Both flows must + * use the same proxy or an upgrade would be priced on a different tariff than the original sale. + */ +export function resolveNationalityProxy(displayCurrency?: string | null): { + nationalityType: 'LOCAL' | 'INTERNATIONAL'; + nationality: string | undefined; +} { + return { + nationalityType: displayCurrency === 'USD' ? 'INTERNATIONAL' : 'LOCAL', + nationality: + displayCurrency === 'DJF' ? 'Djiboutian' : displayCurrency === 'ETB' ? 'Ethiopian' : undefined, + }; +} + +/** + * Mirrors SearchService's class matching: nationality filter, then bed position. + * `Seat.bedPosition` is lowercase and `SeatClass.bedPosition` uppercase, hence the folding. + */ +export function pickSeatClass(classes: any[], bedPosition: string | null, nationalityType: string) { + const byNat = classes.filter((c) => !c.nationalityType || c.nationalityType === nationalityType); + const pool = byNat.length ? byNat : classes; + const bed = bedPosition?.toLowerCase() ?? null; + const exact = pool.find((c) => (c.bedPosition?.toLowerCase() ?? null) === bed); + return exact ?? pool.find((c) => !c.bedPosition) ?? pool[0] ?? null; +} + +/** + * Distributes a leg fare over seats; free children (fare 0) stay 0 and rounding lands on the last + * paid seat. + */ +export function splitFare(total: number, seats: Array<{ fareMinor: number | null }>): number[] { + const paid = seats.map((s) => s.fareMinor !== 0); + const n = paid.filter(Boolean).length || 1; + const each = Math.floor(total / n); + let remaining = total; + let lastPaid = -1; + const out = seats.map((_, i) => { + if (!paid[i]) return 0; + lastPaid = i; + remaining -= each; + return each; + }); + if (lastPaid >= 0) out[lastPaid] += remaining; + return out; +} diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts index 8c391aef1..43a2c65e8 100644 --- a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts @@ -24,12 +24,25 @@ export const MIN_PAYMENT_WINDOW_MINUTES = 7; export const PAYMENT_SETTLE_MARGIN_SECONDS = 60; +/** + * `windowMinutes` is how long the payer is given, and is configurable per flow + * (`booking_payment_window_minutes`, `reschedule_…`, `upgrade_…` in SystemConfig). It defaults to + * MAX_PAYMENT_HOURS so any caller that does not pass it behaves exactly as before. + * + * The check-in cutoff is still the hard ceiling: a longer window can never let someone pay after + * boarding has closed on their train. + * + * EVERY site that decides whether a booking is still payable — the payment link, the seat hold, + * and the crons that auto-cancel unpaid bookings — must pass the SAME window for a given booking, + * or a cron will cancel a booking whose link still says it is valid. + */ export function computePaymentDeadline( createdAt: Date, departureAt: Date, checkinMinutes: number = CUTOFF_MINUTES, + windowMinutes: number = MAX_PAYMENT_HOURS * 60, ): Date { - const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const maxDeadline = new Date(createdAt.getTime() + windowMinutes * 60 * 1000); const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000); return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; } diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index 78b7d4bc7..048be2b82 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -1,3 +1,4 @@ +import { SystemConfigModule } from '../system-config/system-config.module'; import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { AuditModule } from '../../common/audit.module'; @@ -14,7 +15,7 @@ import { PaymentsModule } from '../payments/payments.module'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule], + imports: [SystemConfigModule, AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule], controllers: [BookingsController], providers: [BookingsService, GuestBookingService], exports: [BookingsService, GuestBookingService] diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 133d289e3..cf5d92b82 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -16,6 +16,7 @@ import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking- import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client'; import { JourneyDirection } from '../seats/seats.dto'; import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils'; +import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; import { randomUUID } from 'crypto'; @@ -90,6 +91,7 @@ export class GuestBookingService { private readonly logger = new Logger(GuestBookingService.name); constructor( + private systemConfig: SystemConfigService, private prisma: PrismaService, @InjectDataSource() private readonly dataSource: DataSource, private seatsService: SeatsService, @@ -582,7 +584,10 @@ export class GuestBookingService { const { guestPassengerId } = await this.resolveGuestPassenger({}, passengerData); const payToken = isStaff ? undefined : randomUUID(); - const payTokenExpiresAt = isStaff ? undefined : computePaymentDeadline(new Date(), schedule.departureAt); + const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES); + const payTokenExpiresAt = isStaff + ? undefined + : computePaymentDeadline(new Date(), schedule.departureAt, undefined, bookingWindowMinutes); const booking = await this.prisma.booking.create({ data: { 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 44ba03dea..4dc168d00 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -749,6 +749,26 @@ export class NotificationsService { ); } + @OnEvent('booking.upgraded') + async onBookingUpgraded(payload: any) { + const { booking, upgrade } = payload; + const items = Array.isArray(upgrade?.items) ? upgrade.items : []; + await this.send( + 'booking.upgraded', + booking.passengerId, + { + bookingRef: booking.bookingRef, + leg: upgrade?.leg === 2 ? 'return' : 'outbound', + passengerSummary: items.map((i: any) => i.passengerName).join(', '), + amountPaid: (((upgrade?.feeMinor ?? 0) + Math.max(0, upgrade?.fareDifferenceMinor ?? 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 970983cb4..ec492505d 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -1,3 +1,4 @@ +import { SystemConfigModule } from '../system-config/system-config.module'; import { Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; import { ConfigService } from "@nestjs/config"; @@ -55,6 +56,7 @@ function rabbitMQImport(): DynamicModule[] { @Module({ imports: [ + SystemConfigModule, SeatsModule, TicketsModule, CurrencyModule, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 6c8f4b58d..2ee97aa11 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from "@nestjs/testing"; import { PaymentsService } from "./payments.service"; +import { SystemConfigService } from "../system-config/system-config.service"; import { PaymentClientService } from "./payment-client.service"; import { CurrencyService } from "../currency/currency.service"; import { PrismaService } from "../../common/prisma.service"; @@ -133,6 +134,8 @@ describe("PaymentsService", () => { { provide: PaymentClientService, useValue: mockPaymentClient }, { provide: CurrencyService, useValue: mockCurrencyService }, { provide: AuditService, useValue: { log: jest.fn() } }, + // 120 = the default booking payment window; the deadline maths under test is unchanged by it. + { provide: SystemConfigService, useValue: { getNumber: jest.fn().mockResolvedValue(120) } }, ], }).compile(); 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 ced758d98..e8e17b608 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -36,6 +36,7 @@ import { MIN_PAYMENT_WINDOW_MINUTES, PAYMENT_SETTLE_MARGIN_SECONDS, } from "../../common/utils/payment-deadline.utils"; +import { CONFIG_KEYS, SystemConfigService } from "../system-config/system-config.service"; import { PaymentClientService, PaymentDiagnostic, @@ -88,6 +89,7 @@ export class PaymentsService { private readonly waafiDemoTrustReturn = true; constructor( + private systemConfig: SystemConfigService, private prisma: PrismaService, private seatsService: SeatsService, private ticketsService: TicketsService, @@ -748,7 +750,10 @@ export class PaymentsService { originRouteStop?.checkinMinutesBefore ?? booking.schedule.route?.checkinMinutesBefore ?? undefined; - return computePaymentDeadline(booking.createdAt, dep, checkinMinutes); + // Same window the auto-cancel cron uses, or the payer would be shown a deadline the cron + // does not honour. + const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES); + return computePaymentDeadline(booking.createdAt, dep, checkinMinutes, windowMinutes); } private resolveReturnUrls( diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts index ffb2c23bc..03c277647 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts @@ -7,6 +7,7 @@ import { SeatsModule } from '../seats/seats.module'; import { TicketsModule } from '../tickets/tickets.module'; import { PaymentsModule } from '../payments/payments.module'; import { CurrencyModule } from '../currency/currency.module'; +import { SystemConfigModule } from '../system-config/system-config.module'; import { RescheduleController } from './reschedule.controller'; import { RescheduleService, SUPPLEMENTARY_CHARGE_PAID_EVENT } from './reschedule.service'; @@ -32,7 +33,7 @@ export class RescheduleEventsListener { } @Module({ - imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule], + imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule, SystemConfigModule], controllers: [RescheduleController], providers: [RescheduleService, RescheduleEventsListener], exports: [RescheduleService], diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts index fdf10ca0a..f296dac74 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -13,6 +13,7 @@ import { AuditService } from '../../common/audit.service'; import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; import { MeLikeUser } from '../../common/passenger-permission.util'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; +import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service'; import { normalizePhone, samePhone } from '../../common/utils/phone.utils'; import { BookingsService } from '../bookings/bookings.service'; import { SeatsService } from '../seats/seats.service'; @@ -85,6 +86,8 @@ type LegView = { departureAt: Date; seats: Array<{ id: string; seatId: string; passengerName: string; fareMinor: number | null; passengerCategory: string }>; coachTypeId: string; + /** Every distinct coach type on the leg. More than one means a partial upgrade happened. */ + coachTypeIds: string[]; }; // Seats are ordered by passenger name so getOptions(), quote() and create() all see the same @@ -109,6 +112,7 @@ export class RescheduleService { private currencyService: CurrencyService, private auditService: AuditService, private eventEmitter: EventEmitter2, + private systemConfig: SystemConfigService, ) {} // ── Policy admin ───────────────────────────────────────────────────────── @@ -267,7 +271,8 @@ export class RescheduleService { const requestedBy = user.id ?? user.sub ?? booking.passengerId; const newDeparture = q.newDepartureAt; - const expiresAt = computePaymentDeadline(new Date(), newDeparture); + const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.RESCHEDULE_PAYMENT_WINDOW_MINUTES); + const expiresAt = computePaymentDeadline(new Date(), newDeparture, undefined, windowMinutes); const reschedule = await this.prisma.bookingReschedule.create({ data: { @@ -314,7 +319,8 @@ export class RescheduleService { where: { id: reschedule.id }, data: { supplementaryChargeId: charge.id }, }); - await this.seatsService.confirmSeats(dto.newSeatIds); + // Same instant the charge carries, so the hold and the payment link die together. + await this.seatsService.confirmSeats(dto.newSeatIds, new Date(), expiresAt); await this.auditService.log({ userId: requestedBy, @@ -497,11 +503,11 @@ export class RescheduleService { .map((s) => ({ id: s.id, seatId: s.seatId, passengerName: s.passengerName, fareMinor: s.fareMinor, passengerCategory: s.passengerCategory, coachTypeId: s.seat?.coach?.coachTypeId })); const l1 = seatsOf(1); if (l1.length && booking.schedule) { - legs.push({ leg: 1, scheduleId: booking.scheduleId, originStationId: booking.originStationId, destinationStationId: booking.destinationStationId, departureAt: booking.schedule.departureAt, seats: l1, coachTypeId: l1[0].coachTypeId }); + legs.push({ leg: 1, scheduleId: booking.scheduleId, originStationId: booking.originStationId, destinationStationId: booking.destinationStationId, departureAt: booking.schedule.departureAt, seats: l1, coachTypeId: l1[0].coachTypeId, coachTypeIds: [...new Set(l1.map((s) => s.coachTypeId))] }); } const l2 = seatsOf(2); if (booking.bookingType === 'ROUND_TRIP' && l2.length && booking.returnSchedule) { - legs.push({ leg: 2, scheduleId: booking.returnScheduleId, originStationId: booking.returnOriginStationId, destinationStationId: booking.returnDestinationStationId, departureAt: booking.returnSchedule.departureAt, seats: l2, coachTypeId: l2[0].coachTypeId }); + legs.push({ leg: 2, scheduleId: booking.returnScheduleId, originStationId: booking.returnOriginStationId, destinationStationId: booking.returnDestinationStationId, departureAt: booking.returnSchedule.departureAt, seats: l2, coachTypeId: l2[0].coachTypeId, coachTypeIds: [...new Set(l2.map((s) => s.coachTypeId))] }); } return legs; } @@ -521,6 +527,13 @@ export class RescheduleService { // round trip whose outbound was already used can't change its return yet — needs leg-scoped // ticket regeneration. if (booking.outboundBoardedAt || booking.returnBoardedAt) blockers.push('This booking has already been used for travel.'); + // A partial fare-class upgrade can leave one leg spanning two coach types. Everything below + // — the policy lookup, the fee, the seat map — keys off a single leg-wide class taken from + // the first seat, so a mixed leg would silently reschedule at the wrong class and price. + // Refuse it outright until reschedule is made class-aware per passenger. + if (leg.coachTypeIds.length > 1) { + blockers.push('This booking has passengers in different fare classes. Please contact support to change it.'); + } if (!policy || !policy.isActive) blockers.push('Rescheduling is not available for this fare class.'); else if (leg.departureAt.getTime() - now.getTime() < policy.cutoffMinutes * 60_000) { blockers.push(`Changes must be made at least ${policy.cutoffMinutes} minutes before departure.`); @@ -537,6 +550,10 @@ export class RescheduleService { const pending = await this.prisma.bookingReschedule.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } }); if (pending) blockers.push('A reschedule is already awaiting payment for this booking.'); + // One change at a time. Two live supplementary charges would both drive ticket regeneration + // on this booking and interleave unpredictably once each is paid. + const pendingUpgrade = await this.prisma.bookingUpgrade.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } }); + if (pendingUpgrade) blockers.push('An upgrade is awaiting payment for this booking — finish or cancel it first.'); if (dto.newSeatIds.length !== leg.seats.length) blockers.push(`Select exactly ${leg.seats.length} seat(s).`); if (new Set(dto.newSeatIds).size !== dto.newSeatIds.length) blockers.push('Duplicate seats selected.'); 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 c678dcf44..5c3b74c53 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -701,7 +701,14 @@ export class SeatsService { // short seat-selection hold (5 min by default). Without this, the hold could expire // while the customer was still on the payment page, and a second customer could // hold/book the exact same seat out from under them. - async confirmSeats(seatIds: string[], now: Date = new Date()): Promise { + /** + * `deadlineOverride` pins the hold to a deadline the caller has already computed. The + * reschedule and upgrade flows pass the exact value their supplementary charge carries — if + * this recomputed it instead, a per-flow payment window would give the hold and the payment + * link different lifetimes and the seat could lapse while the link still worked. + * Without it, the booking payment window is used, as before. + */ + async confirmSeats(seatIds: string[], now: Date = new Date(), deadlineOverride?: Date): Promise { if (seatIds.length === 0) return; const holds = await this.prisma.seatHold.findMany({ @@ -717,12 +724,16 @@ export class SeatsService { }); const departureById = new Map(schedules.map(s => [s.id, s.departureAt])); + const windowMinutes = deadlineOverride + ? 0 // unused — the override wins below + : await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES); + let extended = 0; await Promise.all( holds.map(async (hold) => { const departureAt = departureById.get(hold.scheduleId); if (!departureAt) return; - const deadline = computePaymentDeadline(now, departureAt); + const deadline = deadlineOverride ?? computePaymentDeadline(now, departureAt, undefined, windowMinutes); // Only ever extend forward — never shorten a hold that's already valid longer // than the payment deadline would give it (e.g. a second confirmSeats call on // the same booking, or a hold that was already extended). diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts index 5c1cd9679..32d09f41d 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts @@ -1,6 +1,7 @@ import { IsInt, IsOptional, Min, Max } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiPropertyOptional } from '@nestjs/swagger'; +import { MIN_PAYMENT_WINDOW_MINUTES } from '../../common/utils/payment-deadline.utils'; /** * Whitelisted, typed body for `PATCH /config`. Config is persisted as string key/values, but every @@ -22,6 +23,23 @@ export class UpdateSystemConfigDto { @IsOptional() @Type(() => Number) @IsInt() @Min(0) boarding_window_hours_before_departure?: number; + // Payment windows, in minutes. Floored at MIN_PAYMENT_WINDOW_MINUTES (7) because canOpenPaymentSession + // refuses to open a provider session with less than that left — a window below it makes every + // card/HPP payment impossible to start. Capped at 1440 (24h) — the check-in cutoff already bounds the + // effective deadline, but a stray 100000 would make the auto-cancel pre-filter scan pointlessly + // far back. + @ApiPropertyOptional({ example: 120, description: 'Minutes a new booking has to be paid (7..1440)' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440) + booking_payment_window_minutes?: number; + + @ApiPropertyOptional({ example: 120, description: 'Minutes a reschedule charge has to be paid (7..1440)' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440) + reschedule_payment_window_minutes?: number; + + @ApiPropertyOptional({ example: 120, description: 'Minutes a fare upgrade has to be paid (7..1440)' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440) + upgrade_payment_window_minutes?: number; + @ApiPropertyOptional({ example: 5 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) throttle_auth_limit?: number; diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts index 3cfa1beb8..437fcd22d 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts @@ -5,6 +5,12 @@ export const CONFIG_KEYS = { SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes', HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure', BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE: 'boarding_window_hours_before_departure', + // How long a passenger has to pay, per flow. The effective deadline is always + // MIN(now + window, departure - check-in cutoff) — a longer window can never let someone pay + // after boarding closes. + BOOKING_PAYMENT_WINDOW_MINUTES: 'booking_payment_window_minutes', + RESCHEDULE_PAYMENT_WINDOW_MINUTES: 'reschedule_payment_window_minutes', + UPGRADE_PAYMENT_WINDOW_MINUTES: 'upgrade_payment_window_minutes', THROTTLE_AUTH_LIMIT: 'throttle_auth_limit', THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms', THROTTLE_STRICT_LIMIT: 'throttle_strict_limit', @@ -17,6 +23,10 @@ const DEFAULTS: Record = { [CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5', [CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2', [CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE]: '4', + // 120 = the 2 hours these flows used before the window became configurable. + [CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES]: '120', + [CONFIG_KEYS.RESCHEDULE_PAYMENT_WINDOW_MINUTES]: '120', + [CONFIG_KEYS.UPGRADE_PAYMENT_WINDOW_MINUTES]: '120', [CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: '5', [CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000', [CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20', diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts index 335d11f81..2cec27cfe 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts @@ -3,10 +3,11 @@ import { PrismaModule } from '../../common/prisma.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { CurrencyModule } from '../currency/currency.module'; import { PaymentsModule } from '../payments/payments.module'; +import { SystemConfigModule } from '../system-config/system-config.module'; import { TasksService } from './tasks.service'; @Module({ - imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule], + imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule, SystemConfigModule], providers: [TasksService], }) export class TasksModule {} 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 27a94f4e9..29dde3d53 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -6,6 +6,8 @@ import { SmsClientService } from '../notifications/sms-client.service'; import { CurrencyService } from '../currency/currency.service'; import { PaymentsService } from '../payments/payments.service'; import { RescheduleService } from '../reschedule/reschedule.service'; +import { UpgradeService } from '../upgrade/upgrade.service'; +import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service'; import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; // Retention windows @@ -44,6 +46,8 @@ export class TasksService { // REQUEST), and injecting a request-scoped provider here would make TasksService request-scoped // too — which silently stops all its @Cron methods from firing. Resolve it per-tick instead. private readonly moduleRef: ModuleRef, + // Singleton (only injects Prisma), so it does not drag request scope in and silence the crons. + private readonly systemConfig: SystemConfigService, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -177,6 +181,7 @@ export class TasksService { // ── Send reminder at the midpoint of each booking's payment window ──────── private async sendPaymentReminders(now: Date) { + const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES); // Only look at bookings created within the last 3 h with a future departure. const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000); @@ -216,7 +221,7 @@ export class TasksService { ); const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? 30; if (dep <= now) continue; // segment has already departed; cancel job handles clean-up - const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); + const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes, bookingWindowMinutes); const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime(); // Skip degenerate windows (< 2 min) — the cancel job will handle these immediately @@ -260,7 +265,10 @@ export class TasksService { // ── Cancel bookings whose payment deadline has passed ───────────────────── private async cancelExpiredPendingBookings(now: Date) { - const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); + // Must be the SAME window the payment link was issued with, or a shortened window would + // leave older bookings unselected by the pre-filter and never auto-cancelled. + const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES); + const windowAgo = new Date(now.getTime() - bookingWindowMinutes * 60 * 1000); // The departure pre-filter below is a query-scoping optimization only — the real // deadline check happens per-row further down. It must be widened to the largest @@ -286,7 +294,7 @@ export class TasksService { where: { status: 'PENDING_PAYMENT', OR: [ - { createdAt: { lte: twoHoursAgo } }, + { createdAt: { lte: windowAgo } }, { schedule: { departureAt: { lte: departureCutoff } } }, ], }, @@ -329,7 +337,7 @@ export class TasksService { (s: any) => s.stationId === (booking as any).originStationId, ); const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES; - const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); + const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes, bookingWindowMinutes); if (now < paymentDeadline) continue; // Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded @@ -526,6 +534,22 @@ export class TasksService { } } + // ───────────────────────────────────────────────────────────────────────── + // Every 1 min: fare-class upgrades whose payment deadline passed → EXPIRED. + // Separate from the reschedule sweep on purpose — a failure in one must not + // skip the other. + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/1 * * * *') + async expireStaleUpgrades() { + try { + const upgrade = await this.moduleRef.resolve(UpgradeService, undefined, { strict: false }); + const n = await upgrade.expireStale(); + if (n > 0) this.logger.log(`Expired ${n} unpaid upgrade request(s)`); + } catch (err) { + this.logger.error(`expireStaleUpgrades failed: ${err instanceof Error ? err.message : err}`); + } + } + @Cron('0 2 * * *') async purgeExpiredData() { const now = new Date(); diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts new file mode 100644 index 000000000..f52abcbc4 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts @@ -0,0 +1,91 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; +import { UpgradeService } from './upgrade.service'; +import { + CreateUpgradeDto, + CreateUpgradePolicyDto, + UpgradeHoldDto, + UpgradeQuoteDto, + UpdateUpgradePolicyDto, +} from './upgrade.dto'; + +@ApiTags('Fare upgrade') +@Controller() +export class UpgradeController { + constructor(private service: UpgradeService) {} + + @Get('upgrade/policies') + @PassengerStaff(PASSENGER_PERMS.bookings.view) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' }) + listPolicies() { + return this.service.listPolicies(); + } + + @Get('upgrade/policies/available-coach-types') + @PassengerStaff(PASSENGER_PERMS.bookings.view) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' }) + listUnconfiguredCoachTypes() { + return this.service.listUnconfiguredCoachTypes(); + } + + @Post('upgrade/policies') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' }) + createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) { + return this.service.createPolicy(dto, req.user?.id); + } + + @Patch('upgrade/policies/:coachTypeId') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' }) + updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) { + return this.service.updatePolicy(coachTypeId, dto, req.user?.id); + } + + @Delete('upgrade/policies/:coachTypeId') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' }) + deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) { + return this.service.deletePolicy(coachTypeId, req.user?.id); + } + + @Get('bookings/:bookingRef/upgrade') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Per-leg upgrade eligibility, per-passenger targets, pending request and history' }) + options(@Req() req: any, @Param('bookingRef') bookingRef: string) { + return this.service.getOptions(bookingRef, req.user); + } + + @Post('bookings/:bookingRef/upgrade/quote') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Price an upgrade without committing to it' }) + quote(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: UpgradeQuoteDto) { + return this.service.quote(bookingRef, dto, req.user); + } + + @Post('bookings/:bookingRef/upgrade/hold') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Hold the chosen seats, clearing abandoned attempts on this booking first' }) + hold(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: UpgradeHoldDto) { + return this.service.holdForUpgrade(bookingRef, dto, req.user); + } + + @Post('bookings/:bookingRef/upgrade') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Request an upgrade; returns a payment token when money is owed' }) + create(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: CreateUpgradeDto) { + return this.service.create(bookingRef, dto, req.user); + } +} diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.dto.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.dto.ts new file mode 100644 index 000000000..d587172d5 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.dto.ts @@ -0,0 +1,94 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + Max, + Min, + ValidateNested, +} from 'class-validator'; + +export class UpdateUpgradePolicyDto { + @ApiPropertyOptional({ example: 2, description: 'Ladder position — an upgrade needs a strictly higher rank' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) + rank?: number; + + @ApiPropertyOptional({ example: 0, description: '% of the passenger\'s original fare charged as a change fee' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) + feePercent?: number; + + @ApiPropertyOptional({ example: 0, description: 'Fee floor in ETB minor units (500 ETB = 50000)' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) + feeMinMinor?: number; + + @ApiPropertyOptional({ example: true, description: 'Waive the change fee entirely (policy US-17 §5)' }) + @IsOptional() @IsBoolean() + feeWaived?: boolean; + + @ApiPropertyOptional({ example: true, description: 'Passengers may upgrade OUT of this class' }) + @IsOptional() @IsBoolean() + isUpgradable?: boolean; + + @ApiPropertyOptional({ example: true, description: 'Passengers may upgrade INTO this class' }) + @IsOptional() @IsBoolean() + isTargetable?: boolean; + + @ApiPropertyOptional({ example: true }) + @IsOptional() @IsBoolean() + isActive?: boolean; +} + +export class CreateUpgradePolicyDto extends UpdateUpgradePolicyDto { + @ApiProperty({ example: 'coach-type-uuid', description: 'CoachType this policy applies to (one per fare class)' }) + @IsString() + coachTypeId: string; +} + +export class UpgradeItemDto { + @ApiProperty({ example: 'booking-seat-uuid', description: 'The BookingSeat row being upgraded' }) + @IsString() + bookingSeatId: string; + + @ApiProperty({ example: 'seat-uuid', description: 'Seat this passenger moves to, in the target coach type' }) + @IsString() + newSeatId: string; +} + +export class UpgradeQuoteDto { + @ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2) + leg?: number; + + @ApiProperty({ example: 'coach-type-uuid', description: 'Fare class every listed passenger is moving to' }) + @IsString() + newCoachTypeId: string; + + @ApiProperty({ + type: [UpgradeItemDto], + description: + 'One entry per upgrading passenger. Keyed on bookingSeatId, not array position — only some ' + + 'passengers move, so a positional pairing would be ambiguous.', + }) + @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => UpgradeItemDto) + items: UpgradeItemDto[]; +} + +export class UpgradeHoldDto { + @ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2) + leg?: number; + + @ApiProperty({ type: [String], description: 'Seats to hold, in the target coach type' }) + @IsArray() @ArrayMinSize(1) @IsString({ each: true }) + seatIds: string[]; +} + +export class CreateUpgradeDto extends UpgradeQuoteDto { + @ApiProperty({ example: 'seat-hold-uuid', description: 'Hold covering every newSeatId' }) + @IsString() + holdId: string; +} diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.module.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.module.ts new file mode 100644 index 000000000..ad9b7fa64 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.module.ts @@ -0,0 +1,46 @@ +import { Injectable, Logger, Module } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { OnEvent } from '@nestjs/event-emitter'; +import { AuditModule } from '../../common/audit.module'; +import { BookingsModule } from '../bookings/bookings.module'; +import { SeatsModule } from '../seats/seats.module'; +import { SegmentsModule } from '../segments/segments.module'; +import { TicketsModule } from '../tickets/tickets.module'; +import { PaymentsModule } from '../payments/payments.module'; +import { CurrencyModule } from '../currency/currency.module'; +import { SystemConfigModule } from '../system-config/system-config.module'; +import { SUPPLEMENTARY_CHARGE_PAID_EVENT } from '../reschedule/reschedule.service'; +import { UpgradeController } from './upgrade.controller'; +import { UpgradeService } from './upgrade.service'; + +/** + * Same shape and same reason as RescheduleEventsListener: UpgradeService is request-scoped by + * transitivity (AuditService injects REQUEST), and Nest never fires @OnEvent on request-scoped + * providers — so the listener is a singleton that resolves the service per event. + * + * Two listeners on one event is fine: each looks its charge up by its own unique + * `supplementaryChargeId` and returns silently when the charge is not theirs. + */ +@Injectable() +export class UpgradeEventsListener { + private readonly logger = new Logger(UpgradeEventsListener.name); + constructor(private readonly moduleRef: ModuleRef) {} + + @OnEvent(SUPPLEMENTARY_CHARGE_PAID_EVENT, { async: true }) + async onChargePaid(payload: { chargeId: string }) { + try { + const service = await this.moduleRef.resolve(UpgradeService, undefined, { strict: false }); + await service.applyForCharge(payload.chargeId); + } catch (err) { + this.logger.error(`Failed to apply upgrade for charge ${payload.chargeId}: ${err instanceof Error ? err.message : err}`); + } + } +} + +@Module({ + imports: [AuditModule, BookingsModule, SeatsModule, SegmentsModule, TicketsModule, PaymentsModule, CurrencyModule, SystemConfigModule], + controllers: [UpgradeController], + providers: [UpgradeService, UpgradeEventsListener], + exports: [UpgradeService], +}) +export class UpgradeModule {} diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.spec.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.spec.ts new file mode 100644 index 000000000..e0740e41b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.spec.ts @@ -0,0 +1,52 @@ +import { computeUpgradeAmounts } from './upgrade.service'; + +// Pure arithmetic only, mirroring reschedule.service.spec.ts — no Nest test module, no mocks. +describe('computeUpgradeAmounts', () => { + const free = { feePercent: 0, feeMinMinor: 0, feeWaived: false }; + const waived = { feePercent: 30, feeMinMinor: 50000, feeWaived: true }; + const percentOnly = { feePercent: 10, feeMinMinor: 0, feeWaived: false }; + const flooredFee = { feePercent: 10, feeMinMinor: 50000, feeWaived: false }; + + it('charges only the fare difference when the class has no fee', () => { + // RS 1752.34 → EBC 2336.46, as seeded on dev + expect(computeUpgradeAmounts(free, 175234, 233646)).toEqual({ + feeMinor: 0, + fareDifferenceMinor: 58412, + amountDueMinor: 58412, + }); + }); + + it('ignores a configured fee when the policy waives it (US-17 §5)', () => { + expect(computeUpgradeAmounts(waived, 175234, 233646)).toEqual({ + feeMinor: 0, + fareDifferenceMinor: 58412, + amountDueMinor: 58412, + }); + }); + + it('takes the fee as a percentage of the ORIGINAL fare, not of the difference', () => { + const r = computeUpgradeAmounts(percentOnly, 175234, 233646); + expect(r.feeMinor).toBe(17523); // 10% of 175234, not of 58412 + expect(r.amountDueMinor).toBe(17523 + 58412); + }); + + it('applies the fee floor when the percentage falls below it', () => { + const r = computeUpgradeAmounts(flooredFee, 100000, 150000); + expect(r.feeMinor).toBe(50000); // max(10% of 100000 = 10000, floor 50000) + expect(r.amountDueMinor).toBe(100000); + }); + + it('never lets a negative difference reduce the amount due', () => { + // Refused upstream, but the arithmetic must not produce a credit if it ever gets here. + const r = computeUpgradeAmounts(percentOnly, 200000, 150000); + expect(r.fareDifferenceMinor).toBe(-50000); + expect(r.amountDueMinor).toBe(r.feeMinor); + expect(r.amountDueMinor).toBeGreaterThanOrEqual(0); + }); + + it('charges the full target fare for a free child', () => { + const r = computeUpgradeAmounts(free, 0, 233646); + expect(r.feeMinor).toBe(0); // a percentage of zero is zero + expect(r.amountDueMinor).toBe(233646); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts new file mode 100644 index 000000000..f0248233c --- /dev/null +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts @@ -0,0 +1,859 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma.service'; +import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; +import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; +import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service'; +import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils'; +import { + ActingUser, + isNonFareCoachType, + loadOwnedBooking, + NOT_A_FARE_CLASS, + pickSeatClass, + resolveNationalityProxy, +} from '../../common/utils/booking-change.utils'; +import { BookingsService } from '../bookings/bookings.service'; +import { SeatsService } from '../seats/seats.service'; +import { SegmentsService } from '../segments/segments.service'; +import { TicketsService } from '../tickets/tickets.service'; +import { PaymentsService } from '../payments/payments.service'; +import { SupplementaryChargesService } from '../payments/supplementary-charges.service'; +import { CurrencyService } from '../currency/currency.service'; +import { JourneyDirection } from '../seats/seats.dto'; +import { + CreateUpgradeDto, + CreateUpgradePolicyDto, + UpgradeHoldDto, + UpgradeQuoteDto, + UpdateUpgradePolicyDto, +} from './upgrade.dto'; + +export const UPGRADE_CHARGE_REASON = 'UPGRADE'; + +type PolicyFee = { feePercent: number; feeMinMinor: number; feeWaived: boolean }; + +/** + * Pure fee arithmetic for one upgrading passenger — policy US-17. The fee is read from the class + * being upgraded TO (§5 waives it for the premium classes), and is a percentage of that + * passenger's ORIGINAL fare, not of the difference. + * + * A non-positive difference never produces a credit: an upgrade that prices below the current + * seat is refused upstream rather than refunded here (see `buildQuote`). + */ +export function computeUpgradeAmounts( + policy: PolicyFee, + oldFareMinor: number, + newFareMinor: number, +): { feeMinor: number; fareDifferenceMinor: number; amountDueMinor: number } { + const feeMinor = policy.feeWaived + ? 0 + : policy.feePercent > 0 || policy.feeMinMinor > 0 + ? Math.max(Math.round((oldFareMinor * policy.feePercent) / 100), policy.feeMinMinor) + : 0; + const fareDifferenceMinor = newFareMinor - oldFareMinor; + return { feeMinor, fareDifferenceMinor, amountDueMinor: feeMinor + Math.max(0, fareDifferenceMinor) }; +} + +type UpgradeItem = { + bookingSeatId: string; + passengerName: string; + passengerCategory: string; + oldSeatId: string; + oldSeatLabel: string | null; + oldCoachTypeId: string; + oldSeatClassId: string | null; + oldFareMinor: number; + newSeatId: string; + newSeatLabel: string | null; + newCoachTypeId: string; + newSeatClassId: string | null; + newFareMinor: number; + feeMinor: number; + fareDifferenceMinor: number; +}; + +// Seats ordered the same way the reschedule flow orders them, so both features present a leg's +// passengers in one stable sequence. Upgrade itself keys on bookingSeatId, not position. +const bookingInclude = { + schedule: { + select: { + id: true, departureAt: true, arrivalAt: true, status: true, + originStationId: true, destinationStationId: true, + route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } }, + }, + }, + returnSchedule: { + select: { + id: true, departureAt: true, arrivalAt: true, status: true, + originStationId: true, destinationStationId: true, + route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } }, + }, + }, + seats: { + include: { seat: { include: { coach: { select: { id: true, coachTypeId: true } } } } }, + orderBy: [{ passengerName: 'asc' as const }, { id: 'asc' as const }], + }, +} satisfies Prisma.BookingInclude; + +@Injectable() +export class UpgradeService { + private readonly logger = new Logger(UpgradeService.name); + + constructor( + private prisma: PrismaService, + private bookingsService: BookingsService, + private seatsService: SeatsService, + private segmentsService: SegmentsService, + private ticketsService: TicketsService, + private paymentsService: PaymentsService, + private supplementaryCharges: SupplementaryChargesService, + private currencyService: CurrencyService, + private auditService: AuditService, + private eventEmitter: EventEmitter2, + private systemConfig: SystemConfigService, + ) {} + + // ── Policy admin ───────────────────────────────────────────────────────── + + async listPolicies() { + return this.prisma.upgradePolicy.findMany({ + include: { coachType: { select: { id: true, code: true, name: true, type: true } } }, + orderBy: { rank: 'asc' }, + }); + } + + /** Fare classes with no upgrade policy yet — the add dialog's dropdown. */ + async listUnconfiguredCoachTypes() { + return this.prisma.coachType.findMany({ + where: { ...NOT_A_FARE_CLASS, upgradePolicy: { is: null } }, + select: { id: true, code: true, name: true, type: true }, + orderBy: { code: 'asc' }, + }); + } + + async createPolicy(dto: CreateUpgradePolicyDto, actorId?: string) { + const { coachTypeId, ...values } = dto; + const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } }); + if (!coachType) throw new NotFoundException('Coach type not found'); + if (isNonFareCoachType(coachType)) { + throw new BadRequestException(`${coachType.code} is not a fare class — no seats are sold in it.`); + } + const existing = await this.prisma.upgradePolicy.findUnique({ where: { coachTypeId } }); + if (existing) throw new ConflictException(`${coachType.code} already has an upgrade policy — edit it instead.`); + await this.assertRankIsFree(values.rank ?? 0, null); + + const policy = await this.prisma.upgradePolicy.create({ data: { coachTypeId, ...values } }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.UpgradePolicy, + entityId: policy.id, + newData: { coachTypeCode: coachType.code, ...values }, + }); + return policy; + } + + async updatePolicy(coachTypeId: string, dto: UpdateUpgradePolicyDto, actorId?: string) { + const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } }); + if (!coachType) throw new NotFoundException('Coach type not found'); + const before = await this.prisma.upgradePolicy.findUnique({ where: { coachTypeId } }); + if (dto.rank !== undefined) await this.assertRankIsFree(dto.rank, coachTypeId); + + const policy = await this.prisma.upgradePolicy.upsert({ + where: { coachTypeId }, + update: dto, + create: { coachTypeId, ...dto }, + }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.UpgradePolicy, + entityId: policy.id, + oldData: before ?? undefined, + newData: { coachTypeCode: coachType.code, ...dto }, + }); + return policy; + } + + async deletePolicy(coachTypeId: string, actorId?: string) { + const policy = await this.prisma.upgradePolicy.findUnique({ + where: { coachTypeId }, + include: { coachType: { select: { code: true } } }, + }); + if (!policy) throw new NotFoundException('Upgrade policy not found'); + + await this.prisma.upgradePolicy.delete({ where: { coachTypeId } }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.UpgradePolicy, + entityId: policy.id, + oldData: policy, + }); + // With no policy the class can be neither left nor entered — the intended effect of deleting. + return { deleted: true, coachTypeId }; + } + + /** + * Two active policies sharing a rank make "strictly higher" undefined, so the ladder must stay + * a total order. + */ + private async assertRankIsFree(rank: number, exceptCoachTypeId: string | null) { + const clash = await this.prisma.upgradePolicy.findFirst({ + where: { rank, isActive: true, ...(exceptCoachTypeId ? { coachTypeId: { not: exceptCoachTypeId } } : {}) }, + include: { coachType: { select: { code: true } } }, + }); + if (clash) { + throw new ConflictException(`Rank ${rank} is already used by ${clash.coachType.code}. Ranks must be unique.`); + } + } + + // ── Reads ──────────────────────────────────────────────────────────────── + + /** Per leg: who can upgrade, to which classes, and roughly what it costs. */ + async getOptions(bookingRef: string, user: ActingUser) { + const booking = await this.load(bookingRef, user); + const legs = this.legsOf(booking); + const pending = await this.prisma.bookingUpgrade.findFirst({ + where: { bookingId: booking.id, status: 'PENDING_PAYMENT' }, + }); + const charge = pending?.supplementaryChargeId + ? await this.prisma.supplementaryCharge.findUnique({ + where: { id: pending.supplementaryChargeId }, + select: { paymentToken: true, status: true, expiresAt: true }, + }) + : null; + + const out = []; + for (const leg of legs) { + const blockers = await this.legBlockers(booking, leg); + const targets = await this.targetsFor(leg); + // Each passenger's own class decides what counts as "up" for them, so the source policy + // has to be resolved per seat — on a mixed-class booking they differ. + const sourcePolicies = await this.prisma.upgradePolicy.findMany({ + where: { coachTypeId: { in: [...new Set(leg.seats.map((s: any) => s.coachTypeId as string).filter(Boolean))] as string[] } }, + }); + const sourceByCoachType = new Map(sourcePolicies.map((p) => [p.coachTypeId, p])); + + const passengers = leg.seats.map((s: any) => { + const source = sourceByCoachType.get(s.coachTypeId); + const canLeave = !!source && source.isActive && source.isUpgradable; + return { + bookingSeatId: s.id, + passengerName: s.passengerName, + passengerCategory: s.passengerCategory, + seatId: s.seatId, + seatLabel: s.seatLabel, + coachTypeId: s.coachTypeId, + currentRank: source?.rank ?? null, + currentFareMinor: s.fareMinor ?? 0, + // A passenger can only move up from where they actually sit, which on a mixed-class + // booking differs per passenger. No policy on their current class means they cannot + // leave it at all. + targets: canLeave + ? targets.filter((t) => t.rank > source!.rank && t.coachTypeId !== s.coachTypeId) + : [], + }; + }); + + out.push({ + leg: leg.leg, + scheduleId: leg.scheduleId, + originStationId: leg.originStationId, + destinationStationId: leg.destinationStationId, + departureAt: leg.departureAt, + checkinCutoffAt: leg.checkin?.cutoffAt ?? null, + checkinMinutes: leg.checkin?.checkinMinutes ?? null, + canUpgrade: blockers.length === 0 && passengers.some((p: any) => p.targets.length > 0), + blockers, + passengers, + }); + } + + const history = await this.prisma.bookingUpgrade.findMany({ + where: { bookingId: booking.id, status: { not: 'PENDING_PAYMENT' } }, + orderBy: { createdAt: 'desc' }, + }); + + return { + bookingRef: booking.bookingRef, + bookingType: booking.bookingType, + legs: out, + pending: pending ? { ...pending, paymentToken: charge?.paymentToken ?? null } : null, + history, + }; + } + + // ── Quote / create / apply ─────────────────────────────────────────────── + + /** + * Takes the seat hold for an upgrade attempt. + * + * Server-side rather than letting the portal call `/seats/hold` directly, because an upgrade + * holds on the SAME schedule the booking already occupies — so a retry collides with the + * caller's own abandoned attempt: first on the synthetic passenger id, and if they re-pick the + * same seat, on the seat itself. Clearing this booking's own stale upgrade holds first is the + * only way a passenger can change their mind inside the hold TTL. Deriving the schedule and + * stations from the booking instead of trusting the client is a bonus. + */ + async holdForUpgrade(bookingRef: string, dto: UpgradeHoldDto, user: ActingUser) { + const booking = await this.load(bookingRef, user); + const legNo = dto.leg ?? 1; + const leg = this.legsOf(booking).find((l) => l.leg === legNo); + if (!leg) throw new BadRequestException(`Booking has no leg ${legNo}`); + + await this.releaseAbandonedHolds(booking.bookingRef, leg.scheduleId); + + return this.seatsService.holdSeats({ + scheduleId: leg.scheduleId, + originStationId: leg.originStationId, + destinationStationId: leg.destinationStationId, + journeyDirection: legNo === 2 ? JourneyDirection.RETURN : JourneyDirection.ONE_WAY, + // Synthetic ids: there is no real passenger id to hand, and holdSeats only uses them to + // stop one passenger holding two seats on a leg. Tagged with the booking ref so this + // booking's own abandoned attempts can be told apart from anyone else's hold. + passengers: dto.seatIds.map((seatId, i) => ({ + passengerId: `${this.upgradeHoldPrefix(bookingRef)}${i}`, + seatId, + })), + } as any); + } + + private upgradeHoldPrefix(bookingRef: string) { + return `upgrade-${bookingRef}-`; + } + + /** + * Deletes holds this booking's own earlier upgrade attempts left behind, except one already + * committed to a PENDING_PAYMENT upgrade (that one is paid-for and must survive). + */ + private async releaseAbandonedHolds(bookingRef: string, scheduleId: string) { + const prefix = this.upgradeHoldPrefix(bookingRef); + const live = await this.prisma.bookingUpgrade.findMany({ + where: { status: 'PENDING_PAYMENT', holdId: { not: null } }, + select: { holdId: true }, + }); + const committed = new Set(live.map((u) => u.holdId!)); + + const holds = await this.prisma.seatHold.findMany({ where: { scheduleId } }); + const mine = holds.filter((h) => { + if (committed.has(h.id)) return false; + if (!h.createdBy?.trimStart().startsWith('{')) return false; + try { + const meta = JSON.parse(h.createdBy); + return (meta.passengers ?? []).some((p: any) => String(p.passengerId ?? '').startsWith(prefix)); + } catch { + return false; + } + }); + if (mine.length) { + await this.prisma.seatHold.deleteMany({ where: { id: { in: mine.map((h) => h.id) } } }); + this.logger.log(`Released ${mine.length} abandoned upgrade hold(s) for ${bookingRef}`); + } + } + + async quote(bookingRef: string, dto: UpgradeQuoteDto, user: ActingUser) { + const booking = await this.load(bookingRef, user); + return this.buildQuote(booking, dto); + } + + async create(bookingRef: string, dto: CreateUpgradeDto, user: ActingUser) { + const booking = await this.load(bookingRef, user); + const q = await this.buildQuote(booking, dto, { skipAvailability: true }); + if (!q.allowed) throw new BadRequestException(q.blockers.join(' ')); + + const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); + if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired'); + if (hold.scheduleId !== q.scheduleId) throw new BadRequestException('Seat hold is for a different schedule'); + const held = new Set(hold.seatIds); + if (!q.items.every((it) => held.has(it.newSeatId))) { + throw new BadRequestException('Selected seats are not covered by the hold'); + } + + const requestedBy = user.id ?? user.sub ?? booking.passengerId; + // Deadline is the earlier of the usual 2h payment window and the check-in cutoff, so a + // passenger can never pay for an upgrade after boarding has closed on it. + const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.UPGRADE_PAYMENT_WINDOW_MINUTES); + const expiresAt = computePaymentDeadline( + new Date(), + q.checkin.segmentTime, + q.checkin.checkinMinutes, + windowMinutes, + ); + + const upgrade = await this.prisma.bookingUpgrade.create({ + data: { + bookingId: booking.id, + leg: q.leg, + status: 'PENDING_PAYMENT', + requestedBy, + scheduleId: q.scheduleId, + items: q.items as unknown as Prisma.InputJsonValue, + holdId: dto.holdId, + oldFareMinor: q.oldFareMinor, + newFareMinor: q.newFareMinor, + fareDifferenceMinor: q.fareDifferenceMinor, + feeMinor: q.feeMinor, + amountDueMinor: q.amountDueMinor, + expiresAt: q.amountDueMinor > 0 ? expiresAt : null, + }, + }); + + if (q.amountDueMinor === 0) { + await this.apply(upgrade.id); + return { upgradeId: upgrade.id, status: 'APPLIED', amountDueMinor: 0, paymentToken: null, quote: q }; + } + + const charge = await this.supplementaryCharges.create({ + bookingRef: booking.bookingRef, + amountMinor: q.amountDueMinor, + reason: UPGRADE_CHARGE_REASON, + notes: `Upgrade leg ${q.leg} → ${q.newCoachTypeCode} (${q.items.length} passenger(s))`, + createdBy: requestedBy, + expiresAt, + }); + await this.prisma.bookingUpgrade.update({ + where: { id: upgrade.id }, + data: { supplementaryChargeId: charge.id }, + }); + // Same instant the charge carries, so the hold and the payment link die together. + await this.seatsService.confirmSeats(q.items.map((it) => it.newSeatId), new Date(), expiresAt); + + await this.auditService.log({ + userId: requestedBy, + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.BookingUpgrade, + entityId: upgrade.id, + newData: { + bookingRef: booking.bookingRef, + leg: q.leg, + newCoachTypeId: dto.newCoachTypeId, + amountDueMinor: q.amountDueMinor, + chargeId: charge.id, + }, + }); + return { + upgradeId: upgrade.id, + status: 'PENDING_PAYMENT', + amountDueMinor: q.amountDueMinor, + paymentToken: charge.paymentToken, + expiresAt, + quote: q, + }; + } + + /** Entry point for the paid-charge event. Idempotent: only a PENDING_PAYMENT row is applied. */ + async applyForCharge(supplementaryChargeId: string) { + const u = await this.prisma.bookingUpgrade.findUnique({ where: { supplementaryChargeId } }); + if (!u || u.status !== 'PENDING_PAYMENT') return; + await this.apply(u.id); + } + + /** Moves the named passengers into their new seats. The schedule never changes. */ + async apply(upgradeId: string) { + const u = await this.prisma.bookingUpgrade.findUnique({ where: { id: upgradeId } }); + if (!u) throw new NotFoundException('Upgrade not found'); + if (u.status !== 'PENDING_PAYMENT') return u; + + const booking = await this.prisma.booking.findUnique({ where: { id: u.bookingId }, include: bookingInclude }); + if (!booking) throw new NotFoundException('Booking not found'); + const items = u.items as unknown as UpgradeItem[]; + + const seatById = new Map(booking.seats.map((s) => [s.id, s])); + for (const it of items) { + if (!seatById.has(it.bookingSeatId)) { + throw new BadRequestException('A passenger on this upgrade is no longer on the booking'); + } + } + + const newTotal = Math.max(0, booking.totalMinor + u.fareDifferenceMinor); + const displayTotal = + booking.displayCurrency && booking.displayCurrency !== 'ETB' + ? await this.currencyService.convertAmount(newTotal, 'ETB' as any, booking.displayCurrency as any) + : newTotal; + + await this.prisma.$transaction(async (tx) => { + await tx.booking.update({ + where: { id: booking.id }, + data: { totalMinor: newTotal, displayTotalMinor: displayTotal }, + }); + + // Two passes, as the reschedule flow does. Here it is defensive rather than required: the + // schedule is unchanged, so `@@unique([scheduleId, seatId])` can only collide when one + // request upgrades two passengers and the second lands on a seat the first is vacating + // (B: EBC→VIP frees EBC-7 while A: RS→EBC takes it). Parking every row on a per-row-unique + // sentinel first makes the write order irrelevant. + for (const it of items) { + await tx.bookingSeat.update({ + where: { id: it.bookingSeatId }, + data: { scheduleId: `moving-${it.bookingSeatId}` }, + }); + } + for (const it of items) { + await tx.bookingSeat.update({ + where: { id: it.bookingSeatId }, + data: { + seatId: it.newSeatId, + scheduleId: u.scheduleId, + fareMinor: it.newFareMinor, + seatLabelSnapshot: null, + }, + }); + } + + await tx.bookingModification.create({ + data: { + bookingId: booking.id, + modifiedBy: u.requestedBy, + modificationType: 'UPGRADE', + oldData: { + leg: u.leg, + scheduleId: u.scheduleId, + items: items.map((i) => ({ + bookingSeatId: i.bookingSeatId, passengerName: i.passengerName, + seatId: i.oldSeatId, seatLabel: i.oldSeatLabel, + coachTypeId: i.oldCoachTypeId, fareMinor: i.oldFareMinor, + })), + }, + newData: { + leg: u.leg, + scheduleId: u.scheduleId, + feeMinor: u.feeMinor, + items: items.map((i) => ({ + bookingSeatId: i.bookingSeatId, passengerName: i.passengerName, + seatId: i.newSeatId, seatLabel: i.newSeatLabel, + coachTypeId: i.newCoachTypeId, fareMinor: i.newFareMinor, + })), + }, + fareAdjustment: u.fareDifferenceMinor, + }, + }); + + await tx.bookingUpgrade.update({ + where: { id: u.id }, + data: { status: 'APPLIED', appliedAt: new Date() }, + }); + }); + + // Occupancy + tickets are rebuilt from the (now updated) booking, outside the transaction. + const fresh = await this.prisma.booking.findUnique({ + where: { id: booking.id }, + include: { seats: true, tickets: { select: { id: true } } }, + }); + if (fresh) { + try { + await this.seatsService.releaseSeats(fresh.id); + await this.paymentsService.createJourneySegments(fresh as any); + } catch (err) { + this.logger.error(`Upgrade ${u.id}: journey segments failed: ${err instanceof Error ? err.message : err}`); + } + // Old tickets' SYSTEM seat blocks reference ticket ids generate() is about to delete, and + // generate() only clears blocks for the booking's CURRENT seats — the vacated seat is no + // longer among them, so its block would survive. + for (const t of fresh.tickets) { + await this.prisma.seatBlock.deleteMany({ where: { reason: { contains: t.id }, blockedBy: 'SYSTEM' } }); + } + try { + await this.ticketsService.generate(fresh.id); + } catch (err) { + this.logger.error(`Upgrade ${u.id}: ticket generation failed: ${err instanceof Error ? err.message : err}`); + } + } + + // Unlike reschedule, this upgrade stayed on the SAME schedule — so the booking's original + // seat hold is still in scope and would keep the vacated seat reading HELD on the very train + // still being sold. Clearing it is what puts that seat back on sale. + await this.prisma.seatHold.deleteMany({ + where: { + OR: [ + { id: u.holdId ?? '' }, + { scheduleId: u.scheduleId, seatIds: { hasSome: items.map((i) => i.oldSeatId) } }, + ], + }, + }); + + await this.auditService.log({ + userId: u.requestedBy, + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Booking, + entityId: booking.id, + oldData: { leg: u.leg, items: items.map((i) => ({ seatId: i.oldSeatId, coachTypeId: i.oldCoachTypeId })) }, + newData: { + leg: u.leg, + upgradeId: u.id, + feeMinor: u.feeMinor, + fareDifferenceMinor: u.fareDifferenceMinor, + items: items.map((i) => ({ seatId: i.newSeatId, coachTypeId: i.newCoachTypeId })), + }, + }); + this.eventEmitter.emit('booking.upgraded', { booking: fresh ?? booking, upgrade: u }); + return { ...u, status: 'APPLIED' }; + } + + /** Cron hook: unpaid upgrades past their payment deadline. The seat hold lapses by itself. */ + async expireStale(now = new Date()): Promise { + const stale = await this.prisma.bookingUpgrade.findMany({ + where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } }, + select: { id: true, supplementaryChargeId: true }, + }); + for (const u of stale) { + await this.prisma.bookingUpgrade.update({ where: { id: u.id }, data: { status: 'EXPIRED' } }); + if (u.supplementaryChargeId) { + await this.prisma.supplementaryCharge.updateMany({ + where: { id: u.supplementaryChargeId, status: 'PENDING' }, + data: { status: 'EXPIRED' }, + }); + } + } + return stale.length; + } + + // ── Internals ──────────────────────────────────────────────────────────── + + private load(bookingRef: string, user: ActingUser) { + return loadOwnedBooking(this.prisma, bookingRef, user, bookingInclude, 'upgrade it') as Promise< + Prisma.BookingGetPayload<{ include: typeof bookingInclude }> + >; + } + + private legsOf(booking: any) { + const legs: any[] = []; + const build = (n: number, scheduleId: string, schedule: any, originStationId: string, destinationStationId: string) => { + const seats = (booking.seats as any[]) + .filter((s) => (s.leg ?? 1) === n) + .map((s) => ({ + id: s.id, + seatId: s.seatId, + seatLabel: s.seatLabelSnapshot ?? s.seat?.seatNumber ?? null, + passengerName: s.passengerName, + passengerCategory: s.passengerCategory, + fareMinor: s.fareMinor, + coachTypeId: s.seat?.coach?.coachTypeId, + })); + if (!seats.length || !schedule) return; + legs.push({ leg: n, scheduleId, schedule, originStationId, destinationStationId, departureAt: schedule.departureAt, seats }); + }; + build(1, booking.scheduleId, booking.schedule, booking.originStationId, booking.destinationStationId); + if (booking.bookingType === 'ROUND_TRIP') { + build(2, booking.returnScheduleId, booking.returnSchedule, booking.returnOriginStationId, booking.returnDestinationStationId); + } + return legs; + } + + /** Resolves the boarding stop's check-in cutoff — the deadline US-17 §1 means by "before check-in". */ + private async resolveLegCheckin(leg: any) { + const stopTime = await this.prisma.tripStopTime.findFirst({ + where: { scheduleId: leg.scheduleId, stationId: leg.originStationId ?? undefined }, + select: { plannedArrivalAt: true, plannedDepartureAt: true }, + }); + return resolveCheckinCutoff(leg.schedule, stopTime, leg.originStationId); + } + + private async legBlockers(booking: any, leg: any, now = new Date()): Promise { + const blockers: string[] = []; + if (!['ONE_WAY', 'ROUND_TRIP'].includes(booking.bookingType)) blockers.push('Only one-way and round-trip bookings can be upgraded.'); + if (booking.status !== 'CONFIRMED') blockers.push('Only confirmed bookings can be upgraded.'); + if (booking.outboundBoardedAt || booking.returnBoardedAt) blockers.push('This booking has already been used for travel.'); + if (leg.schedule?.status !== 'SCHEDULED' || leg.departureAt <= now) blockers.push('This departure is no longer upgradable.'); + + leg.checkin = await this.resolveLegCheckin(leg); + if (leg.checkin.cutoffAt <= now) { + blockers.push(`Upgrades close ${leg.checkin.checkinMinutes} minutes before departure.`); + } + + // One change at a time. Two live supplementary charges could both drive ticket regeneration + // on this booking and interleave unpredictably. + const pendingUpgrade = await this.prisma.bookingUpgrade.findFirst({ + where: { bookingId: booking.id, status: 'PENDING_PAYMENT' }, + }); + if (pendingUpgrade) blockers.push('An upgrade is already awaiting payment for this booking.'); + const pendingReschedule = await this.prisma.bookingReschedule.findFirst({ + where: { bookingId: booking.id, status: 'PENDING_PAYMENT' }, + }); + if (pendingReschedule) blockers.push('A reschedule is awaiting payment for this booking — finish or cancel it first.'); + + return blockers; + } + + /** Fare classes on this schedule that anyone could upgrade into. */ + private async targetsFor(leg: any) { + const assignments = await this.prisma.coachAssignment.findMany({ + where: { scheduleId: leg.scheduleId, isOperational: true }, + select: { coach: { select: { coachTypeId: true } } }, + }); + const onBoard = [...new Set(assignments.map((a) => a.coach.coachTypeId))]; + if (!onBoard.length) return []; + + const policies = await this.prisma.upgradePolicy.findMany({ + where: { coachTypeId: { in: onBoard }, isActive: true, isTargetable: true, coachType: NOT_A_FARE_CLASS }, + include: { coachType: { select: { id: true, code: true, name: true } } }, + orderBy: { rank: 'asc' }, + }); + return policies.map((p) => ({ + coachTypeId: p.coachTypeId, + code: p.coachType.code, + name: p.coachType.name, + rank: p.rank, + feePercent: p.feePercent, + feeMinMinor: p.feeMinMinor, + feeWaived: p.feeWaived, + })); + } + + private async buildQuote(booking: any, dto: UpgradeQuoteDto, opts: { skipAvailability?: boolean } = {}) { + const legNo = dto.leg ?? 1; + const leg = this.legsOf(booking).find((l) => l.leg === legNo); + if (!leg) throw new BadRequestException(`Booking has no leg ${legNo}`); + + const blockers = await this.legBlockers(booking, leg); + + const target = await this.prisma.upgradePolicy.findUnique({ + where: { coachTypeId: dto.newCoachTypeId }, + include: { coachType: { select: { id: true, code: true, name: true, type: true, seatClasses: { where: { isActive: true } } } } }, + }); + if (!target) throw new NotFoundException('That fare class has no upgrade policy'); + if (!target.isActive || !target.isTargetable) blockers.push(`${target.coachType.code} cannot be upgraded to.`); + + const onSchedule = await this.prisma.coachAssignment.count({ + where: { scheduleId: leg.scheduleId, isOperational: true, coach: { coachTypeId: dto.newCoachTypeId } }, + }); + if (!onSchedule) blockers.push(`${target.coachType.code} is not available on this train.`); + + const seatRows = await this.prisma.seat.findMany({ + where: { id: { in: dto.items.map((i) => i.newSeatId) } }, + include: { coach: { select: { id: true, coachTypeId: true } } }, + }); + const seatById = new Map(seatRows.map((s) => [s.id, s])); + if (seatRows.length !== dto.items.length) blockers.push('One or more selected seats do not exist.'); + if (new Set(dto.items.map((i) => i.newSeatId)).size !== dto.items.length) blockers.push('Duplicate seats selected.'); + + const stopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: leg.scheduleId }, + include: { station: { select: { code: true } } }, + orderBy: { sequence: 'asc' }, + }); + const originStop = stopTimes.find((s) => s.stationId === leg.originStationId); + const destStop = stopTimes.find((s) => s.stationId === leg.destinationStationId); + if (!originStop || !destStop) blockers.push('This leg\'s route could not be resolved.'); + + const { nationalityType, nationality } = resolveNationalityProxy(booking.displayCurrency); + const segmentRoute = originStop && destStop ? `${originStop.station.code}-${destStop.station.code}` : undefined; + + const bookingSeats = new Map(leg.seats.map((s: any) => [s.id, s])); + const items: UpgradeItem[] = []; + let oldFareMinor = 0; + let newFareMinor = 0; + let feeMinor = 0; + + for (const req of dto.items) { + const current: any = bookingSeats.get(req.bookingSeatId); + if (!current) { blockers.push('A selected passenger is not on this leg.'); break; } + + const source = await this.prisma.upgradePolicy.findUnique({ where: { coachTypeId: current.coachTypeId } }); + if (!source || !source.isActive || !source.isUpgradable) { + blockers.push(`${current.passengerName} is in a class that cannot be upgraded.`); + break; + } + if (target.rank <= source.rank) { + blockers.push(`${target.coachType.code} is not an upgrade from ${current.passengerName}'s current class.`); + break; + } + + const seat = seatById.get(req.newSeatId); + if (!seat) break; // already reported above + if (seat.coach.coachTypeId !== dto.newCoachTypeId) { + blockers.push('Every selected seat must be in the fare class being upgraded to.'); + break; + } + + const seatClass = originStop && destStop + ? pickSeatClass(target.coachType.seatClasses, seat.bedPosition, nationalityType) + : null; + if (!seatClass) { blockers.push('No fare is configured for the selected seat.'); break; } + + const seatFare = await this.bookingsService.getBaseFare( + leg.scheduleId, seatClass.id, segmentRoute, undefined, nationality, + originStop!.sequence, destStop!.sequence, originStop!.stationId, destStop!.stationId, + ); + const currentFare = current.fareMinor ?? 0; + const amounts = computeUpgradeAmounts(target, currentFare, seatFare); + + // Refuse rather than credit. A "higher" class pricing below the current seat means the fare + // configuration disagrees with the ladder; handing out a free upgrade would hide that. + if (amounts.fareDifferenceMinor <= 0) { + blockers.push(`${target.coachType.code} is not priced above ${current.passengerName}'s current seat on this route.`); + break; + } + + oldFareMinor += currentFare; + newFareMinor += seatFare; + feeMinor += amounts.feeMinor; + items.push({ + bookingSeatId: current.id, + passengerName: current.passengerName, + passengerCategory: current.passengerCategory, + oldSeatId: current.seatId, + oldSeatLabel: current.seatLabel, + oldCoachTypeId: current.coachTypeId, + oldSeatClassId: null, + oldFareMinor: currentFare, + newSeatId: seat.id, + newSeatLabel: seat.seatNumber, + newCoachTypeId: seat.coach.coachTypeId, + newSeatClassId: seatClass.id, + newFareMinor: seatFare, + feeMinor: amounts.feeMinor, + fareDifferenceMinor: amounts.fareDifferenceMinor, + }); + } + + // Availability last, so a bad selection reports the clearer error first. + // + // Skipped when re-quoting inside create(): by then the caller is holding these very seats, + // so this check would see their own hold and refuse the upgrade they just paid to make. The + // hold itself is the stronger guarantee — holdSeats ran assertNoRouteSeatConflict plus the + // hold and journey-segment collision checks, and create() verifies the hold is unexpired, + // for this schedule, and covers exactly these seats. + if (!opts.skipAvailability && !blockers.length && originStop && destStop) { + const free = await this.segmentsService.getFreeSeatIds( + leg.scheduleId, + items.map((i) => i.newSeatId), + stopTimes as any, + originStop.sequence, + destStop.sequence, + legNo === 2 ? JourneyDirection.RETURN : JourneyDirection.ONE_WAY, + ); + const taken = items.filter((i) => !free.has(i.newSeatId)); + if (taken.length) blockers.push('One or more selected seats have just been taken.'); + } + + const fareDifferenceMinor = newFareMinor - oldFareMinor; + return { + allowed: blockers.length === 0 && items.length === dto.items.length, + blockers: Array.from(new Set(blockers)), + leg: legNo, + scheduleId: leg.scheduleId, + newCoachTypeId: dto.newCoachTypeId, + newCoachTypeCode: target.coachType.code, + newCoachTypeName: target.coachType.name, + checkin: leg.checkin, + items, + oldFareMinor, + newFareMinor, + fareDifferenceMinor, + feeMinor, + amountDueMinor: feeMinor + Math.max(0, fareDifferenceMinor), + currency: 'ETB', + policy: { feePercent: target.feePercent, feeMinMinor: target.feeMinMinor, feeWaived: target.feeWaived }, + }; + } +} 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 90725b683..f0a0d17f5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -9,6 +9,9 @@ type Tab = 'general' | 'payment' | 'integrations' | 'configurations'; export default function SettingsPage() { const [activeTab, setActiveTab] = useState('general'); const [seatHoldMinutes, setSeatHoldMinutes] = useState('5'); + const [bookingPayWindow, setBookingPayWindow] = useState('120'); + const [reschedulePayWindow, setReschedulePayWindow] = useState('120'); + const [upgradePayWindow, setUpgradePayWindow] = useState('120'); const [holdCutoffHours, setHoldCutoffHours] = useState('2'); const [boardingWindowHours, setBoardingWindowHours] = useState('4'); const [throttleAuthLimit, setThrottleAuthLimit] = useState('5'); @@ -24,6 +27,9 @@ export default function SettingsPage() { systemConfigApi.getAll() .then((data) => { if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes); + if (data?.booking_payment_window_minutes) setBookingPayWindow(data.booking_payment_window_minutes); + if (data?.reschedule_payment_window_minutes) setReschedulePayWindow(data.reschedule_payment_window_minutes); + if (data?.upgrade_payment_window_minutes) setUpgradePayWindow(data.upgrade_payment_window_minutes); if (data?.hold_cutoff_hours_before_departure) setHoldCutoffHours(data.hold_cutoff_hours_before_departure); if (data?.boarding_window_hours_before_departure) setBoardingWindowHours(data.boarding_window_hours_before_departure); if (data?.throttle_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit); @@ -40,6 +46,9 @@ export default function SettingsPage() { try { await systemConfigApi.update({ seat_hold_duration_minutes: seatHoldMinutes, + booking_payment_window_minutes: bookingPayWindow, + reschedule_payment_window_minutes: reschedulePayWindow, + upgrade_payment_window_minutes: upgradePayWindow, hold_cutoff_hours_before_departure: holdCutoffHours, boarding_window_hours_before_departure: boardingWindowHours, throttle_auth_limit: throttleAuthLimit, @@ -149,6 +158,44 @@ export default function SettingsPage() { )} +

Payment Windows (minutes)

+

+ How long a payer has before the request expires and the held seat is released. The + check-in cutoff is still the hard limit — a longer window can never allow payment after + boarding closes. +

+
+
+ + setBookingPayWindow(e.target.value)} + /> +

Time to pay for a new booking before it is auto-cancelled. Default: 120.

+
+
+ + setReschedulePayWindow(e.target.value)} + /> +

Time to pay a reschedule charge. Default: 120.

+
+
+ + setUpgradePayWindow(e.target.value)} + /> +

Time to pay a fare-class upgrade. Default: 120.

+
+

Seat Booking

{configLoading ? (

Loading...

diff --git a/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/layout.tsx new file mode 100644 index 000000000..47bf1a723 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function UpgradePoliciesLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx new file mode 100644 index 000000000..5be9108b9 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx @@ -0,0 +1,26 @@ +'use client'; + +import UpgradePolicyManager from '@/components/upgrade/UpgradePolicyManager'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; + +/** + * Master Data → Upgrade Policies. One policy per fare class (coach type); a class with no policy + * can be neither upgraded from nor to. Gated on bookings:view because that is what + * `GET /upgrade/policies` requires; creating, editing and deleting are admin-only server-side. + */ +export default function UpgradePoliciesPage() { + return ( + +
+
+

Upgrade Policies

+

+ Which fare classes a passenger may move up to before check-in, and what the change costs +

+
+ +
+
+ ); +} 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 99c24be9a..58f20b17c 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -29,6 +29,7 @@ import { Briefcase, Calendar, CalendarClock, + ArrowUpNarrowWide, Utensils, Package, Moon, @@ -92,6 +93,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, { name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view }, + { name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: PERMS.bookings.view }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx b/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx new file mode 100644 index 000000000..49e66f607 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx @@ -0,0 +1,363 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Edit, Plus, Save, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { + upgradePolicyApi, + type UpgradePolicyCoachType, + type UpgradePolicyRow, + type UpgradePolicyValues, +} from '@/lib/api'; + +const EMPTY_POLICY: UpgradePolicyValues = { + rank: 0, + feePercent: 0, + feeMinMinor: 0, + feeWaived: false, + isUpgradable: true, + isTargetable: true, + isActive: true, +}; + +// Money is entered in ETB and stored in minor units. +const etb = (minor: number) => String(minor / 100); +const toMinor = (value: string) => Math.round(Number(value || 0) * 100); +const feeLabel = (p: UpgradePolicyRow) => + p.feeWaived + ? 'Waived' + : p.feePercent > 0 || p.feeMinMinor > 0 + ? `${p.feePercent}% · min ETB ${etb(p.feeMinMinor)}` + : 'Free'; + +/** + * Policy US-17 — one upgrade policy per fare class (coach type), listed as a table and edited in + * a dialog, the same shape as Reschedule Policies and Coach Management. + */ +export default function UpgradePolicyManager() { + const [rows, setRows] = useState([]); + 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([ + upgradePolicyApi.list(), + upgradePolicyApi.availableCoachTypes(), + ]); + setRows(Array.isArray(policies) ? policies : []); + setAvailable(Array.isArray(coachTypes) ? coachTypes : []); + } catch { + setMessage('Failed to load upgrade policies.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const openCreate = () => { + setEditing(null); + setCoachTypeId(''); + // Suggest the next free rung rather than 0, which would clash with an existing policy. + setForm({ ...EMPTY_POLICY, rank: Math.max(0, ...rows.map((r) => r.rank)) + 1 }); + setFormError(''); + setShowModal(true); + }; + + const openEdit = (row: UpgradePolicyRow) => { + setEditing(row); + setCoachTypeId(row.coachTypeId); + setForm({ + rank: row.rank, + feePercent: row.feePercent, + feeMinMinor: row.feeMinMinor, + feeWaived: row.feeWaived, + isUpgradable: row.isUpgradable, + isTargetable: row.isTargetable, + isActive: row.isActive, + }); + setFormError(''); + setShowModal(true); + }; + + const setField = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); + + const submit = async () => { + if (!editing && !coachTypeId) { + setFormError('Pick a fare class.'); + return; + } + setSaving(true); + setFormError(''); + try { + if (editing) await upgradePolicyApi.update(editing.coachTypeId, form); + else await upgradePolicyApi.create({ coachTypeId, ...form }); + setShowModal(false); + setMessage(editing ? 'Policy updated.' : 'Policy created.'); + await load(); + } catch (err: any) { + setFormError(err?.response?.data?.message || err?.message || 'Failed to save the policy.'); + } finally { + setSaving(false); + } + }; + + const confirmDelete = async () => { + if (!deleting) return; + setDeleteBusy(true); + try { + await upgradePolicyApi.remove(deleting.coachTypeId); + setDeleting(null); + setMessage('Policy deleted.'); + await load(); + } catch { + setMessage('Failed to delete the policy.'); + } finally { + setDeleteBusy(false); + } + }; + + const columns = [ + { + key: 'coachType', + label: 'Fare class', + render: (row: UpgradePolicyRow) => ( +
+ {row.coachType?.code} + — {row.coachType?.name} +
+ ), + }, + { + key: 'rank', + label: 'Rank', + render: (row: UpgradePolicyRow) => {row.rank}, + }, + { + key: 'fee', + label: 'Change fee', + render: (row: UpgradePolicyRow) => {feeLabel(row)}, + }, + { + key: 'isUpgradable', + label: 'Upgrade from', + render: (row: UpgradePolicyRow) => ( + + {row.isUpgradable ? 'Allowed' : 'No'} + + ), + }, + { + key: 'isTargetable', + label: 'Upgrade to', + render: (row: UpgradePolicyRow) => ( + + {row.isTargetable ? 'Allowed' : 'No'} + + ), + }, + { + key: 'isActive', + label: 'Status', + render: (row: UpgradePolicyRow) => ( + + {row.isActive ? 'Active' : 'Disabled'} + + ), + }, + ]; + + const actions = [ + { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, + { + label: 'Delete', + onClick: (row: UpgradePolicyRow) => setDeleting(row), + variant: 'danger' as const, + icon: Trash2, + }, + ]; + + return ( +
+
+

+ Rank orders the ladder — a passenger may only move to a class with a higher rank, on the same train. + Fee = max(fee % × the passenger's original fare, minimum), read from the class being upgraded + to and charged per upgraded passenger. A fare class with no policy here can be neither + upgraded from nor to. +

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

Every fare class already has a policy.

+ )} + {message &&

{message}

} + + + + setShowModal(false)} + title={editing ? `Edit Upgrade Policy — ${editing.coachType?.code}` : 'Add Upgrade Policy'} + size="lg" + > +
+
+ + {editing ? ( + <> + +

A policy stays attached to its fare class.

+ + ) : ( + + )} +
+ +
+
+ + setField({ rank: Number(e.target.value) })} + /> +

Higher beats lower. Must be unique among active policies.

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

{formError}

} + +
+ setShowModal(false)}> + Cancel + + + {editing ? 'Update Policy' : 'Create Policy'} + +
+
+
+ + setDeleting(null)} + onConfirm={confirmDelete} + title="Delete upgrade policy" + message={`Delete the upgrade policy for ${deleting?.coachType?.code ?? ''}?`} + warning="Passengers will no longer be able to upgrade out of or into this fare class. Upgrades already applied are unaffected." + confirmText="Delete" + isDanger + isLoading={deleteBusy} + /> +
+ ); +} 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 f444adda8..56de6b1a5 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -565,6 +565,40 @@ export interface ReschedulePolicyRow extends ReschedulePolicyValues { coachTypeId: string; coachType: ReschedulePolicyCoachType; } +// Fare-class upgrade policy API — one policy per coach type. `rank` orders the ladder; an +// upgrade requires a strictly higher rank. A coach type with no policy can be neither left nor +// entered. +export interface UpgradePolicyValues { + rank: number; + feePercent: number; + feeMinMinor: number; + feeWaived: boolean; + isUpgradable: boolean; + isTargetable: boolean; + isActive: boolean; +} +export interface UpgradePolicyCoachType { + id: string; + code: string; + name: string; + type: string; +} +export interface UpgradePolicyRow extends UpgradePolicyValues { + id: string; + coachTypeId: string; + coachType: UpgradePolicyCoachType; +} +export const upgradePolicyApi = { + list: () => apiClient.get('/upgrade/policies'), + availableCoachTypes: () => + apiClient.get('/upgrade/policies/available-coach-types'), + create: (data: UpgradePolicyValues & { coachTypeId: string }) => + apiClient.post('/upgrade/policies', data), + update: (coachTypeId: string, data: Partial) => + apiClient.patch(`/upgrade/policies/${coachTypeId}`, data), + remove: (coachTypeId: string) => apiClient.delete(`/upgrade/policies/${coachTypeId}`), +}; + export const reschedulePolicyApi = { list: () => apiClient.get('/reschedule/policies'), availableCoachTypes: () => 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 ab3a6376f..3c98e64da 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 @@ -10,6 +10,7 @@ import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; import { useEffect, useState } from "react"; import { Clock, + ArrowUpCircle, Users, CheckCircle2, AlertCircle, @@ -351,6 +352,7 @@ function BookingDetailContent() { const canReschedule = isAuthenticated && (isBooker || !booking.contactPhone); const reschedulePath = `/booking/reschedule?ref=${booking.bookingRef}`; + const upgradePath = `/booking/upgrade?ref=${booking.bookingRef}`; const StatusBadge = () => { const statusConfig = { @@ -1070,6 +1072,31 @@ function BookingDetailContent() { {isAuthInitialized && !isAuthenticated ? "Sign in to reschedule" : "Reschedule"} )} + {/* Same gating as Reschedule: hidden from a signed-in viewer who did not book + the trip, because the API refuses them; a guest still gets the sign-in + prompt, since signing in as the booker is what unblocks them. Whether any + higher class actually exists on this train is the upgrade page's call. */} + {bookingSupportsReschedule && (!isAuthInitialized || !isAuthenticated || canReschedule) && ( + + )} )} diff --git a/apps/edr-passenger-web/portal/src/app/booking/upgrade/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/upgrade/page.tsx new file mode 100644 index 000000000..94ef6a114 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/upgrade/page.tsx @@ -0,0 +1,571 @@ +"use client"; + +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { AlertCircle, ArrowUpCircle, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react"; +import { apiClient } from "@/lib/api-client"; +import { useAuthStore } from "@/lib/auth-store"; +import SeatMap, { buildSeatLabel, getValidSeatsForCoach } from "@/components/SeatMap"; + +type Target = { + coachTypeId: string; + code: string; + name: string; + rank: number; + feePercent: number; + feeMinMinor: number; + feeWaived: boolean; +}; + +type PassengerOption = { + bookingSeatId: string; + passengerName: string; + passengerCategory: string; + seatId: string; + seatLabel: string | null; + coachTypeId: string; + currentFareMinor: number; + targets: Target[]; +}; + +type LegOption = { + leg: number; + scheduleId: string; + originStationId: string | null; + destinationStationId: string | null; + departureAt: string; + checkinCutoffAt: string | null; + checkinMinutes: number | null; + canUpgrade: boolean; + blockers: string[]; + passengers: PassengerOption[]; +}; + +type Options = { + bookingRef: string; + bookingType: string; + legs: LegOption[]; + pending: { id: string; amountDueMinor: number; paymentToken: string | null; expiresAt: string | null } | null; +}; + +type QuoteItem = { + bookingSeatId: string; + passengerName: string; + oldSeatLabel: string | null; + newSeatLabel: string | null; + oldFareMinor: number; + newFareMinor: number; + feeMinor: number; + fareDifferenceMinor: number; +}; + +type Quote = { + allowed: boolean; + blockers: string[]; + newCoachTypeCode: string; + items: QuoteItem[]; + oldFareMinor: number; + newFareMinor: number; + fareDifferenceMinor: number; + feeMinor: number; + amountDueMinor: number; +}; + +const etb = (minor: number) => `ETB ${(minor / 100).toFixed(2)}`; + +function UpgradePageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const ref = searchParams.get("ref") || ""; + + // Every endpoint here is behind JwtGuard, so a guest deep-linking would otherwise watch the + // options request 401 and land on a message blaming the booking. Send them to sign in and + // bring them back. Waits for isInitialized: the store starts logged-out. + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isAuthInitialized = useAuthStore((s) => s.isInitialized); + const needsLogin = isAuthInitialized && !isAuthenticated; + + useEffect(() => { + if (!needsLogin) return; + const back = ref ? `/booking/upgrade?ref=${ref}` : "/booking/lookup"; + router.replace(`/login?redirect=${encodeURIComponent(back)}`); + }, [needsLogin, ref, router]); + + const [legNo, setLegNo] = useState(1); + const [targetCoachTypeId, setTargetCoachTypeId] = useState(""); + /** bookingSeatId → chosen seat. Only the passengers in here are upgrading. */ + const [picks, setPicks] = useState>({}); + const [activeBookingSeatId, setActiveBookingSeatId] = useState(null); + const [selectedCoach, setSelectedCoach] = useState(null); + const [done, setDone] = useState<{ status: string } | null>(null); + const [error, setError] = useState(null); + + const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery({ + queryKey: ["upgrade-options", ref], + queryFn: () => apiClient.get(`/bookings/${ref}/upgrade`), + enabled: !!ref && isAuthInitialized && isAuthenticated, + retry: false, + }); + + const leg = useMemo( + () => options?.legs.find((l) => l.leg === legNo) ?? options?.legs[0], + [options, legNo], + ); + + // Every class anyone on this leg could move up to, de-duplicated for the chooser. + const targets = useMemo(() => { + const byId = new Map(); + for (const p of leg?.passengers ?? []) for (const t of p.targets) byId.set(t.coachTypeId, t); + return [...byId.values()].sort((a, b) => a.rank - b.rank); + }, [leg]); + + const target = targets.find((t) => t.coachTypeId === targetCoachTypeId) ?? null; + + const resetSelection = () => { + setPicks({}); + setActiveBookingSeatId(null); + setSelectedCoach(null); + setError(null); + }; + + // Switching leg or target invalidates every seat already picked — they belong to a coach that + // is no longer being shown. + useEffect(() => { + resetSelection(); + }, [legNo, targetCoachTypeId]); + + const { data: seatMap, isLoading: loadingSeats } = useQuery({ + queryKey: ["upgrade-seatmap", leg?.scheduleId, targetCoachTypeId, leg?.originStationId, leg?.destinationStationId], + queryFn: async () => { + const res: any = await apiClient.get( + `/seats/seatmap/${leg!.scheduleId}?coachTypeId=${targetCoachTypeId}` + + `&journeyDirection=${legNo === 2 ? "RETURN" : "ONE_WAY"}` + + `&originStationId=${leg!.originStationId}&destinationStationId=${leg!.destinationStationId}`, + ); + return res?.data || res; + }, + enabled: !!leg?.scheduleId && !!targetCoachTypeId, + }); + + const coaches: any[] = useMemo(() => seatMap?.coaches ?? [], [seatMap]); + + const autoExpandedFor = useRef(null); + useEffect(() => { + if (!targetCoachTypeId || coaches.length === 0) return; + if (autoExpandedFor.current === targetCoachTypeId) return; + autoExpandedFor.current = targetCoachTypeId; + setSelectedCoach(coaches[0].id); + }, [targetCoachTypeId, coaches]); + + /** Passengers eligible for the chosen target, in the API's own order. */ + const eligible = useMemo( + () => (leg?.passengers ?? []).filter((p) => p.targets.some((t) => t.coachTypeId === targetCoachTypeId)), + [leg, targetCoachTypeId], + ); + + // Someone must be "active" for a seat click to mean anything. Without this the seat map looks + // fully interactive but every click is a silent no-op until a passenger row is clicked first — + // and on a single-passenger booking there is nothing obvious to click. + useEffect(() => { + if (!targetCoachTypeId || eligible.length === 0) return; + setActiveBookingSeatId((current) => { + if (current && eligible.some((p) => p.bookingSeatId === current)) return current; + return eligible[0].bookingSeatId; + }); + }, [targetCoachTypeId, eligible]); + + const items = useMemo( + () => + eligible + .filter((p) => picks[p.bookingSeatId]) + .map((p) => ({ bookingSeatId: p.bookingSeatId, newSeatId: picks[p.bookingSeatId] })), + [eligible, picks], + ); + + const quoteBody = leg && targetCoachTypeId && items.length > 0 + ? { leg: leg.leg, newCoachTypeId: targetCoachTypeId, items } + : null; + + const { data: quote, isFetching: quoting } = useQuery({ + queryKey: ["upgrade-quote", ref, quoteBody], + queryFn: () => apiClient.post(`/bookings/${ref}/upgrade/quote`, quoteBody), + enabled: !!quoteBody, + }); + + const confirm = useMutation({ + mutationFn: async () => { + // Goes through the upgrade module rather than /seats/hold directly: an upgrade holds on + // the SAME schedule the booking already occupies, so a retry collides with the caller's + // own abandoned attempt. The server clears those first, and derives the schedule and + // stations from the booking instead of trusting us. + const hold: any = await apiClient.post(`/bookings/${ref}/upgrade/hold`, { + leg: leg!.leg, + seatIds: items.map((it) => it.newSeatId), + }); + return apiClient.post(`/bookings/${ref}/upgrade`, { ...quoteBody, holdId: hold.holdId || hold.id }); + }, + onSuccess: (res) => { + if (res.paymentToken) router.push(`/pay-balance/${res.paymentToken}`); + else setDone({ status: res.status }); + }, + onError: (e: any) => setError(e?.response?.data?.message || e?.message || "Could not upgrade"), + }); + + const seatOwner = (seatId: string) => + Object.entries(picks).find(([, sid]) => sid === seatId)?.[0] ?? null; + + const handleSeatToggle = (seatId: string) => { + const owner = seatOwner(seatId); + if (owner && owner !== activeBookingSeatId) return; // already another passenger's pick + // Fall back to the first passenger still without a seat, so a click is never swallowed. + const forPassenger = + activeBookingSeatId ?? eligible.find((p) => !picks[p.bookingSeatId])?.bookingSeatId; + if (!forPassenger) return; + + setPicks((prev) => { + const next = { ...prev }; + if (next[forPassenger] === seatId) { + delete next[forPassenger]; + return next; + } + next[forPassenger] = seatId; + // Move to the next passenger still without a seat, so a multi-passenger upgrade can be + // filled by clicking straight down the coach — same behaviour as /booking/seats. + const nextUnassigned = eligible.find((p) => p.bookingSeatId !== forPassenger && !next[p.bookingSeatId]); + if (nextUnassigned) setActiveBookingSeatId(nextUnassigned.bookingSeatId); + return next; + }); + }; + + const labelForSeat = (seatId: string) => { + const seat = coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === seatId); + return seat ? buildSeatLabel(seat) : ""; + }; + + if (!ref) return

Missing booking reference.

; + if (!isAuthInitialized || needsLogin) { + return ; + } + if (loadingOptions) return ; + if (optionsError || !options || !leg) { + return ( + +

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

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

Upgrade confirmed

+

New tickets have been issued for booking {ref}.

+ +
+
+ ); + } + + if (options.pending) { + return ( + +
+

Upgrade awaiting payment

+

+ An upgrade of {etb(options.pending.amountDueMinor)} is waiting to be paid + {options.pending.expiresAt ? ` before ${format(new Date(options.pending.expiresAt), "dd MMM HH:mm")}` : ""}. + Your new seats are held until then. +

+ {options.pending.paymentToken && ( + + )} +
+
+ ); + } + + const Summary = () => ( +
+

+ Upgrade summary +

+
+
Journey
+
+ {format(new Date(leg.departureAt), "EEE dd MMM, HH:mm")} +
+ {leg.checkinCutoffAt && ( +
+ Upgrades close {format(new Date(leg.checkinCutoffAt), "dd MMM HH:mm")} +
+ )} +
+ + {target && ( +
+
Upgrading to
+
+ {target.code} — {target.name} +
+
+ Change fee: {target.feeWaived || (target.feePercent === 0 && target.feeMinMinor === 0) + ? "none" + : `${target.feePercent}% (min ${etb(target.feeMinMinor)})`} +
+
+ )} + +
+ {eligible.map((p) => ( +
+ {p.passengerName} + + {picks[p.bookingSeatId] + ? `${p.seatLabel ?? "seat"} → ${labelForSeat(picks[p.bookingSeatId])}` + : "Not upgrading"} + +
+ ))} +
+ + {!quoteBody ? ( +

+ Choose a class and a seat for each passenger you want to upgrade. +

+ ) : quoting ? ( +
+ +
+ ) : quote ? ( +
+ + + + +
+ Total due now + {etb(quote.amountDueMinor)} +
+ {quote.blockers.length > 0 && ( +
+ +
{quote.blockers.map((b) =>
{b}
)}
+
+ )} + {error &&
{error}
} + +
+ ) : null} +
+ ); + + return ( + + +

Upgrade {ref}

+

+ Move to a higher fare class on the same train. Each passenger can be upgraded on their own. +

+ + {options.legs.length > 1 && ( +
+ {options.legs.map((l) => ( + + ))} +
+ )} + + {!leg.canUpgrade && ( +
+ +
+ {leg.blockers.length + ? leg.blockers.map((b) =>
{b}
) + :
No higher fare class is available on this train.
} +
+
+ )} + + {leg.canUpgrade && ( +
+
+
+ {/* Step 1 — class */} +

Choose a class

+
+ {targets.map((t) => ( + + ))} +
+ + {/* Step 2 — who, and which seat */} + {targetCoachTypeId && ( + <> +

+ Who is upgrading? ({items.length}/{eligible.length}) +

+

+ Pick a passenger, then choose their new seat below. Leave a passenger unselected to keep + their current seat. +

+
+ {eligible.map((p) => { + const isActive = activeBookingSeatId === p.bookingSeatId; + const picked = picks[p.bookingSeatId]; + return ( + + ); + })} +
+ + {loadingSeats ? ( + + ) : ( + !!activeBookingSeatId && picks[activeBookingSeatId] === id} + isSeatAssignedToOther={(id) => { + const owner = seatOwner(id); + return !!owner && owner !== activeBookingSeatId; + }} + onSeatToggle={handleSeatToggle} + emptyLabel="No seats of that class on this train." + /> + )} + + )} +
+ +
+ +
+
+ +
+
+ +
+
+
+ )} +
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function Shell({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) { + return ( +
+
+ {wide ? ( +
{children}
+ ) : ( +
+ {children} +
+ )} +
+
+ ); +} + +export default function UpgradePage() { + return ( + }> + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx b/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx index 96ecd0d3e..cec48132c 100644 --- a/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx +++ b/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx @@ -10,6 +10,7 @@ import { ChevronLeft, ChevronRight, Clock, + ArrowUpCircle, Eye, CreditCard, RefreshCw, @@ -63,33 +64,52 @@ function describeSeats(seats: MyBookingItem['seats'], leg: number) { interface RowActions { canReschedule: boolean; rescheduleBlocker: string | null; + canUpgrade: boolean; + upgradeBlocker: string | null; isPendingPayment: boolean; } /** - * The coarse reschedule gate, mirroring booking/detail/page.tsx. The per-leg rules - * (fare-class policy, cutoff, seats still free) belong to the reschedule page, which - * names them as blockers — this only avoids sending the customer somewhere that is - * certain to reject them. The phone test matches the API's own ownership check - * (reschedule.service.ts loadOwnedBooking), which is phone-based, not account-based. + * The coarse gate for both change actions, mirroring booking/detail/page.tsx. Reschedule and + * upgrade share it because the booking-shape rules and the ownership check are identical — only + * the wording differs, hence the verb. + * + * The per-leg rules (fare-class policy, cutoffs, whether a higher class even runs on this train, + * seats still free) belong to the reschedule and upgrade pages, which name them as blockers. This + * only avoids sending the customer somewhere certain to reject them. The phone test matches the + * API's own ownership check (loadOwnedBooking), which is phone-based, not account-based. */ function resolveActions(b: MyBookingItem, userPhone?: string): RowActions { const isPendingPayment = b.status === 'PENDING_PAYMENT' || b.status === 'DRAFT'; - let rescheduleBlocker: string | null = null; - if (b.status !== 'CONFIRMED') rescheduleBlocker = 'Only a confirmed booking can be rescheduled'; - else if (b.isPackageBooking) rescheduleBlocker = 'Package bookings cannot be rescheduled online'; - else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType)) - rescheduleBlocker = 'Transit bookings cannot be rescheduled online'; - else if (b.outboundBoardedAt) rescheduleBlocker = 'This trip has already been boarded'; - // The API applies policy.cutoffMinutes to the old leg's departure, so a departed trip - // is always rejected. Say so here instead of sending them to a page that refuses. - else if (new Date(b.schedule.departureAt).getTime() <= Date.now()) - rescheduleBlocker = 'This trip has already departed'; - else if (b.contactPhone && !samePhone(userPhone, b.contactPhone)) - rescheduleBlocker = 'Only the person who made this booking can reschedule it'; + // Both forms are needed: "can be rescheduled" but "can reschedule it". + type Verbs = { past: string; base: string }; + const RESCHEDULE: Verbs = { past: 'rescheduled', base: 'reschedule' }; + const UPGRADE: Verbs = { past: 'upgraded', base: 'upgrade' }; - return { canReschedule: rescheduleBlocker === null, rescheduleBlocker, isPendingPayment }; + let reason: ((v: Verbs) => string) | null = null; + if (b.status !== 'CONFIRMED') reason = (v) => `Only a confirmed booking can be ${v.past}`; + else if (b.isPackageBooking) reason = (v) => `Package bookings cannot be ${v.past} online`; + else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType)) + reason = (v) => `Transit bookings cannot be ${v.past} online`; + else if (b.outboundBoardedAt) reason = () => 'This trip has already been boarded'; + // Both APIs apply a cutoff measured against departure, so a departed trip is always rejected. + // Say so here instead of sending them to a page that refuses. + else if (new Date(b.schedule.departureAt).getTime() <= Date.now()) + reason = () => 'This trip has already departed'; + else if (b.contactPhone && !samePhone(userPhone, b.contactPhone)) + reason = (v) => `Only the person who made this booking can ${v.base} it`; + + const rescheduleBlocker = reason ? reason(RESCHEDULE) : null; + const upgradeBlocker = reason ? reason(UPGRADE) : null; + + return { + canReschedule: rescheduleBlocker === null, + rescheduleBlocker, + canUpgrade: upgradeBlocker === null, + upgradeBlocker, + isPendingPayment, + }; } /** @@ -122,6 +142,7 @@ export default function MyBookingsTable() { const openDetail = (b: MyBookingItem) => router.push(`/booking/detail?ref=${b.bookingRef}`); const openReschedule = (b: MyBookingItem) => router.push(`/booking/reschedule?ref=${b.bookingRef}`); + const openUpgrade = (b: MyBookingItem) => router.push(`/booking/upgrade?ref=${b.bookingRef}`); const cardClass = 'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'; @@ -268,6 +289,20 @@ export default function MyBookingsTable() { Reschedule )} + {!actions.isPendingPayment && ( + + )} @@ -344,6 +379,20 @@ export default function MyBookingsTable() { Reschedule )} + {!actions.isPendingPayment && ( + + )} );