mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 17:08:18 +00:00
feat: (upgrade) implement per-passenger fare class upgrade with configurable policies
This commit is contained in:
@@ -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.
|
||||||
@@ -81,6 +81,7 @@ model CoachType {
|
|||||||
coaches Coach[]
|
coaches Coach[]
|
||||||
seatClasses SeatClass[]
|
seatClasses SeatClass[]
|
||||||
reschedulePolicy ReschedulePolicy?
|
reschedulePolicy ReschedulePolicy?
|
||||||
|
upgradePolicy UpgradePolicy?
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,6 +571,7 @@ model Booking {
|
|||||||
agentBooking AgentBooking?
|
agentBooking AgentBooking?
|
||||||
modifications BookingModification[]
|
modifications BookingModification[]
|
||||||
reschedules BookingReschedule[]
|
reschedules BookingReschedule[]
|
||||||
|
upgrades BookingUpgrade[]
|
||||||
cancellation BookingCancellation?
|
cancellation BookingCancellation?
|
||||||
baggage BaggageBooking[]
|
baggage BaggageBooking[]
|
||||||
excessBaggageCharges ExcessBaggageCharge[]
|
excessBaggageCharges ExcessBaggageCharge[]
|
||||||
@@ -1207,6 +1209,64 @@ model AgentCommission {
|
|||||||
@@schema("passenger")
|
@@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
|
/// 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
|
/// coach types (HSC = Standard, HBC = Flex, SBC = Premium). Seeded by migration from the policy
|
||||||
/// doc; edited in backoffice Settings → Reschedule Policy.
|
/// doc; edited in backoffice Settings → Reschedule Policy.
|
||||||
|
|||||||
@@ -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: '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.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.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).
|
// 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.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' },
|
{ id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import { SegmentFareSeeder } from "./seed/segment-fare.seeder";
|
|||||||
|
|
||||||
import { EOtpType } from "@tria-plc/iamapi-common";
|
import { EOtpType } from "@tria-plc/iamapi-common";
|
||||||
import { RescheduleModule } from './modules/reschedule/reschedule.module';
|
import { RescheduleModule } from './modules/reschedule/reschedule.module';
|
||||||
|
import { UpgradeModule } from './modules/upgrade/upgrade.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -166,6 +167,7 @@ import { RescheduleModule } from './modules/reschedule/reschedule.module';
|
|||||||
AppReleasesModule,
|
AppReleasesModule,
|
||||||
ConfigurableFareModule,
|
ConfigurableFareModule,
|
||||||
RescheduleModule,
|
RescheduleModule,
|
||||||
|
UpgradeModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_FILTER, useClass: DeleteExceptionFilter },
|
{ provide: APP_FILTER, useClass: DeleteExceptionFilter },
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export const AUDIT_ENTITIES = {
|
|||||||
Booking: 'Booking',
|
Booking: 'Booking',
|
||||||
BookingReschedule: 'BookingReschedule',
|
BookingReschedule: 'BookingReschedule',
|
||||||
ReschedulePolicy: 'ReschedulePolicy',
|
ReschedulePolicy: 'ReschedulePolicy',
|
||||||
|
BookingUpgrade: 'BookingUpgrade',
|
||||||
|
UpgradePolicy: 'UpgradePolicy',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES];
|
export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES];
|
||||||
|
|||||||
144
apps/edr-passenger-api/src/common/utils/booking-change.utils.ts
Normal file
144
apps/edr-passenger-api/src/common/utils/booking-change.utils.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -24,12 +24,25 @@ export const MIN_PAYMENT_WINDOW_MINUTES = 7;
|
|||||||
export const PAYMENT_SETTLE_MARGIN_SECONDS = 60;
|
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(
|
export function computePaymentDeadline(
|
||||||
createdAt: Date,
|
createdAt: Date,
|
||||||
departureAt: Date,
|
departureAt: Date,
|
||||||
checkinMinutes: number = CUTOFF_MINUTES,
|
checkinMinutes: number = CUTOFF_MINUTES,
|
||||||
|
windowMinutes: number = MAX_PAYMENT_HOURS * 60,
|
||||||
): Date {
|
): 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);
|
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
|
||||||
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
|
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { AuditModule } from '../../common/audit.module';
|
import { AuditModule } from '../../common/audit.module';
|
||||||
@@ -14,7 +15,7 @@ import { PaymentsModule } from '../payments/payments.module';
|
|||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
@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],
|
controllers: [BookingsController],
|
||||||
providers: [BookingsService, GuestBookingService],
|
providers: [BookingsService, GuestBookingService],
|
||||||
exports: [BookingsService, GuestBookingService]
|
exports: [BookingsService, GuestBookingService]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-
|
|||||||
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
|
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
|
||||||
import { JourneyDirection } from '../seats/seats.dto';
|
import { JourneyDirection } from '../seats/seats.dto';
|
||||||
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
|
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 { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ export class GuestBookingService {
|
|||||||
private readonly logger = new Logger(GuestBookingService.name);
|
private readonly logger = new Logger(GuestBookingService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private systemConfig: SystemConfigService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
@InjectDataSource() private readonly dataSource: DataSource,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
private seatsService: SeatsService,
|
private seatsService: SeatsService,
|
||||||
@@ -582,7 +584,10 @@ export class GuestBookingService {
|
|||||||
const { guestPassengerId } = await this.resolveGuestPassenger({}, passengerData);
|
const { guestPassengerId } = await this.resolveGuestPassenger({}, passengerData);
|
||||||
|
|
||||||
const payToken = isStaff ? undefined : randomUUID();
|
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({
|
const booking = await this.prisma.booking.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -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')
|
@OnEvent('booking.cancelled')
|
||||||
async onBookingCancelled(payload: any) {
|
async onBookingCancelled(payload: any) {
|
||||||
const booking = payload.booking;
|
const booking = payload.booking;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
@@ -55,6 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
SystemConfigModule,
|
||||||
SeatsModule,
|
SeatsModule,
|
||||||
TicketsModule,
|
TicketsModule,
|
||||||
CurrencyModule,
|
CurrencyModule,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { PaymentsService } from "./payments.service";
|
import { PaymentsService } from "./payments.service";
|
||||||
|
import { SystemConfigService } from "../system-config/system-config.service";
|
||||||
import { PaymentClientService } from "./payment-client.service";
|
import { PaymentClientService } from "./payment-client.service";
|
||||||
import { CurrencyService } from "../currency/currency.service";
|
import { CurrencyService } from "../currency/currency.service";
|
||||||
import { PrismaService } from "../../common/prisma.service";
|
import { PrismaService } from "../../common/prisma.service";
|
||||||
@@ -133,6 +134,8 @@ describe("PaymentsService", () => {
|
|||||||
{ provide: PaymentClientService, useValue: mockPaymentClient },
|
{ provide: PaymentClientService, useValue: mockPaymentClient },
|
||||||
{ provide: CurrencyService, useValue: mockCurrencyService },
|
{ provide: CurrencyService, useValue: mockCurrencyService },
|
||||||
{ provide: AuditService, useValue: { log: jest.fn() } },
|
{ 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();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
MIN_PAYMENT_WINDOW_MINUTES,
|
MIN_PAYMENT_WINDOW_MINUTES,
|
||||||
PAYMENT_SETTLE_MARGIN_SECONDS,
|
PAYMENT_SETTLE_MARGIN_SECONDS,
|
||||||
} from "../../common/utils/payment-deadline.utils";
|
} from "../../common/utils/payment-deadline.utils";
|
||||||
|
import { CONFIG_KEYS, SystemConfigService } from "../system-config/system-config.service";
|
||||||
import {
|
import {
|
||||||
PaymentClientService,
|
PaymentClientService,
|
||||||
PaymentDiagnostic,
|
PaymentDiagnostic,
|
||||||
@@ -88,6 +89,7 @@ export class PaymentsService {
|
|||||||
private readonly waafiDemoTrustReturn = true;
|
private readonly waafiDemoTrustReturn = true;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private systemConfig: SystemConfigService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private seatsService: SeatsService,
|
private seatsService: SeatsService,
|
||||||
private ticketsService: TicketsService,
|
private ticketsService: TicketsService,
|
||||||
@@ -748,7 +750,10 @@ export class PaymentsService {
|
|||||||
originRouteStop?.checkinMinutesBefore ??
|
originRouteStop?.checkinMinutesBefore ??
|
||||||
booking.schedule.route?.checkinMinutesBefore ??
|
booking.schedule.route?.checkinMinutesBefore ??
|
||||||
undefined;
|
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(
|
private resolveReturnUrls(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { SeatsModule } from '../seats/seats.module';
|
|||||||
import { TicketsModule } from '../tickets/tickets.module';
|
import { TicketsModule } from '../tickets/tickets.module';
|
||||||
import { PaymentsModule } from '../payments/payments.module';
|
import { PaymentsModule } from '../payments/payments.module';
|
||||||
import { CurrencyModule } from '../currency/currency.module';
|
import { CurrencyModule } from '../currency/currency.module';
|
||||||
|
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||||
import { RescheduleController } from './reschedule.controller';
|
import { RescheduleController } from './reschedule.controller';
|
||||||
import { RescheduleService, SUPPLEMENTARY_CHARGE_PAID_EVENT } from './reschedule.service';
|
import { RescheduleService, SUPPLEMENTARY_CHARGE_PAID_EVENT } from './reschedule.service';
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ export class RescheduleEventsListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule],
|
imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule, SystemConfigModule],
|
||||||
controllers: [RescheduleController],
|
controllers: [RescheduleController],
|
||||||
providers: [RescheduleService, RescheduleEventsListener],
|
providers: [RescheduleService, RescheduleEventsListener],
|
||||||
exports: [RescheduleService],
|
exports: [RescheduleService],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { AuditService } from '../../common/audit.service';
|
|||||||
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
||||||
import { MeLikeUser } from '../../common/passenger-permission.util';
|
import { MeLikeUser } from '../../common/passenger-permission.util';
|
||||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
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 { normalizePhone, samePhone } from '../../common/utils/phone.utils';
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
import { SeatsService } from '../seats/seats.service';
|
import { SeatsService } from '../seats/seats.service';
|
||||||
@@ -85,6 +86,8 @@ type LegView = {
|
|||||||
departureAt: Date;
|
departureAt: Date;
|
||||||
seats: Array<{ id: string; seatId: string; passengerName: string; fareMinor: number | null; passengerCategory: string }>;
|
seats: Array<{ id: string; seatId: string; passengerName: string; fareMinor: number | null; passengerCategory: string }>;
|
||||||
coachTypeId: 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
|
// 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 currencyService: CurrencyService,
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
|
private systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Policy admin ─────────────────────────────────────────────────────────
|
// ── Policy admin ─────────────────────────────────────────────────────────
|
||||||
@@ -267,7 +271,8 @@ export class RescheduleService {
|
|||||||
|
|
||||||
const requestedBy = user.id ?? user.sub ?? booking.passengerId;
|
const requestedBy = user.id ?? user.sub ?? booking.passengerId;
|
||||||
const newDeparture = q.newDepartureAt;
|
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({
|
const reschedule = await this.prisma.bookingReschedule.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -314,7 +319,8 @@ export class RescheduleService {
|
|||||||
where: { id: reschedule.id },
|
where: { id: reschedule.id },
|
||||||
data: { supplementaryChargeId: charge.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({
|
await this.auditService.log({
|
||||||
userId: requestedBy,
|
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 }));
|
.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);
|
const l1 = seatsOf(1);
|
||||||
if (l1.length && booking.schedule) {
|
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);
|
const l2 = seatsOf(2);
|
||||||
if (booking.bookingType === 'ROUND_TRIP' && l2.length && booking.returnSchedule) {
|
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;
|
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
|
// round trip whose outbound was already used can't change its return yet — needs leg-scoped
|
||||||
// ticket regeneration.
|
// ticket regeneration.
|
||||||
if (booking.outboundBoardedAt || booking.returnBoardedAt) blockers.push('This booking has already been used for travel.');
|
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.');
|
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) {
|
else if (leg.departureAt.getTime() - now.getTime() < policy.cutoffMinutes * 60_000) {
|
||||||
blockers.push(`Changes must be made at least ${policy.cutoffMinutes} minutes before departure.`);
|
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' } });
|
const pending = await this.prisma.bookingReschedule.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } });
|
||||||
if (pending) blockers.push('A reschedule is already awaiting payment for this booking.');
|
if (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 (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.');
|
if (new Set(dto.newSeatIds).size !== dto.newSeatIds.length) blockers.push('Duplicate seats selected.');
|
||||||
|
|
||||||
|
|||||||
@@ -701,7 +701,14 @@ export class SeatsService {
|
|||||||
// short seat-selection hold (5 min by default). Without this, the hold could expire
|
// 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
|
// while the customer was still on the payment page, and a second customer could
|
||||||
// hold/book the exact same seat out from under them.
|
// 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;
|
if (seatIds.length === 0) return;
|
||||||
|
|
||||||
const holds = await this.prisma.seatHold.findMany({
|
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 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;
|
let extended = 0;
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
holds.map(async (hold) => {
|
holds.map(async (hold) => {
|
||||||
const departureAt = departureById.get(hold.scheduleId);
|
const departureAt = departureById.get(hold.scheduleId);
|
||||||
if (!departureAt) return;
|
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
|
// 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
|
// than the payment deadline would give it (e.g. a second confirmSeats call on
|
||||||
// the same booking, or a hold that was already extended).
|
// the same booking, or a hold that was already extended).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IsInt, IsOptional, Min, Max } from 'class-validator';
|
import { IsInt, IsOptional, Min, Max } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
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
|
* 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)
|
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
|
||||||
boarding_window_hours_before_departure?: number;
|
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 })
|
@ApiPropertyOptional({ example: 5 })
|
||||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||||
throttle_auth_limit?: number;
|
throttle_auth_limit?: number;
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ export const CONFIG_KEYS = {
|
|||||||
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
|
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
|
||||||
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure',
|
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure',
|
||||||
BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE: 'boarding_window_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_LIMIT: 'throttle_auth_limit',
|
||||||
THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms',
|
THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms',
|
||||||
THROTTLE_STRICT_LIMIT: 'throttle_strict_limit',
|
THROTTLE_STRICT_LIMIT: 'throttle_strict_limit',
|
||||||
@@ -17,6 +23,10 @@ const DEFAULTS: Record<string, string> = {
|
|||||||
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
|
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
|
||||||
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2',
|
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2',
|
||||||
[CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE]: '4',
|
[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_LIMIT]: '5',
|
||||||
[CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000',
|
[CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000',
|
||||||
[CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20',
|
[CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20',
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { PrismaModule } from '../../common/prisma.module';
|
|||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { CurrencyModule } from '../currency/currency.module';
|
import { CurrencyModule } from '../currency/currency.module';
|
||||||
import { PaymentsModule } from '../payments/payments.module';
|
import { PaymentsModule } from '../payments/payments.module';
|
||||||
|
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||||
import { TasksService } from './tasks.service';
|
import { TasksService } from './tasks.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
|
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule, SystemConfigModule],
|
||||||
providers: [TasksService],
|
providers: [TasksService],
|
||||||
})
|
})
|
||||||
export class TasksModule {}
|
export class TasksModule {}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { SmsClientService } from '../notifications/sms-client.service';
|
|||||||
import { CurrencyService } from '../currency/currency.service';
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
import { PaymentsService } from '../payments/payments.service';
|
import { PaymentsService } from '../payments/payments.service';
|
||||||
import { RescheduleService } from '../reschedule/reschedule.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';
|
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||||
|
|
||||||
// Retention windows
|
// Retention windows
|
||||||
@@ -44,6 +46,8 @@ export class TasksService {
|
|||||||
// REQUEST), and injecting a request-scoped provider here would make TasksService request-scoped
|
// 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.
|
// too — which silently stops all its @Cron methods from firing. Resolve it per-tick instead.
|
||||||
private readonly moduleRef: ModuleRef,
|
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 ────────
|
// ── Send reminder at the midpoint of each booking's payment window ────────
|
||||||
private async sendPaymentReminders(now: Date) {
|
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.
|
// Only look at bookings created within the last 3 h with a future departure.
|
||||||
const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000);
|
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;
|
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
|
||||||
if (dep <= now) continue; // segment has already departed; cancel job handles clean-up
|
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();
|
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
|
||||||
|
|
||||||
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
|
// 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 ─────────────────────
|
// ── Cancel bookings whose payment deadline has passed ─────────────────────
|
||||||
private async cancelExpiredPendingBookings(now: Date) {
|
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
|
// 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
|
// deadline check happens per-row further down. It must be widened to the largest
|
||||||
@@ -286,7 +294,7 @@ export class TasksService {
|
|||||||
where: {
|
where: {
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
OR: [
|
OR: [
|
||||||
{ createdAt: { lte: twoHoursAgo } },
|
{ createdAt: { lte: windowAgo } },
|
||||||
{ schedule: { departureAt: { lte: departureCutoff } } },
|
{ schedule: { departureAt: { lte: departureCutoff } } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -329,7 +337,7 @@ export class TasksService {
|
|||||||
(s: any) => s.stationId === (booking as any).originStationId,
|
(s: any) => s.stationId === (booking as any).originStationId,
|
||||||
);
|
);
|
||||||
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
|
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;
|
if (now < paymentDeadline) continue;
|
||||||
|
|
||||||
// Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded
|
// 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 * * *')
|
@Cron('0 2 * * *')
|
||||||
async purgeExpiredData() {
|
async purgeExpiredData() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
apps/edr-passenger-api/src/modules/upgrade/upgrade.dto.ts
Normal file
94
apps/edr-passenger-api/src/modules/upgrade/upgrade.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
46
apps/edr-passenger-api/src/modules/upgrade/upgrade.module.ts
Normal file
46
apps/edr-passenger-api/src/modules/upgrade/upgrade.module.ts
Normal 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 {}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
859
apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts
Normal file
859
apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
|
|||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('general');
|
const [activeTab, setActiveTab] = useState<Tab>('general');
|
||||||
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
|
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 [holdCutoffHours, setHoldCutoffHours] = useState('2');
|
||||||
const [boardingWindowHours, setBoardingWindowHours] = useState('4');
|
const [boardingWindowHours, setBoardingWindowHours] = useState('4');
|
||||||
const [throttleAuthLimit, setThrottleAuthLimit] = useState('5');
|
const [throttleAuthLimit, setThrottleAuthLimit] = useState('5');
|
||||||
@@ -24,6 +27,9 @@ export default function SettingsPage() {
|
|||||||
systemConfigApi.getAll()
|
systemConfigApi.getAll()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes);
|
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?.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?.boarding_window_hours_before_departure) setBoardingWindowHours(data.boarding_window_hours_before_departure);
|
||||||
if (data?.throttle_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit);
|
if (data?.throttle_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit);
|
||||||
@@ -40,6 +46,9 @@ export default function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await systemConfigApi.update({
|
await systemConfigApi.update({
|
||||||
seat_hold_duration_minutes: seatHoldMinutes,
|
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,
|
hold_cutoff_hours_before_departure: holdCutoffHours,
|
||||||
boarding_window_hours_before_departure: boardingWindowHours,
|
boarding_window_hours_before_departure: boardingWindowHours,
|
||||||
throttle_auth_limit: throttleAuthLimit,
|
throttle_auth_limit: throttleAuthLimit,
|
||||||
@@ -149,6 +158,44 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<h3 className="text-lg font-semibold text-foreground">Seat Booking</h3>
|
||||||
{configLoading ? (
|
{configLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import DashboardLayout from '../dashboard/layout';
|
||||||
|
|
||||||
|
export default function UpgradePoliciesLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <DashboardLayout>{children}</DashboardLayout>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
Briefcase,
|
Briefcase,
|
||||||
Calendar,
|
Calendar,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
ArrowUpNarrowWide,
|
||||||
Utensils,
|
Utensils,
|
||||||
Package,
|
Package,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -92,6 +93,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
|||||||
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
|
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
|
||||||
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
|
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
|
||||||
{ name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.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 },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -565,6 +565,40 @@ export interface ReschedulePolicyRow extends ReschedulePolicyValues {
|
|||||||
coachTypeId: string;
|
coachTypeId: string;
|
||||||
coachType: ReschedulePolicyCoachType;
|
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 = {
|
export const reschedulePolicyApi = {
|
||||||
list: () => apiClient.get<ReschedulePolicyRow[]>('/reschedule/policies'),
|
list: () => apiClient.get<ReschedulePolicyRow[]>('/reschedule/policies'),
|
||||||
availableCoachTypes: () =>
|
availableCoachTypes: () =>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
|
ArrowUpCircle,
|
||||||
Users,
|
Users,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -351,6 +352,7 @@ function BookingDetailContent() {
|
|||||||
const canReschedule = isAuthenticated && (isBooker || !booking.contactPhone);
|
const canReschedule = isAuthenticated && (isBooker || !booking.contactPhone);
|
||||||
|
|
||||||
const reschedulePath = `/booking/reschedule?ref=${booking.bookingRef}`;
|
const reschedulePath = `/booking/reschedule?ref=${booking.bookingRef}`;
|
||||||
|
const upgradePath = `/booking/upgrade?ref=${booking.bookingRef}`;
|
||||||
|
|
||||||
const StatusBadge = () => {
|
const StatusBadge = () => {
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
@@ -1070,6 +1072,31 @@ function BookingDetailContent() {
|
|||||||
{isAuthInitialized && !isAuthenticated ? "Sign in to reschedule" : "Reschedule"}
|
{isAuthInitialized && !isAuthenticated ? "Sign in to reschedule" : "Reschedule"}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|||||||
571
apps/edr-passenger-web/portal/src/app/booking/upgrade/page.tsx
Normal file
571
apps/edr-passenger-web/portal/src/app/booking/upgrade/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
|
ArrowUpCircle,
|
||||||
Eye,
|
Eye,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -63,33 +64,52 @@ function describeSeats(seats: MyBookingItem['seats'], leg: number) {
|
|||||||
interface RowActions {
|
interface RowActions {
|
||||||
canReschedule: boolean;
|
canReschedule: boolean;
|
||||||
rescheduleBlocker: string | null;
|
rescheduleBlocker: string | null;
|
||||||
|
canUpgrade: boolean;
|
||||||
|
upgradeBlocker: string | null;
|
||||||
isPendingPayment: boolean;
|
isPendingPayment: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The coarse reschedule gate, mirroring booking/detail/page.tsx. The per-leg rules
|
* The coarse gate for both change actions, mirroring booking/detail/page.tsx. Reschedule and
|
||||||
* (fare-class policy, cutoff, seats still free) belong to the reschedule page, which
|
* upgrade share it because the booking-shape rules and the ownership check are identical — only
|
||||||
* names them as blockers — this only avoids sending the customer somewhere that is
|
* the wording differs, hence the verb.
|
||||||
* 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 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 {
|
function resolveActions(b: MyBookingItem, userPhone?: string): RowActions {
|
||||||
const isPendingPayment = b.status === 'PENDING_PAYMENT' || b.status === 'DRAFT';
|
const isPendingPayment = b.status === 'PENDING_PAYMENT' || b.status === 'DRAFT';
|
||||||
|
|
||||||
let rescheduleBlocker: string | null = null;
|
// Both forms are needed: "can be rescheduled" but "can reschedule it".
|
||||||
if (b.status !== 'CONFIRMED') rescheduleBlocker = 'Only a confirmed booking can be rescheduled';
|
type Verbs = { past: string; base: string };
|
||||||
else if (b.isPackageBooking) rescheduleBlocker = 'Package bookings cannot be rescheduled online';
|
const RESCHEDULE: Verbs = { past: 'rescheduled', base: 'reschedule' };
|
||||||
else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType))
|
const UPGRADE: Verbs = { past: 'upgraded', base: 'upgrade' };
|
||||||
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';
|
|
||||||
|
|
||||||
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 openDetail = (b: MyBookingItem) => router.push(`/booking/detail?ref=${b.bookingRef}`);
|
||||||
const openReschedule = (b: MyBookingItem) => router.push(`/booking/reschedule?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 =
|
const cardClass =
|
||||||
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700';
|
'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
|
Reschedule
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -344,6 +379,20 @@ export default function MyBookingsTable() {
|
|||||||
Reschedule
|
Reschedule
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user