feat: (upgrade) implement per-passenger fare class upgrade with configurable policies

This commit is contained in:
Abubeker Yasin
2026-09-02 16:18:36 +03:00
parent 31121db07b
commit e914aaeb53
34 changed files with 2705 additions and 34 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string | null> {
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<T extends Prisma.BookingInclude>(
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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<void> {
/**
* `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<void> {
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).

View File

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

View File

@@ -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<string, string> = {
[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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<number> {
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<string[]> {
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 },
};
}
}

View File

@@ -9,6 +9,9 @@ type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>('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() {
</div>
</div>
)}
<h3 className="text-lg font-semibold text-foreground">Payment Windows (minutes)</h3>
<p className="text-xs text-muted-foreground -mt-4">
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.
</p>
<div className="max-w-sm space-y-4">
<div className="space-y-2">
<label className="label" htmlFor="booking-pay-window">New booking</label>
<input
id="booking-pay-window"
type="number" min="1" className="input"
value={bookingPayWindow}
onChange={(e) => setBookingPayWindow(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Time to pay for a new booking before it is auto-cancelled. Default: 120.</p>
</div>
<div className="space-y-2">
<label className="label" htmlFor="reschedule-pay-window">Reschedule</label>
<input
id="reschedule-pay-window"
type="number" min="1" className="input"
value={reschedulePayWindow}
onChange={(e) => setReschedulePayWindow(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Time to pay a reschedule charge. Default: 120.</p>
</div>
<div className="space-y-2">
<label className="label" htmlFor="upgrade-pay-window">Fare upgrade</label>
<input
id="upgrade-pay-window"
type="number" min="1" className="input"
value={upgradePayWindow}
onChange={(e) => setUpgradePayWindow(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Time to pay a fare-class upgrade. Default: 120.</p>
</div>
</div>
<h3 className="text-lg font-semibold text-foreground">Seat Booking</h3>
{configLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function UpgradePoliciesLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -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 (
<PermissionGuard permission={PERMS.bookings.view}>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Upgrade Policies</h1>
<p className="text-muted-foreground mt-1">
Which fare classes a passenger may move up to before check-in, and what the change costs
</p>
</div>
<UpgradePolicyManager />
</div>
</PermissionGuard>
);
}

View File

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

View File

@@ -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<UpgradePolicyRow[]>([]);
const [available, setAvailable] = useState<UpgradePolicyCoachType[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<UpgradePolicyRow | null>(null);
const [coachTypeId, setCoachTypeId] = useState('');
const [form, setForm] = useState<UpgradePolicyValues>(EMPTY_POLICY);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState('');
const [deleting, setDeleting] = useState<UpgradePolicyRow | null>(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<UpgradePolicyValues>) => 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) => (
<div>
<span className="font-semibold text-foreground">{row.coachType?.code}</span>
<span className="text-muted-foreground"> {row.coachType?.name}</span>
</div>
),
},
{
key: 'rank',
label: 'Rank',
render: (row: UpgradePolicyRow) => <span className="font-mono text-sm">{row.rank}</span>,
},
{
key: 'fee',
label: 'Change fee',
render: (row: UpgradePolicyRow) => <span className="text-sm">{feeLabel(row)}</span>,
},
{
key: 'isUpgradable',
label: 'Upgrade from',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isUpgradable ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isUpgradable ? 'Allowed' : 'No'}
</span>
),
},
{
key: 'isTargetable',
label: 'Upgrade to',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isTargetable ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isTargetable ? 'Allowed' : 'No'}
</span>
),
},
{
key: 'isActive',
label: 'Status',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isActive ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isActive ? 'Active' : 'Disabled'}
</span>
),
},
];
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 (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<p className="text-xs text-muted-foreground max-w-3xl">
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&apos;s original fare, minimum), read from the class being upgraded
<em> to</em> and charged per upgraded passenger. A fare class with no policy here can be neither
upgraded from nor to.
</p>
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}>
Add Upgrade Policy
</ActionButton>
</div>
{!loading && available.length === 0 && (
<p className="text-xs text-muted-foreground">Every fare class already has a policy.</p>
)}
{message && <p className="text-sm text-muted-foreground">{message}</p>}
<DataTable
data={rows}
columns={columns}
actions={actions}
loading={loading}
emptyMessage="No upgrade policies yet — add one to allow fare-class upgrades."
/>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title={editing ? `Edit Upgrade Policy — ${editing.coachType?.code}` : 'Add Upgrade Policy'}
size="lg"
>
<div className="space-y-4">
<div className="space-y-1">
<label className="label">Fare class</label>
{editing ? (
<>
<input
className="input"
value={`${editing.coachType?.code}${editing.coachType?.name}`}
disabled
/>
<p className="text-xs text-muted-foreground">A policy stays attached to its fare class.</p>
</>
) : (
<select className="input" value={coachTypeId} onChange={(e) => setCoachTypeId(e.target.value)}>
<option value="">Select a fare class...</option>
{available.map((ct) => (
<option key={ct.id} value={ct.id}>
{ct.code} {ct.name}
</option>
))}
</select>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="label">Rank</label>
<input
type="number"
min="0"
className="input"
value={form.rank}
onChange={(e) => setField({ rank: Number(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">Higher beats lower. Must be unique among active policies.</p>
</div>
<div className="space-y-1">
<label className="label">Change fee</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.feeWaived}
onChange={(e) => setField({ feeWaived: e.target.checked })}
/>
Waived charge only the fare difference
</label>
</div>
<div className="space-y-1">
<label className="label">Fee (% of original fare)</label>
<input
type="number"
min="0"
max="100"
className="input"
disabled={form.feeWaived}
value={form.feePercent}
onChange={(e) => setField({ feePercent: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Minimum fee (ETB)</label>
<input
type="number"
min="0"
step="0.01"
className="input"
disabled={form.feeWaived}
value={etb(form.feeMinMinor)}
onChange={(e) => setField({ feeMinMinor: toMinor(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Upgrade from this class</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isUpgradable}
onChange={(e) => setField({ isUpgradable: e.target.checked })}
/>
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Upgrade to this class</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isTargetable}
onChange={(e) => setField({ isTargetable: e.target.checked })}
/>
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Status</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isActive}
onChange={(e) => setField({ isActive: e.target.checked })}
/>
Active
</label>
</div>
</div>
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
Cancel
</ActionButton>
<ActionButton icon={Save} onClick={submit} loading={saving}>
{editing ? 'Update Policy' : 'Create Policy'}
</ActionButton>
</div>
</div>
</Modal>
<ConfirmDialog
isOpen={!!deleting}
onClose={() => 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}
/>
</div>
);
}

View File

@@ -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<UpgradePolicyRow[]>('/upgrade/policies'),
availableCoachTypes: () =>
apiClient.get<UpgradePolicyCoachType[]>('/upgrade/policies/available-coach-types'),
create: (data: UpgradePolicyValues & { coachTypeId: string }) =>
apiClient.post<UpgradePolicyRow>('/upgrade/policies', data),
update: (coachTypeId: string, data: Partial<UpgradePolicyValues>) =>
apiClient.patch<UpgradePolicyRow>(`/upgrade/policies/${coachTypeId}`, data),
remove: (coachTypeId: string) => apiClient.delete<any>(`/upgrade/policies/${coachTypeId}`),
};
export const reschedulePolicyApi = {
list: () => apiClient.get<ReschedulePolicyRow[]>('/reschedule/policies'),
availableCoachTypes: () =>

View File

@@ -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"}
</button>
)}
{/* 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) && (
<button
disabled={!isAuthInitialized}
onClick={() =>
router.push(
isAuthenticated
? upgradePath
: `/login?redirect=${encodeURIComponent(upgradePath)}`,
)
}
title={
isAuthenticated
? "Move to a higher fare class on the same train"
: "Upgrading needs an account — sign in to continue"
}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<ArrowUpCircle className="w-4 h-4" />
{isAuthInitialized && !isAuthenticated ? "Sign in to upgrade" : "Upgrade class"}
</button>
)}
</>
)}
</div>

View File

@@ -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<Record<string, string>>({});
const [activeBookingSeatId, setActiveBookingSeatId] = useState<string | null>(null);
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
const [done, setDone] = useState<{ status: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({
queryKey: ["upgrade-options", ref],
queryFn: () => apiClient.get<Options>(`/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<string, Target>();
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<any>({
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<string | null>(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<Quote>({
queryKey: ["upgrade-quote", ref, quoteBody],
queryFn: () => apiClient.post<Quote>(`/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<any>(`/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 <Shell><p className="text-gray-600">Missing booking reference.</p></Shell>;
if (!isAuthInitialized || needsLogin) {
return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
}
if (loadingOptions) return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
if (optionsError || !options || !leg) {
return (
<Shell>
<p className="text-red-600">
{(optionsError as any)?.response?.data?.message || "This booking cannot be upgraded."}
</p>
</Shell>
);
}
if (done) {
return (
<Shell>
<div className="text-center space-y-4">
<CheckCircle2 className="w-14 h-14 text-green-600 mx-auto" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Upgrade confirmed</h1>
<p className="text-gray-600 dark:text-gray-400">New tickets have been issued for booking {ref}.</p>
<button className="btn-primary" onClick={() => router.push(`/booking/detail?ref=${ref}`)}>
View booking
</button>
</div>
</Shell>
);
}
if (options.pending) {
return (
<Shell>
<div className="space-y-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Upgrade awaiting payment</h1>
<p className="text-gray-600 dark:text-gray-400">
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.
</p>
{options.pending.paymentToken && (
<button className="btn-primary" onClick={() => router.push(`/pay-balance/${options.pending!.paymentToken}`)}>
Pay now
</button>
)}
</div>
</Shell>
);
}
const Summary = () => (
<div className="card space-y-3">
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
Upgrade summary
</h2>
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 mb-1">Journey</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{format(new Date(leg.departureAt), "EEE dd MMM, HH:mm")}
</div>
{leg.checkinCutoffAt && (
<div className="text-xs text-gray-500 dark:text-gray-400">
Upgrades close {format(new Date(leg.checkinCutoffAt), "dd MMM HH:mm")}
</div>
)}
</div>
{target && (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800">
<div className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 mb-1">Upgrading to</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{target.code} {target.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Change fee: {target.feeWaived || (target.feePercent === 0 && target.feeMinMinor === 0)
? "none"
: `${target.feePercent}% (min ${etb(target.feeMinMinor)})`}
</div>
</div>
)}
<div className="pt-3 border-t border-gray-100 dark:border-gray-800 space-y-1">
{eligible.map((p) => (
<div key={p.bookingSeatId} className="flex justify-between text-sm">
<span className="text-gray-700 dark:text-gray-300 truncate max-w-[55%]">{p.passengerName}</span>
<span className={picks[p.bookingSeatId] ? "font-semibold text-gray-900 dark:text-gray-100" : "text-gray-400"}>
{picks[p.bookingSeatId]
? `${p.seatLabel ?? "seat"}${labelForSeat(picks[p.bookingSeatId])}`
: "Not upgrading"}
</span>
</div>
))}
</div>
{!quoteBody ? (
<p className="text-xs text-gray-500 dark:text-gray-400 pt-3 border-t border-gray-100 dark:border-gray-800">
Choose a class and a seat for each passenger you want to upgrade.
</p>
) : quoting ? (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
</div>
) : quote ? (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800 space-y-2 text-sm">
<Row label="Current fare" value={etb(quote.oldFareMinor)} />
<Row label="New fare" value={etb(quote.newFareMinor)} />
<Row label="Fare difference" value={etb(Math.max(0, quote.fareDifferenceMinor))} />
<Row label="Change fee" value={etb(quote.feeMinor)} />
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-gray-700">
<span className="font-bold text-gray-900 dark:text-gray-100">Total due now</span>
<span className="text-xl font-bold text-primary">{etb(quote.amountDueMinor)}</span>
</div>
{quote.blockers.length > 0 && (
<div className="text-red-600 flex gap-2 text-xs">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{quote.blockers.map((b) => <div key={b}>{b}</div>)}</div>
</div>
)}
{error && <div className="text-red-600 text-xs">{error}</div>}
<button
className="btn-primary w-full mt-1"
disabled={!quote.allowed || confirm.isPending}
onClick={() => { setError(null); confirm.mutate(); }}
>
{confirm.isPending ? "Processing..." : `Continue to payment · ${etb(quote.amountDueMinor)}`}
</button>
</div>
) : null}
</div>
);
return (
<Shell wide>
<button
onClick={() => router.push(`/booking/detail?ref=${ref}`)}
className="flex items-center gap-1 text-sm text-gray-500 mb-4"
>
<ChevronLeft className="w-4 h-4" /> Back to booking
</button>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-1">Upgrade {ref}</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
Move to a higher fare class on the same train. Each passenger can be upgraded on their own.
</p>
{options.legs.length > 1 && (
<div className="flex gap-2 mb-6">
{options.legs.map((l) => (
<button
key={l.leg}
onClick={() => setLegNo(l.leg)}
className={`px-4 py-2 rounded-lg border text-sm ${
legNo === l.leg ? "border-primary text-primary" : "border-gray-200 text-gray-600"
}`}
>
{l.leg === 1 ? "Outbound" : "Return"} · {format(new Date(l.departureAt), "dd MMM")}
</button>
))}
</div>
)}
{!leg.canUpgrade && (
<div className="rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 p-4 text-sm text-red-700 mb-6 flex gap-2">
<AlertCircle className="w-5 h-5 shrink-0" />
<div>
{leg.blockers.length
? leg.blockers.map((b) => <div key={b}>{b}</div>)
: <div>No higher fare class is available on this train.</div>}
</div>
</div>
)}
{leg.canUpgrade && (
<div className="lg:grid lg:grid-cols-3 lg:gap-6">
<div className="lg:col-span-2 space-y-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-100 dark:border-gray-700 shadow-sm">
{/* Step 1 — class */}
<h2 className="font-semibold text-gray-900 dark:text-white mb-3">Choose a class</h2>
<div className="grid sm:grid-cols-2 gap-3 mb-6">
{targets.map((t) => (
<button
key={t.coachTypeId}
onClick={() => setTargetCoachTypeId(t.coachTypeId)}
className={`rounded-xl border p-4 text-left ${
targetCoachTypeId === t.coachTypeId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700 hover:border-gray-300"
}`}
>
<div className="font-semibold text-gray-900 dark:text-white">{t.name}</div>
<div className="text-xs text-gray-500">
{t.code} ·{" "}
{t.feeWaived || (t.feePercent === 0 && t.feeMinMinor === 0)
? "no change fee"
: `${t.feePercent}% change fee`}
</div>
</button>
))}
</div>
{/* Step 2 — who, and which seat */}
{targetCoachTypeId && (
<>
<h2 className="font-semibold text-gray-900 dark:text-white mb-1">
Who is upgrading? ({items.length}/{eligible.length})
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Pick a passenger, then choose their new seat below. Leave a passenger unselected to keep
their current seat.
</p>
<div className="mb-4 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800">
{eligible.map((p) => {
const isActive = activeBookingSeatId === p.bookingSeatId;
const picked = picks[p.bookingSeatId];
return (
<button
key={p.bookingSeatId}
type="button"
// Always selects, never clears: with nobody active every seat click is
// silently ignored, which reads as a broken seat map.
onClick={() => setActiveBookingSeatId(p.bookingSeatId)}
className={`w-full flex items-center justify-between py-2 px-3 text-left transition-all ${
isActive ? "bg-[rgb(20,113,76)]/5 dark:bg-[rgb(20,113,76)]/10" : ""
} hover:bg-gray-50 dark:hover:bg-gray-800/60`}
>
<div className="flex items-center gap-2 min-w-0">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
picked
? "bg-[rgb(20,113,76)] text-white"
: isActive
? "bg-[rgb(20,113,76)]/20 text-[rgb(20,113,76)] ring-2 ring-[rgb(20,113,76)]"
: "bg-gray-200 dark:bg-gray-700 text-gray-500"
}`}
>
<ArrowUpCircle className="w-3.5 h-3.5" />
</div>
<div className="min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 truncate block max-w-[160px]">
{p.passengerName}
</span>
<span className="text-[10px] text-gray-400">
now in seat {p.seatLabel ?? "—"}
</span>
</div>
</div>
<span
className={`text-sm font-semibold flex-shrink-0 ${
picked ? "text-[rgb(20,113,76)]" : "text-gray-400"
}`}
>
{picked ? `Seat ${labelForSeat(picked)}` : isActive ? "Pick a seat" : "Not upgrading"}
</span>
</button>
);
})}
</div>
{loadingSeats ? (
<Loader2 className="w-5 h-5 animate-spin text-primary" />
) : (
<SeatMap
coaches={coaches}
selectedCoachId={selectedCoach}
onSelectCoach={setSelectedCoach}
isSeatSelected={(id) => !!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."
/>
)}
</>
)}
</div>
<div className="lg:hidden">
<Summary />
</div>
</div>
<div className="hidden lg:block">
<div className="sticky top-6 max-h-[calc(100vh-3rem)] overflow-y-auto">
<Summary />
</div>
</div>
</div>
)}
</Shell>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between text-gray-700 dark:text-gray-300">
<span>{label}</span>
<span>{value}</span>
</div>
);
}
function Shell({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
<div className="container mx-auto px-4">
{wide ? (
<div className="max-w-6xl mx-auto">{children}</div>
) : (
<div className="max-w-3xl mx-auto bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
{children}
</div>
)}
</div>
</div>
);
}
export default function UpgradePage() {
return (
<Suspense fallback={<Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>}>
<UpgradePageContent />
</Suspense>
);
}

View File

@@ -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
</button>
)}
{!actions.isPendingPayment && (
<button
onClick={() => openUpgrade(b)}
disabled={!actions.canUpgrade}
title={
actions.upgradeBlocker ??
'Move to a higher fare class on the same train'
}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium transition-colors whitespace-nowrap"
>
<ArrowUpCircle className="w-3.5 h-3.5" />
Upgrade
</button>
)}
</div>
</td>
</tr>
@@ -344,6 +379,20 @@ export default function MyBookingsTable() {
Reschedule
</button>
)}
{!actions.isPendingPayment && (
<button
onClick={() => openUpgrade(b)}
disabled={!actions.canUpgrade}
title={
actions.upgradeBlocker ??
'Move to a higher fare class on the same train'
}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium"
>
<ArrowUpCircle className="w-3.5 h-3.5" />
Upgrade
</button>
)}
</div>
</div>
);