Merge pull request #1486 from Tria-plc/reschedule

Reschedule
This commit is contained in:
Abubeker Yasin
2026-09-03 16:17:11 +03:00
committed by GitHub
37 changed files with 3296 additions and 141 deletions

View File

@@ -65,6 +65,7 @@ import { SegmentFareSeeder } from "./seed/segment-fare.seeder";
import { EOtpType } from "@tria-plc/iamapi-common";
import { RescheduleModule } from './modules/reschedule/reschedule.module';
import { UpgradeModule } from './modules/upgrade/upgrade.module';
@Module({
imports: [
@@ -166,6 +167,7 @@ import { RescheduleModule } from './modules/reschedule/reschedule.module';
AppReleasesModule,
ConfigurableFareModule,
RescheduleModule,
UpgradeModule,
],
providers: [
{ provide: APP_FILTER, useClass: DeleteExceptionFilter },

View File

@@ -88,6 +88,8 @@ export const AUDIT_ENTITIES = {
Booking: 'Booking',
BookingReschedule: 'BookingReschedule',
ReschedulePolicy: 'ReschedulePolicy',
BookingUpgrade: 'BookingUpgrade',
UpgradePolicy: 'UpgradePolicy',
} as const;
export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES];

View File

@@ -0,0 +1,144 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma.service';
import { MeLikeUser } from '../passenger-permission.util';
import { normalizePhone, samePhone } from './phone.utils';
/**
* Shared by every flow that lets a passenger change a confirmed booking — reschedule today,
* fare-class upgrade next. These were private to RescheduleService; they live here so the two
* features cannot drift apart on who is allowed to act or how a seat is priced.
*
* Plain functions rather than a provider on purpose: AuditService injects REQUEST, so anything
* made injectable here would drag request scope into whatever consumes it.
*/
export type ActingUser = MeLikeUser & { id?: string; sub?: string; phoneNumber?: string };
/**
* The signed-in user's phone. The session snapshot (`userInfo.phoneNumber`) is frequently an
* empty string, so `iam.users` is the source of truth — and reading it live also means a user
* who changed their number does not have to sign out before the new one counts.
*/
export async function resolveUserPhone(
prisma: PrismaService,
iamUserId: string,
user: ActingUser,
): Promise<string | null> {
const fromSession = normalizePhone(user.phoneNumber);
if (fromSession) return fromSession;
const rows = await prisma.$queryRaw<{ phone_number: string | null }[]>`
SELECT phone_number FROM iam.users WHERE id = ${iamUserId}::uuid LIMIT 1
`;
return normalizePhone(rows[0]?.phone_number);
}
/**
* Loads a booking only for the person who made it, proven by their account's phone number
* matching the booking's `contactPhone`. Being merely *named* on the booking is not enough — a
* passenger travelling on someone else's booking cannot change it.
*
* There is deliberately no staff override. `bookings:reschedule` exists in the registry (and on
* the stationMaster preset) but is not honoured, so a station master cannot act on a customer's
* behalf yet.
*
* `action` only shapes the error message ("reschedule it" / "upgrade it").
*/
export async function loadOwnedBooking<T extends Prisma.BookingInclude>(
prisma: PrismaService,
bookingRef: string,
user: ActingUser,
include: T,
action = 'change it',
) {
const booking = await prisma.booking.findUnique({ where: { bookingRef }, include });
if (!booking) throw new NotFoundException('Booking not found');
const iamUserId = user.id ?? user.sub;
if (!iamUserId) throw new ForbiddenException();
const b = booking as any;
if (b.contactPhone) {
const callerPhone = await resolveUserPhone(prisma, iamUserId, user);
if (samePhone(callerPhone, b.contactPhone)) return booking;
throw new ForbiddenException(
`Only the person who made this booking can ${action}. Sign in with the phone number used to book.`,
);
}
// A small tail of bookings carry no contactPhone at all, so there is nothing to match against.
// Fall back to the account link rather than locking their owner out entirely.
const passenger = await prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
if (!passenger || passenger.id !== b.passengerId) throw new ForbiddenException('Not your booking');
return booking;
}
/**
* Coaches nobody buys a seat in, so they can never carry a fare-class policy.
*
* Matched loosely on purpose: `CoachType.type` is documented as 'passenger' | 'sleeper' |
* 'dining' | 'baggage', but the live data holds display labels ('Dining Coach ', trailing space
* included). A `notIn: ['dining','baggage']` filter therefore matches nothing and offers the
* dining coach as a fare class. Mirrors the portal's own test (`/dining|dpc/i`).
*/
export const NON_FARE_COACH_TERMS = ['dining', 'dpc', 'baggage'];
export const NOT_A_FARE_CLASS = {
NOT: NON_FARE_COACH_TERMS.flatMap((term) => [
{ type: { contains: term, mode: 'insensitive' as const } },
{ code: { contains: term, mode: 'insensitive' as const } },
]),
};
/** True when this coach type is a dining/baggage coach rather than a sellable fare class. */
export function isNonFareCoachType(coachType: { type?: string | null; code?: string | null }): boolean {
const haystack = `${coachType.type ?? ''} ${coachType.code ?? ''}`.toLowerCase();
return NON_FARE_COACH_TERMS.some((t) => haystack.includes(t));
}
/**
* Nationality is not stored on the booking, so the display currency is the proxy the search and
* fare code already use: ETB/DJF are local tariffs, USD is the international one. Both flows must
* use the same proxy or an upgrade would be priced on a different tariff than the original sale.
*/
export function resolveNationalityProxy(displayCurrency?: string | null): {
nationalityType: 'LOCAL' | 'INTERNATIONAL';
nationality: string | undefined;
} {
return {
nationalityType: displayCurrency === 'USD' ? 'INTERNATIONAL' : 'LOCAL',
nationality:
displayCurrency === 'DJF' ? 'Djiboutian' : displayCurrency === 'ETB' ? 'Ethiopian' : undefined,
};
}
/**
* Mirrors SearchService's class matching: nationality filter, then bed position.
* `Seat.bedPosition` is lowercase and `SeatClass.bedPosition` uppercase, hence the folding.
*/
export function pickSeatClass(classes: any[], bedPosition: string | null, nationalityType: string) {
const byNat = classes.filter((c) => !c.nationalityType || c.nationalityType === nationalityType);
const pool = byNat.length ? byNat : classes;
const bed = bedPosition?.toLowerCase() ?? null;
const exact = pool.find((c) => (c.bedPosition?.toLowerCase() ?? null) === bed);
return exact ?? pool.find((c) => !c.bedPosition) ?? pool[0] ?? null;
}
/**
* Distributes a leg fare over seats; free children (fare 0) stay 0 and rounding lands on the last
* paid seat.
*/
export function splitFare(total: number, seats: Array<{ fareMinor: number | null }>): number[] {
const paid = seats.map((s) => s.fareMinor !== 0);
const n = paid.filter(Boolean).length || 1;
const each = Math.floor(total / n);
let remaining = total;
let lastPaid = -1;
const out = seats.map((_, i) => {
if (!paid[i]) return 0;
lastPaid = i;
remaining -= each;
return each;
});
if (lastPaid >= 0) out[lastPaid] += remaining;
return out;
}

View File

@@ -24,12 +24,25 @@ export const MIN_PAYMENT_WINDOW_MINUTES = 7;
export const PAYMENT_SETTLE_MARGIN_SECONDS = 60;
/**
* `windowMinutes` is how long the payer is given, and is configurable per flow
* (`booking_payment_window_minutes`, `reschedule_…`, `upgrade_…` in SystemConfig). It defaults to
* MAX_PAYMENT_HOURS so any caller that does not pass it behaves exactly as before.
*
* The check-in cutoff is still the hard ceiling: a longer window can never let someone pay after
* boarding has closed on their train.
*
* EVERY site that decides whether a booking is still payable — the payment link, the seat hold,
* and the crons that auto-cancel unpaid bookings — must pass the SAME window for a given booking,
* or a cron will cancel a booking whose link still says it is valid.
*/
export function computePaymentDeadline(
createdAt: Date,
departureAt: Date,
checkinMinutes: number = CUTOFF_MINUTES,
windowMinutes: number = MAX_PAYMENT_HOURS * 60,
): Date {
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const maxDeadline = new Date(createdAt.getTime() + windowMinutes * 60 * 1000);
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
}

View File

@@ -1,3 +1,4 @@
import { SystemConfigModule } from '../system-config/system-config.module';
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { AuditModule } from '../../common/audit.module';
@@ -14,7 +15,7 @@ import { PaymentsModule } from '../payments/payments.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule],
imports: [SystemConfigModule, AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]

View File

@@ -93,22 +93,105 @@ export class BookingsService {
private readonly paymentsService: PaymentsService,
) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
// An IAM user with no Passenger row is normal, not an error: a freshly registered
// account that has never booked, or a staff account. findUniqueOrThrow raised P2025
// here, which surfaced as a 500 on the portal's "My bookings" page. Empty page instead.
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
if (!passenger) {
const page = filters.page ?? 1;
const pageSize = filters.pageSize ?? 20;
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
}
return this.findByPassengerId(passenger.id, filters);
/**
* Every Booking-level condition that means "this booking belongs to the person who
* owns `variants`". Shared by findByPhone (public guest retrieval) and
* findByIamUserId (the portal's own history) so the two can never disagree about
* what a phone number owns.
*
* The IAM lookup is catch-and-warn: a phone match is a best-effort widening, and an
* unavailable IAM must not fail the whole listing.
*/
private async buildPhoneOwnershipClauses(
variants: string[],
): Promise<{ clauses: any[]; passengerIds: string[] }> {
if (variants.length === 0) return { clauses: [], passengerIds: [] };
// Authenticated-user bookings don't store contactPhone — their phone lives in
// iam.users.phone_number, linked through passenger.iamUserId.
const iamRows = await this.dataSource
.query<{ id: string }[]>(
`SELECT u.id FROM iam.users u WHERE u.phone_number = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`IAM phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { id: string }[];
});
const iamPassengerIds = iamRows.length > 0
? (await this.prisma.passenger.findMany({
where: { iamUserId: { in: iamRows.map(r => r.id) } },
select: { id: true },
})).map(p => p.id)
: [];
return {
clauses: [
{ contactPhone: { in: variants } },
{ passenger: { user: { phone: { in: variants } } } },
...(iamPassengerIds.length > 0 ? [{ passengerId: { in: iamPassengerIds } }] : []),
],
passengerIds: iamPassengerIds,
};
}
/**
* The portal's authenticated "My bookings" history (GET /bookings/my).
*
* Returns bookings made **while signed in** (they hang off the Passenger row linked
* to this IAM user) *and* bookings made as a **guest with the same phone number**.
* The second half matters: resolveGuestPassenger (guest-booking.service.ts) creates a
* fresh, unlinked `Passenger` for every guest booking and never looks the phone up, so
* a customer's guest history is scattered across orphan rows that a passengerId-only
* filter cannot see. On the dev database one account had 4 visible bookings out of 30
* carrying its own phone number.
*
* Privacy note: the widened set is exactly what `GET /bookings/by-phone` already
* returns to *anonymous* callers, so showing it to the verified owner of that number
* exposes nothing that was not already public. The phone comes from iam.users, not
* from the request.
*/
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
// An IAM user with no Passenger row is normal, not an error: a freshly registered
// account that has never booked, or a staff account. findUniqueOrThrow raised P2025
// here, which surfaced as a 500 on the portal's "My bookings" page.
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
const iamRows = await this.dataSource
.query<{ phone_number: string | null }[]>(
`SELECT phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
[iamUserId],
)
.catch((err: unknown) => {
this.logger.warn(`IAM self phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { phone_number: string | null }[];
});
const variants = normalizePhoneVariants(iamRows[0]?.phone_number ?? '');
const ownership: any[] = [
...(passenger ? [{ passengerId: passenger.id }] : []),
...(await this.buildPhoneOwnershipClauses(variants)).clauses,
];
if (ownership.length === 0) {
const page = filters.page ?? 1;
const pageSize = filters.pageSize ?? 20;
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
}
return this.findBookingsForOwner({ OR: ownership }, filters);
}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
return this.findBookingsForOwner({ passengerId }, filters);
}
/**
* One page of a customer's own bookings. `ownerClause` says whose they are (a single
* passengerId, or the OR of every phone-ownership clause) and is ANDed with the
* search / status / scope filters, so none of them can clobber another's `OR`.
*
* `scope` drives the Upcoming / Past / Cancelled tabs server-side so each tab paginates
* correctly, rather than the client filtering one page at a time. Note it filters on
* `schedule.departureAt` — the schedule's own origin departure — while each item's
@@ -116,40 +199,40 @@ export class BookingsService {
* boarding stop. They differ by the run time to that stop; that is close enough for a
* tab filter and avoids a correlated stopTimes query per row.
*/
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
private async findBookingsForOwner(ownerClause: any, filters: BookingFilters = {}) {
const { search, status, scope = 'all', page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = { passengerId };
const and: any[] = [ownerClause];
if (search) {
where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
];
and.push({
OR: [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
],
});
}
// `status` used to be forwarded raw, so an unrecognised value threw a Prisma
// validation error (a 500) rather than being ignored. Only accept real enum members.
if (status && (Object.values(BookingStatus) as string[]).includes(status)) {
where.status = status;
and.push({ status });
}
const now = new Date();
let orderBy: any = { createdAt: 'desc' };
if (scope === 'cancelled') {
where.status = { in: CLOSED_BOOKING_STATUSES };
and.push({ status: { in: CLOSED_BOOKING_STATUSES } });
} else if (scope === 'upcoming' || scope === 'past') {
// Don't clobber an explicit `status` filter — intersect with it.
if (!where.status) where.status = { notIn: CLOSED_BOOKING_STATUSES };
where.schedule = {
...(where.schedule ?? {}),
departureAt: scope === 'upcoming' ? { gte: now } : { lt: now },
};
and.push({ status: { notIn: CLOSED_BOOKING_STATUSES } });
and.push({ schedule: { departureAt: scope === 'upcoming' ? { gte: now } : { lt: now } } });
orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } };
}
const where: any = { AND: and };
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
@@ -227,68 +310,11 @@ export class BookingsService {
const { status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
// Authenticated-user bookings don't store contactPhone — their phone lives in
// iam.users.phone_number linked via passenger.iamUserId. Mirror the same lookup
// that findAll uses for the search field.
const iamRows = await this.dataSource
.query<{ id: string }[]>(
`SELECT u.id FROM iam.users u WHERE u.phone_number = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`IAM phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { id: string }[];
});
// Same ownership resolution the authenticated history uses, so a customer sees the
// same set here and on "My bookings".
const ownership = await this.buildPhoneOwnershipClauses(variants);
const iamPassengerIds = iamRows.length > 0
? (await this.prisma.passenger.findMany({
where: { iamUserId: { in: iamRows.map(r => r.id) } },
select: { id: true },
})).map(p => p.id)
: [];
// Guest bookings store phone in TravelerProfile.notes JSON (created for every guest booking).
// This catches cases where contactPhone was null but the phone was still recorded in the profile.
const travelerRows = await this.dataSource
.query<{ passengerId: string }[]>(
`SELECT DISTINCT passenger_id AS "passengerId"
FROM passenger.traveler_profiles
WHERE notes IS NOT NULL
AND (notes::jsonb->>'phone') = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`TravelerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { passengerId: string }[];
});
const travelerPassengerIds = travelerRows.map(r => r.passengerId);
// Guests who saved their profile (savePassengerDetails:true) have a SavedPassengerProfile
// row with phone + deviceId. Guest bookings store the deviceId in Booking.userAgent.
const savedProfileRows = await this.dataSource
.query<{ deviceId: string }[]>(
`SELECT DISTINCT device_id AS "deviceId"
FROM passenger.saved_passenger_profiles
WHERE phone = ANY($1::text[]) AND device_id IS NOT NULL`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`SavedPassengerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { deviceId: string }[];
});
const guestDeviceIds = savedProfileRows.map(r => r.deviceId);
// Merge all passenger IDs from every source
const allPassengerIds = [...new Set([...iamPassengerIds, ...travelerPassengerIds])];
const where: any = {
OR: [
{ contactPhone: { in: variants } },
{ passenger: { user: { phone: { in: variants } } } },
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
...(guestDeviceIds.length > 0 ? [{ userAgent: { in: guestDeviceIds } }] : []),
],
};
const where: any = { OR: ownership.clauses };
if (status) where.status = status;
// PackageBooking is a separate table with its own contactPhone field —
@@ -296,7 +322,7 @@ export class BookingsService {
const pkgWhere: any = {
OR: [
{ contactPhone: { in: variants } },
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
...(ownership.passengerIds.length > 0 ? [{ passengerId: { in: ownership.passengerIds } }] : []),
],
};
if (status) pkgWhere.status = status;

View File

@@ -16,6 +16,7 @@ import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
import { JourneyDirection } from '../seats/seats.dto';
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { randomUUID } from 'crypto';
@@ -90,6 +91,7 @@ export class GuestBookingService {
private readonly logger = new Logger(GuestBookingService.name);
constructor(
private systemConfig: SystemConfigService,
private prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
private seatsService: SeatsService,
@@ -582,7 +584,10 @@ export class GuestBookingService {
const { guestPassengerId } = await this.resolveGuestPassenger({}, passengerData);
const payToken = isStaff ? undefined : randomUUID();
const payTokenExpiresAt = isStaff ? undefined : computePaymentDeadline(new Date(), schedule.departureAt);
const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
const payTokenExpiresAt = isStaff
? undefined
: computePaymentDeadline(new Date(), schedule.departureAt, undefined, bookingWindowMinutes);
const booking = await this.prisma.booking.create({
data: {

View File

@@ -0,0 +1,141 @@
import { NotificationsService } from './notifications.service';
/**
* Regression cover for the bug these handlers were written to fix: reschedule/upgrade
* notifications resolved an address only through `iam.users`, where `Passenger.iamUserId` is set
* on under 2% of rows, so EMAIL and SMS were silently skipped on virtually every real booking.
* The handlers must fall back to the contact details the booking itself carries.
*/
describe('booking-change notifications', () => {
const BOOKING = {
id: 'bk-1',
bookingRef: 'NFMRR0',
passengerId: 'pax-1',
bookingType: 'ONE_WAY',
contactPhone: '+251923594242',
contactEmail: 'work.abubeker@gmail.com',
originStationId: 'st-a',
destinationStationId: 'st-b',
schedule: {
departureAt: new Date('2026-09-22T09:00:00Z'),
arrivalAt: new Date('2026-09-22T18:00:00Z'),
originStation: { id: 'st-a', name: 'Sebeta' },
destinationStation: { id: 'st-b', name: 'Dire Dawa' },
stopTimes: [],
},
seats: [
{
leg: 1,
passengerName: 'Abubeker Yasin',
seat: { seatNumber: '3', coach: { number: 'VIP-0001', coachType: { name: 'VIP Seat' } } },
},
],
};
const TEMPLATE = {
code: 'booking.upgraded',
subject: 'Fare Class Upgraded',
bodyTemplate:
'Dear {{passengerName}},\n{{bookingRef}} {{origin}} → {{destination}}\n{{changeLines}}\nPaid: {{amountPaid}} {{currency}}\n{{detailLink}}',
active: true,
};
function build(opts: { iamAddress?: string | null; template?: any; booking?: any } = {}) {
const sms = jest.fn().mockResolvedValue({ queued: true });
const email = jest.fn().mockResolvedValue({ queued: true });
const svc: any = Object.create(NotificationsService.prototype);
svc.prisma = {
booking: { findUnique: jest.fn().mockResolvedValue(opts.booking === undefined ? BOOKING : opts.booking) },
notificationTemplate: {
findUnique: jest.fn().mockResolvedValue(opts.template === undefined ? TEMPLATE : opts.template),
},
};
svc.smsClient = { sendSms: sms };
svc.emailClient = { sendEmail: email };
svc.logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };
// The live condition: IAM knows nothing about this passenger.
svc.getRecipientAddress = jest.fn().mockResolvedValue(opts.iamAddress ?? null);
svc.createInAppNotification = jest.fn().mockResolvedValue(undefined);
return { svc, sms, email };
}
const upgradePayload = {
booking: { id: 'bk-1' },
upgrade: {
leg: 1,
feeMinor: 0,
fareDifferenceMinor: 70000,
items: [
{ passengerName: 'Abubeker Yasin', oldSeatLabel: 'RS-0002 seat 5', newSeatLabel: 'VIP-0001 seat 3' },
],
},
};
it('sends SMS and email via the booking contacts when IAM resolves nothing', async () => {
const { svc, sms, email } = build();
await svc.onBookingUpgraded(upgradePayload);
expect(sms).toHaveBeenCalledTimes(1);
expect(sms.mock.calls[0][0].to).toBe('+251923594242');
expect(email).toHaveBeenCalledTimes(1);
expect(email.mock.calls[0][0].to).toBe('work.abubeker@gmail.com');
expect(email.mock.calls[0][0].subject).toBe('Fare Class Upgraded');
});
it('renders the old → new seat line and the amount paid', async () => {
const { svc, sms } = build();
await svc.onBookingUpgraded(upgradePayload);
const body = sms.mock.calls[0][0].message;
expect(body).toContain('Abubeker Yasin: RS-0002 seat 5 → VIP-0001 seat 3');
expect(body).toContain('Paid: 700.00 ETB');
expect(body).toContain('Sebeta → Dire Dawa');
expect(body).not.toContain('{{'); // every placeholder interpolated
});
it('prefers the IAM address when there is one', async () => {
const { svc, sms } = build({ iamAddress: '+251900000000' });
await svc.onBookingUpgraded(upgradePayload);
expect(sms.mock.calls[0][0].to).toBe('+251900000000');
});
it('a failing SMS gateway does not suppress the email', async () => {
const { svc, sms, email } = build();
sms.mockRejectedValue(new Error('gateway down'));
await svc.onBookingUpgraded(upgradePayload);
expect(email).toHaveBeenCalledTimes(1);
expect(svc.logger.warn).toHaveBeenCalled();
});
it('sends nothing and does not throw when the booking has no contacts', async () => {
const { svc, sms, email } = build({ booking: { ...BOOKING, contactPhone: null, contactEmail: null } });
await expect(svc.onBookingUpgraded(upgradePayload)).resolves.toBeUndefined();
expect(sms).not.toHaveBeenCalled();
expect(email).not.toHaveBeenCalled();
});
it('a missing template is logged, not thrown', async () => {
const { svc, sms } = build({ template: null });
await expect(svc.onBookingUpgraded(upgradePayload)).resolves.toBeUndefined();
expect(sms).not.toHaveBeenCalled();
expect(svc.logger.warn).toHaveBeenCalledWith(expect.stringContaining('not found or inactive'));
});
it('expiry notification sends and never throws', async () => {
const { svc, sms, email } = build({
template: { ...TEMPLATE, code: 'booking.upgrade.expired', subject: 'Upgrade Request Expired' },
});
await svc.onUpgradeExpired({
bookingId: 'bk-1',
request: { leg: 1, amountDueMinor: 70000, items: upgradePayload.upgrade.items },
});
expect(sms).toHaveBeenCalledTimes(1);
expect(email.mock.calls[0][0].subject).toBe('Upgrade Request Expired');
});
it('a booking that vanished is logged, not thrown', async () => {
const { svc, sms } = build({ booking: null });
await expect(svc.onUpgradeExpired({ bookingId: 'gone', request: {} })).resolves.toBeUndefined();
expect(sms).not.toHaveBeenCalled();
});
});

View File

@@ -148,6 +148,180 @@ export class NotificationsService {
});
}
// ── Booking-change notification helpers ──────────────────────────────────
/** Relations the change templates render: station names, coach number and coach-type name. */
private static readonly CHANGE_INCLUDE = {
schedule: {
include: {
originStation: true,
destinationStation: true,
train: true,
stopTimes: { include: { station: true } },
},
},
seats: {
include: { seat: { include: { coach: { include: { coachType: true } } } } },
orderBy: { leg: 'asc' as const },
},
};
private fmtDate(d: any): string {
return d
? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
: 'TBD';
}
private fmtTime(d: any): string {
return d
? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
: 'TBD';
}
/** Minor units → major, 2dp. Charges are raised in ETB, so no conversion applies. */
private fmtMinor(minor: number): string {
return ((minor ?? 0) / 100).toFixed(2);
}
/**
* "Abubeker Yasin: RS-0002 seat 5 → VIP-0001 seat 3", one line per upgraded passenger.
* Labels come off `BookingUpgrade.items`, which snapshots them at quote time — so the message
* still reads correctly even after the seats have moved.
*/
private buildUpgradeChangeLines(items: any[]): string {
return (items ?? [])
.map((i) => {
const who = String(i?.passengerName ?? '').trim();
const from = String(i?.oldSeatLabel ?? '').trim() || 'previous seat';
const to = String(i?.newSeatLabel ?? '').trim() || 'new seat';
return `${who ? `${who}: ` : ''}${from}${to}`;
})
.join('\n');
}
/**
* Delivery addresses for a booking-change message. IAM first so a registered passenger's
* current details win, then the contact the booking itself carries — which is the only address
* a guest booking ever has. Mirrors the `iamPhone ?? contactPhone` fallback that
* `onBookingCreated` and `onPaymentSucceeded` already use.
*/
private async resolveDeliveryContacts(
booking: any,
passengerId: string | null,
): Promise<{ phone: string | null; email: string | null }> {
const iamPhone = passengerId
? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null)
: null;
const iamEmail = passengerId
? await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null)
: null;
return {
phone: iamPhone ?? booking?.contactPhone ?? null,
email: iamEmail ?? booking?.contactEmail ?? null,
};
}
/** Shared context for every change template: who, where, when, which seats. */
private buildBookingChangeContext(booking: any, ref: string): Record<string, unknown> {
const { passengerName, trainSeatLines } = buildSeatSummary(
booking?.seats ?? [],
booking?.bookingType,
);
const segment = resolveBookingSegment(
booking?.schedule ?? {},
booking?.originStationId,
booking?.destinationStationId,
);
return {
passengerName,
bookingRef: ref,
origin: segment.origin?.name ?? '',
destination: segment.destination?.name ?? '',
trainSeatLines,
travelDate: this.fmtDate(segment.departureAt),
departureTime: this.fmtTime(segment.departureAt),
arrivalTime: this.fmtTime(segment.arrivalAt),
currency: 'ETB',
detailLink: `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`,
};
}
/**
* Re-fetch → interpolate → in-app + direct SMS/email.
*
* The event payload is not enough on its own: the reschedule/upgrade services' own
* `bookingInclude` selects `coach: { select: { id, coachTypeId } }` and no station names, so
* buildSeatSummary would render "-, seat no. N". Always read the booking back with
* CHANGE_INCLUDE.
*
* Every failure here is logged and swallowed — a notification must never take down the cron or
* the event emitter that invoked it, and the reschedule/upgrade itself is already committed.
*/
private async notifyBookingChange(
templateCode: string,
bookingId: string,
extra: Record<string, unknown>,
): Promise<void> {
try {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: NotificationsService.CHANGE_INCLUDE as any,
});
if (!booking) {
this.logger.warn(`${templateCode}: booking ${bookingId} not found — nothing sent`);
return;
}
const template = await this.prisma.notificationTemplate.findUnique({
where: { code: templateCode },
});
if (!template || !template.active) {
this.logger.warn(`Template ${templateCode} not found or inactive`);
return;
}
const ref = (booking as any).bookingRef;
const context = { ...this.buildBookingChangeContext(booking, ref), ...extra };
const { subject, body } = this.interpolate(template, context);
const passengerId = (booking as any).passengerId ?? null;
if (passengerId) {
await this.createInAppNotification(passengerId, subject, body, {
category: 'BOOKING',
deepLink: `edr://bookings/${ref}`,
}).catch((err) =>
this.logger.warn(`${templateCode}: in-app notification failed for ${ref}: ${err}`),
);
}
const { phone, email } = await this.resolveDeliveryContacts(booking, passengerId);
if (!phone && !email) {
this.logger.warn(`${templateCode}: no contact details for booking ${ref} — nothing sent`);
return;
}
// Independent try/catch per channel: a dead SMS gateway must not cost the passenger
// their email too.
if (phone) {
try {
await this.smsClient.sendSms({ to: phone, message: body });
} catch (err) {
this.logger.warn(`${templateCode}: SMS failed for booking ${ref}: ${err}`);
}
}
if (email) {
try {
await this.emailClient.sendEmail({ to: email, subject, text: body });
} catch (err) {
this.logger.warn(`${templateCode}: email failed for booking ${ref}: ${err}`);
}
}
} catch (err) {
this.logger.error(`${templateCode}: notification failed for booking ${bookingId}: ${err}`);
}
}
private interpolate(
template: { subject?: string | null; bodyTemplate: string },
context: Record<string, unknown>,
@@ -731,22 +905,68 @@ export class NotificationsService {
);
}
/**
* Booking-change notifications (reschedule / upgrade, applied or expired).
*
* These deliberately do NOT go through `send()`. That path resolves an address only via
* `iam.users`, and `Passenger.iamUserId` is set on well under 2% of rows (and can dangle even
* when set), so EMAIL and SMS were silently skipped for almost every real booking while only the
* in-app row was written. They follow `onBookingCreated` instead: re-fetch, interpolate the
* template, then deliver straight to the booking's own contact details.
*/
@OnEvent('booking.rescheduled')
async onBookingRescheduled(payload: any) {
const { booking, reschedule } = payload;
await this.send(
'booking.rescheduled',
booking.passengerId,
{
bookingRef: booking.bookingRef,
leg: reschedule?.leg === 2 ? 'return' : 'outbound',
feeAmount: ((reschedule?.feeMinor ?? 0) / 100).toFixed(2),
currency: 'ETB',
category: 'BOOKING',
deepLink: `edr://bookings/${booking.bookingRef}`,
},
['IN_APP', 'EMAIL', 'SMS'],
);
let previousTravelDate = '';
if (reschedule?.oldScheduleId) {
const old = await this.prisma.trainSchedule
.findUnique({ where: { id: reschedule.oldScheduleId }, select: { departureAt: true } })
.catch(() => null);
// Pre-formatted so the template never renders a dangling 'Previously:' label.
previousTravelDate = old?.departureAt ? `Previously: ${this.fmtDate(old.departureAt)}
` : '';
}
await this.notifyBookingChange('booking.rescheduled', booking.id, {
leg: reschedule?.leg === 2 ? 'return' : 'outbound',
previousLine: previousTravelDate,
feeAmount: this.fmtMinor(reschedule?.feeMinor ?? 0),
amountPaid: this.fmtMinor(
(reschedule?.feeMinor ?? 0) + Math.max(0, reschedule?.fareDifferenceMinor ?? 0),
),
});
}
@OnEvent('booking.upgraded')
async onBookingUpgraded(payload: any) {
const { booking, upgrade } = payload;
const items = Array.isArray(upgrade?.items) ? upgrade.items : [];
await this.notifyBookingChange('booking.upgraded', booking.id, {
leg: upgrade?.leg === 2 ? 'return' : 'outbound',
passengerSummary: items.map((i: any) => i.passengerName).filter(Boolean).join(', '),
changeLines: this.buildUpgradeChangeLines(items),
amountPaid: this.fmtMinor(
(upgrade?.feeMinor ?? 0) + Math.max(0, upgrade?.fareDifferenceMinor ?? 0),
),
});
}
@OnEvent('booking.reschedule.expired')
async onRescheduleExpired(payload: any) {
await this.notifyBookingChange('booking.reschedule.expired', payload.bookingId, {
leg: payload.request?.leg === 2 ? 'return' : 'outbound',
amountDue: this.fmtMinor(payload.request?.amountDueMinor ?? 0),
});
}
@OnEvent('booking.upgrade.expired')
async onUpgradeExpired(payload: any) {
const items = Array.isArray(payload.request?.items) ? payload.request.items : [];
await this.notifyBookingChange('booking.upgrade.expired', payload.bookingId, {
leg: payload.request?.leg === 2 ? 'return' : 'outbound',
passengerSummary: items.map((i: any) => i.passengerName).filter(Boolean).join(', '),
changeLines: this.buildUpgradeChangeLines(items),
amountDue: this.fmtMinor(payload.request?.amountDueMinor ?? 0),
});
}
@OnEvent('booking.cancelled')

View File

@@ -1,3 +1,4 @@
import { SystemConfigModule } from '../system-config/system-config.module';
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
@@ -55,6 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
@Module({
imports: [
SystemConfigModule,
SeatsModule,
TicketsModule,
CurrencyModule,

View File

@@ -1,5 +1,6 @@
import { Test, TestingModule } from "@nestjs/testing";
import { PaymentsService } from "./payments.service";
import { SystemConfigService } from "../system-config/system-config.service";
import { PaymentClientService } from "./payment-client.service";
import { CurrencyService } from "../currency/currency.service";
import { PrismaService } from "../../common/prisma.service";
@@ -133,6 +134,8 @@ describe("PaymentsService", () => {
{ provide: PaymentClientService, useValue: mockPaymentClient },
{ provide: CurrencyService, useValue: mockCurrencyService },
{ provide: AuditService, useValue: { log: jest.fn() } },
// 120 = the default booking payment window; the deadline maths under test is unchanged by it.
{ provide: SystemConfigService, useValue: { getNumber: jest.fn().mockResolvedValue(120) } },
],
}).compile();

View File

@@ -36,6 +36,7 @@ import {
MIN_PAYMENT_WINDOW_MINUTES,
PAYMENT_SETTLE_MARGIN_SECONDS,
} from "../../common/utils/payment-deadline.utils";
import { CONFIG_KEYS, SystemConfigService } from "../system-config/system-config.service";
import {
PaymentClientService,
PaymentDiagnostic,
@@ -88,6 +89,7 @@ export class PaymentsService {
private readonly waafiDemoTrustReturn = true;
constructor(
private systemConfig: SystemConfigService,
private prisma: PrismaService,
private seatsService: SeatsService,
private ticketsService: TicketsService,
@@ -748,7 +750,10 @@ export class PaymentsService {
originRouteStop?.checkinMinutesBefore ??
booking.schedule.route?.checkinMinutesBefore ??
undefined;
return computePaymentDeadline(booking.createdAt, dep, checkinMinutes);
// Same window the auto-cancel cron uses, or the payer would be shown a deadline the cron
// does not honour.
const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
return computePaymentDeadline(booking.createdAt, dep, checkinMinutes, windowMinutes);
}
private resolveReturnUrls(

View File

@@ -7,6 +7,7 @@ import { SeatsModule } from '../seats/seats.module';
import { TicketsModule } from '../tickets/tickets.module';
import { PaymentsModule } from '../payments/payments.module';
import { CurrencyModule } from '../currency/currency.module';
import { SystemConfigModule } from '../system-config/system-config.module';
import { RescheduleController } from './reschedule.controller';
import { RescheduleService, SUPPLEMENTARY_CHARGE_PAID_EVENT } from './reschedule.service';
@@ -32,7 +33,7 @@ export class RescheduleEventsListener {
}
@Module({
imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule],
imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule, SystemConfigModule],
controllers: [RescheduleController],
providers: [RescheduleService, RescheduleEventsListener],
exports: [RescheduleService],

View File

@@ -13,6 +13,7 @@ import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { MeLikeUser } from '../../common/passenger-permission.util';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service';
import { normalizePhone, samePhone } from '../../common/utils/phone.utils';
import { BookingsService } from '../bookings/bookings.service';
import { SeatsService } from '../seats/seats.service';
@@ -85,6 +86,8 @@ type LegView = {
departureAt: Date;
seats: Array<{ id: string; seatId: string; passengerName: string; fareMinor: number | null; passengerCategory: string }>;
coachTypeId: string;
/** Every distinct coach type on the leg. More than one means a partial upgrade happened. */
coachTypeIds: string[];
};
// Seats are ordered by passenger name so getOptions(), quote() and create() all see the same
@@ -109,6 +112,7 @@ export class RescheduleService {
private currencyService: CurrencyService,
private auditService: AuditService,
private eventEmitter: EventEmitter2,
private systemConfig: SystemConfigService,
) {}
// ── Policy admin ─────────────────────────────────────────────────────────
@@ -267,7 +271,8 @@ export class RescheduleService {
const requestedBy = user.id ?? user.sub ?? booking.passengerId;
const newDeparture = q.newDepartureAt;
const expiresAt = computePaymentDeadline(new Date(), newDeparture);
const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.RESCHEDULE_PAYMENT_WINDOW_MINUTES);
const expiresAt = computePaymentDeadline(new Date(), newDeparture, undefined, windowMinutes);
const reschedule = await this.prisma.bookingReschedule.create({
data: {
@@ -314,7 +319,8 @@ export class RescheduleService {
where: { id: reschedule.id },
data: { supplementaryChargeId: charge.id },
});
await this.seatsService.confirmSeats(dto.newSeatIds);
// Same instant the charge carries, so the hold and the payment link die together.
await this.seatsService.confirmSeats(dto.newSeatIds, new Date(), expiresAt);
await this.auditService.log({
userId: requestedBy,
@@ -427,7 +433,7 @@ export class RescheduleService {
async expireStale(now = new Date()): Promise<number> {
const stale = await this.prisma.bookingReschedule.findMany({
where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
select: { id: true, supplementaryChargeId: true },
select: { id: true, supplementaryChargeId: true, holdId: true, bookingId: true, leg: true, amountDueMinor: true },
});
for (const r of stale) {
await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } });
@@ -437,7 +443,22 @@ export class RescheduleService {
data: { status: 'EXPIRED' },
});
}
// Release the seat the instant the request dies instead of leaving it to the hold's own
// TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at
// creation, and nothing keeps them in step afterwards — so without this the seat can sit
// unsellable long after the link that pays for it has expired. deleteMany: an already-swept
// hold must not throw.
if (r.holdId) {
await this.prisma.seatHold.deleteMany({ where: { id: r.holdId } });
}
}
// After the loop on purpose: the rows are already committed, so a notification failure
// cannot leave a request half-expired. Fire-and-forget — the listener swallows its own errors.
for (const s of stale) {
this.eventEmitter.emit('booking.reschedule.expired', { bookingId: s.bookingId, request: s });
}
return stale.length;
}
@@ -497,11 +518,11 @@ export class RescheduleService {
.map((s) => ({ id: s.id, seatId: s.seatId, passengerName: s.passengerName, fareMinor: s.fareMinor, passengerCategory: s.passengerCategory, coachTypeId: s.seat?.coach?.coachTypeId }));
const l1 = seatsOf(1);
if (l1.length && booking.schedule) {
legs.push({ leg: 1, scheduleId: booking.scheduleId, originStationId: booking.originStationId, destinationStationId: booking.destinationStationId, departureAt: booking.schedule.departureAt, seats: l1, coachTypeId: l1[0].coachTypeId });
legs.push({ leg: 1, scheduleId: booking.scheduleId, originStationId: booking.originStationId, destinationStationId: booking.destinationStationId, departureAt: booking.schedule.departureAt, seats: l1, coachTypeId: l1[0].coachTypeId, coachTypeIds: [...new Set(l1.map((s) => s.coachTypeId))] });
}
const l2 = seatsOf(2);
if (booking.bookingType === 'ROUND_TRIP' && l2.length && booking.returnSchedule) {
legs.push({ leg: 2, scheduleId: booking.returnScheduleId, originStationId: booking.returnOriginStationId, destinationStationId: booking.returnDestinationStationId, departureAt: booking.returnSchedule.departureAt, seats: l2, coachTypeId: l2[0].coachTypeId });
legs.push({ leg: 2, scheduleId: booking.returnScheduleId, originStationId: booking.returnOriginStationId, destinationStationId: booking.returnDestinationStationId, departureAt: booking.returnSchedule.departureAt, seats: l2, coachTypeId: l2[0].coachTypeId, coachTypeIds: [...new Set(l2.map((s) => s.coachTypeId))] });
}
return legs;
}
@@ -521,6 +542,13 @@ export class RescheduleService {
// round trip whose outbound was already used can't change its return yet — needs leg-scoped
// ticket regeneration.
if (booking.outboundBoardedAt || booking.returnBoardedAt) blockers.push('This booking has already been used for travel.');
// A partial fare-class upgrade can leave one leg spanning two coach types. Everything below
// — the policy lookup, the fee, the seat map — keys off a single leg-wide class taken from
// the first seat, so a mixed leg would silently reschedule at the wrong class and price.
// Refuse it outright until reschedule is made class-aware per passenger.
if (leg.coachTypeIds.length > 1) {
blockers.push('This booking has passengers in different fare classes. Please contact support to change it.');
}
if (!policy || !policy.isActive) blockers.push('Rescheduling is not available for this fare class.');
else if (leg.departureAt.getTime() - now.getTime() < policy.cutoffMinutes * 60_000) {
blockers.push(`Changes must be made at least ${policy.cutoffMinutes} minutes before departure.`);
@@ -537,6 +565,10 @@ export class RescheduleService {
const pending = await this.prisma.bookingReschedule.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } });
if (pending) blockers.push('A reschedule is already awaiting payment for this booking.');
// One change at a time. Two live supplementary charges would both drive ticket regeneration
// on this booking and interleave unpredictably once each is paid.
const pendingUpgrade = await this.prisma.bookingUpgrade.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } });
if (pendingUpgrade) blockers.push('An upgrade is awaiting payment for this booking — finish or cancel it first.');
if (dto.newSeatIds.length !== leg.seats.length) blockers.push(`Select exactly ${leg.seats.length} seat(s).`);
if (new Set(dto.newSeatIds).size !== dto.newSeatIds.length) blockers.push('Duplicate seats selected.');

View File

@@ -701,7 +701,14 @@ export class SeatsService {
// short seat-selection hold (5 min by default). Without this, the hold could expire
// while the customer was still on the payment page, and a second customer could
// hold/book the exact same seat out from under them.
async confirmSeats(seatIds: string[], now: Date = new Date()): Promise<void> {
/**
* `deadlineOverride` pins the hold to a deadline the caller has already computed. The
* reschedule and upgrade flows pass the exact value their supplementary charge carries — if
* this recomputed it instead, a per-flow payment window would give the hold and the payment
* link different lifetimes and the seat could lapse while the link still worked.
* Without it, the booking payment window is used, as before.
*/
async confirmSeats(seatIds: string[], now: Date = new Date(), deadlineOverride?: Date): Promise<void> {
if (seatIds.length === 0) return;
const holds = await this.prisma.seatHold.findMany({
@@ -717,24 +724,46 @@ export class SeatsService {
});
const departureById = new Map(schedules.map(s => [s.id, s.departureAt]));
let extended = 0;
const windowMinutes = deadlineOverride
? 0 // unused — the override wins below
: await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
let aligned = 0;
await Promise.all(
holds.map(async (hold) => {
const departureAt = departureById.get(hold.scheduleId);
if (!departureAt) return;
const deadline = computePaymentDeadline(now, departureAt);
// Only ever extend forward — never shorten a hold that's already valid longer
// than the payment deadline would give it (e.g. a second confirmSeats call on
// the same booking, or a hold that was already extended).
if (deadlineOverride) {
// Authoritative in BOTH directions. The caller already issued a payment link with this
// exact deadline, so the hold must match it — including when it is EARLIER than the
// hold's own TTL. Extending only would leave the seat held after the link that pays for
// it has died (reachable whenever a flow's payment window is shorter than
// seat_hold_duration_minutes), so the seat sits unsellable in between.
if (hold.expiresAt.getTime() === deadlineOverride.getTime()) return;
await this.prisma.seatHold.update({
where: { id: hold.id },
data: { expiresAt: deadlineOverride },
});
aligned++;
return;
}
const deadline = computePaymentDeadline(now, departureAt, undefined, windowMinutes);
// Normal booking path: only ever extend forward — never shorten a hold that's already
// valid longer than the payment deadline would give it. A round trip calls confirmSeats
// up to four times, and a later call must not pull in a hold an earlier one set.
if (deadline <= hold.expiresAt) return;
await this.prisma.seatHold.update({ where: { id: hold.id }, data: { expiresAt: deadline } });
extended++;
aligned++;
}),
);
if (extended > 0) {
if (aligned > 0) {
this.logger.log(
`Extended ${extended} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
deadlineOverride
? `Aligned ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their charge's payment deadline`
: `Extended ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
);
}
}

View File

@@ -1,6 +1,7 @@
import { IsInt, IsOptional, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { MIN_PAYMENT_WINDOW_MINUTES } from '../../common/utils/payment-deadline.utils';
/**
* Whitelisted, typed body for `PATCH /config`. Config is persisted as string key/values, but every
@@ -22,6 +23,23 @@ export class UpdateSystemConfigDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
boarding_window_hours_before_departure?: number;
// Payment windows, in minutes. Floored at MIN_PAYMENT_WINDOW_MINUTES (7) because canOpenPaymentSession
// refuses to open a provider session with less than that left — a window below it makes every
// card/HPP payment impossible to start. Capped at 1440 (24h) — the check-in cutoff already bounds the
// effective deadline, but a stray 100000 would make the auto-cancel pre-filter scan pointlessly
// far back.
@ApiPropertyOptional({ example: 120, description: 'Minutes a new booking has to be paid (7..1440)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440)
booking_payment_window_minutes?: number;
@ApiPropertyOptional({ example: 120, description: 'Minutes a reschedule charge has to be paid (7..1440)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440)
reschedule_payment_window_minutes?: number;
@ApiPropertyOptional({ example: 120, description: 'Minutes a fare upgrade has to be paid (7..1440)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440)
upgrade_payment_window_minutes?: number;
@ApiPropertyOptional({ example: 5 })
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
throttle_auth_limit?: number;

View File

@@ -5,6 +5,12 @@ export const CONFIG_KEYS = {
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure',
BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE: 'boarding_window_hours_before_departure',
// How long a passenger has to pay, per flow. The effective deadline is always
// MIN(now + window, departure - check-in cutoff) — a longer window can never let someone pay
// after boarding closes.
BOOKING_PAYMENT_WINDOW_MINUTES: 'booking_payment_window_minutes',
RESCHEDULE_PAYMENT_WINDOW_MINUTES: 'reschedule_payment_window_minutes',
UPGRADE_PAYMENT_WINDOW_MINUTES: 'upgrade_payment_window_minutes',
THROTTLE_AUTH_LIMIT: 'throttle_auth_limit',
THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms',
THROTTLE_STRICT_LIMIT: 'throttle_strict_limit',
@@ -17,6 +23,10 @@ const DEFAULTS: Record<string, string> = {
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2',
[CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE]: '4',
// 120 = the 2 hours these flows used before the window became configurable.
[CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES]: '120',
[CONFIG_KEYS.RESCHEDULE_PAYMENT_WINDOW_MINUTES]: '120',
[CONFIG_KEYS.UPGRADE_PAYMENT_WINDOW_MINUTES]: '120',
[CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: '5',
[CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000',
[CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20',

View File

@@ -3,10 +3,11 @@ import { PrismaModule } from '../../common/prisma.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { CurrencyModule } from '../currency/currency.module';
import { PaymentsModule } from '../payments/payments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
import { TasksService } from './tasks.service';
@Module({
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule, SystemConfigModule],
providers: [TasksService],
})
export class TasksModule {}

View File

@@ -6,6 +6,8 @@ import { SmsClientService } from '../notifications/sms-client.service';
import { CurrencyService } from '../currency/currency.service';
import { PaymentsService } from '../payments/payments.service';
import { RescheduleService } from '../reschedule/reschedule.service';
import { UpgradeService } from '../upgrade/upgrade.service';
import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service';
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
// Retention windows
@@ -44,6 +46,8 @@ export class TasksService {
// REQUEST), and injecting a request-scoped provider here would make TasksService request-scoped
// too — which silently stops all its @Cron methods from firing. Resolve it per-tick instead.
private readonly moduleRef: ModuleRef,
// Singleton (only injects Prisma), so it does not drag request scope in and silence the crons.
private readonly systemConfig: SystemConfigService,
) {}
// ─────────────────────────────────────────────────────────────────────────
@@ -177,6 +181,7 @@ export class TasksService {
// ── Send reminder at the midpoint of each booking's payment window ────────
private async sendPaymentReminders(now: Date) {
const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
// Only look at bookings created within the last 3 h with a future departure.
const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000);
@@ -216,7 +221,7 @@ export class TasksService {
);
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
if (dep <= now) continue; // segment has already departed; cancel job handles clean-up
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes, bookingWindowMinutes);
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
@@ -260,7 +265,10 @@ export class TasksService {
// ── Cancel bookings whose payment deadline has passed ─────────────────────
private async cancelExpiredPendingBookings(now: Date) {
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
// Must be the SAME window the payment link was issued with, or a shortened window would
// leave older bookings unselected by the pre-filter and never auto-cancelled.
const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
const windowAgo = new Date(now.getTime() - bookingWindowMinutes * 60 * 1000);
// The departure pre-filter below is a query-scoping optimization only — the real
// deadline check happens per-row further down. It must be widened to the largest
@@ -286,7 +294,7 @@ export class TasksService {
where: {
status: 'PENDING_PAYMENT',
OR: [
{ createdAt: { lte: twoHoursAgo } },
{ createdAt: { lte: windowAgo } },
{ schedule: { departureAt: { lte: departureCutoff } } },
],
},
@@ -329,7 +337,7 @@ export class TasksService {
(s: any) => s.stationId === (booking as any).originStationId,
);
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes, bookingWindowMinutes);
if (now < paymentDeadline) continue;
// Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded
@@ -526,6 +534,22 @@ export class TasksService {
}
}
// ─────────────────────────────────────────────────────────────────────────
// Every 1 min: fare-class upgrades whose payment deadline passed → EXPIRED.
// Separate from the reschedule sweep on purpose — a failure in one must not
// skip the other.
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/1 * * * *')
async expireStaleUpgrades() {
try {
const upgrade = await this.moduleRef.resolve(UpgradeService, undefined, { strict: false });
const n = await upgrade.expireStale();
if (n > 0) this.logger.log(`Expired ${n} unpaid upgrade request(s)`);
} catch (err) {
this.logger.error(`expireStaleUpgrades failed: ${err instanceof Error ? err.message : err}`);
}
}
@Cron('0 2 * * *')
async purgeExpiredData() {
const now = new Date();

View File

@@ -0,0 +1,91 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { UpgradeService } from './upgrade.service';
import {
CreateUpgradeDto,
CreateUpgradePolicyDto,
UpgradeHoldDto,
UpgradeQuoteDto,
UpdateUpgradePolicyDto,
} from './upgrade.dto';
@ApiTags('Fare upgrade')
@Controller()
export class UpgradeController {
constructor(private service: UpgradeService) {}
@Get('upgrade/policies')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' })
listPolicies() {
return this.service.listPolicies();
}
@Get('upgrade/policies/available-coach-types')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' })
listUnconfiguredCoachTypes() {
return this.service.listUnconfiguredCoachTypes();
}
@Post('upgrade/policies')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' })
createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) {
return this.service.createPolicy(dto, req.user?.id);
}
@Patch('upgrade/policies/:coachTypeId')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' })
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) {
return this.service.updatePolicy(coachTypeId, dto, req.user?.id);
}
@Delete('upgrade/policies/:coachTypeId')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' })
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {
return this.service.deletePolicy(coachTypeId, req.user?.id);
}
@Get('bookings/:bookingRef/upgrade')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Per-leg upgrade eligibility, per-passenger targets, pending request and history' })
options(@Req() req: any, @Param('bookingRef') bookingRef: string) {
return this.service.getOptions(bookingRef, req.user);
}
@Post('bookings/:bookingRef/upgrade/quote')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Price an upgrade without committing to it' })
quote(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: UpgradeQuoteDto) {
return this.service.quote(bookingRef, dto, req.user);
}
@Post('bookings/:bookingRef/upgrade/hold')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Hold the chosen seats, clearing abandoned attempts on this booking first' })
hold(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: UpgradeHoldDto) {
return this.service.holdForUpgrade(bookingRef, dto, req.user);
}
@Post('bookings/:bookingRef/upgrade')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Request an upgrade; returns a payment token when money is owed' })
create(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: CreateUpgradeDto) {
return this.service.create(bookingRef, dto, req.user);
}
}

View File

@@ -0,0 +1,94 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
export class UpdateUpgradePolicyDto {
@ApiPropertyOptional({ example: 2, description: 'Ladder position — an upgrade needs a strictly higher rank' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
rank?: number;
@ApiPropertyOptional({ example: 0, description: '% of the passenger\'s original fare charged as a change fee' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100)
feePercent?: number;
@ApiPropertyOptional({ example: 0, description: 'Fee floor in ETB minor units (500 ETB = 50000)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
feeMinMinor?: number;
@ApiPropertyOptional({ example: true, description: 'Waive the change fee entirely (policy US-17 §5)' })
@IsOptional() @IsBoolean()
feeWaived?: boolean;
@ApiPropertyOptional({ example: true, description: 'Passengers may upgrade OUT of this class' })
@IsOptional() @IsBoolean()
isUpgradable?: boolean;
@ApiPropertyOptional({ example: true, description: 'Passengers may upgrade INTO this class' })
@IsOptional() @IsBoolean()
isTargetable?: boolean;
@ApiPropertyOptional({ example: true })
@IsOptional() @IsBoolean()
isActive?: boolean;
}
export class CreateUpgradePolicyDto extends UpdateUpgradePolicyDto {
@ApiProperty({ example: 'coach-type-uuid', description: 'CoachType this policy applies to (one per fare class)' })
@IsString()
coachTypeId: string;
}
export class UpgradeItemDto {
@ApiProperty({ example: 'booking-seat-uuid', description: 'The BookingSeat row being upgraded' })
@IsString()
bookingSeatId: string;
@ApiProperty({ example: 'seat-uuid', description: 'Seat this passenger moves to, in the target coach type' })
@IsString()
newSeatId: string;
}
export class UpgradeQuoteDto {
@ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2)
leg?: number;
@ApiProperty({ example: 'coach-type-uuid', description: 'Fare class every listed passenger is moving to' })
@IsString()
newCoachTypeId: string;
@ApiProperty({
type: [UpgradeItemDto],
description:
'One entry per upgrading passenger. Keyed on bookingSeatId, not array position — only some ' +
'passengers move, so a positional pairing would be ambiguous.',
})
@IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => UpgradeItemDto)
items: UpgradeItemDto[];
}
export class UpgradeHoldDto {
@ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg' })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2)
leg?: number;
@ApiProperty({ type: [String], description: 'Seats to hold, in the target coach type' })
@IsArray() @ArrayMinSize(1) @IsString({ each: true })
seatIds: string[];
}
export class CreateUpgradeDto extends UpgradeQuoteDto {
@ApiProperty({ example: 'seat-hold-uuid', description: 'Hold covering every newSeatId' })
@IsString()
holdId: string;
}

View File

@@ -0,0 +1,46 @@
import { Injectable, Logger, Module } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { OnEvent } from '@nestjs/event-emitter';
import { AuditModule } from '../../common/audit.module';
import { BookingsModule } from '../bookings/bookings.module';
import { SeatsModule } from '../seats/seats.module';
import { SegmentsModule } from '../segments/segments.module';
import { TicketsModule } from '../tickets/tickets.module';
import { PaymentsModule } from '../payments/payments.module';
import { CurrencyModule } from '../currency/currency.module';
import { SystemConfigModule } from '../system-config/system-config.module';
import { SUPPLEMENTARY_CHARGE_PAID_EVENT } from '../reschedule/reschedule.service';
import { UpgradeController } from './upgrade.controller';
import { UpgradeService } from './upgrade.service';
/**
* Same shape and same reason as RescheduleEventsListener: UpgradeService is request-scoped by
* transitivity (AuditService injects REQUEST), and Nest never fires @OnEvent on request-scoped
* providers — so the listener is a singleton that resolves the service per event.
*
* Two listeners on one event is fine: each looks its charge up by its own unique
* `supplementaryChargeId` and returns silently when the charge is not theirs.
*/
@Injectable()
export class UpgradeEventsListener {
private readonly logger = new Logger(UpgradeEventsListener.name);
constructor(private readonly moduleRef: ModuleRef) {}
@OnEvent(SUPPLEMENTARY_CHARGE_PAID_EVENT, { async: true })
async onChargePaid(payload: { chargeId: string }) {
try {
const service = await this.moduleRef.resolve(UpgradeService, undefined, { strict: false });
await service.applyForCharge(payload.chargeId);
} catch (err) {
this.logger.error(`Failed to apply upgrade for charge ${payload.chargeId}: ${err instanceof Error ? err.message : err}`);
}
}
}
@Module({
imports: [AuditModule, BookingsModule, SeatsModule, SegmentsModule, TicketsModule, PaymentsModule, CurrencyModule, SystemConfigModule],
controllers: [UpgradeController],
providers: [UpgradeService, UpgradeEventsListener],
exports: [UpgradeService],
})
export class UpgradeModule {}

View File

@@ -0,0 +1,52 @@
import { computeUpgradeAmounts } from './upgrade.service';
// Pure arithmetic only, mirroring reschedule.service.spec.ts — no Nest test module, no mocks.
describe('computeUpgradeAmounts', () => {
const free = { feePercent: 0, feeMinMinor: 0, feeWaived: false };
const waived = { feePercent: 30, feeMinMinor: 50000, feeWaived: true };
const percentOnly = { feePercent: 10, feeMinMinor: 0, feeWaived: false };
const flooredFee = { feePercent: 10, feeMinMinor: 50000, feeWaived: false };
it('charges only the fare difference when the class has no fee', () => {
// RS 1752.34 → EBC 2336.46, as seeded on dev
expect(computeUpgradeAmounts(free, 175234, 233646)).toEqual({
feeMinor: 0,
fareDifferenceMinor: 58412,
amountDueMinor: 58412,
});
});
it('ignores a configured fee when the policy waives it (US-17 §5)', () => {
expect(computeUpgradeAmounts(waived, 175234, 233646)).toEqual({
feeMinor: 0,
fareDifferenceMinor: 58412,
amountDueMinor: 58412,
});
});
it('takes the fee as a percentage of the ORIGINAL fare, not of the difference', () => {
const r = computeUpgradeAmounts(percentOnly, 175234, 233646);
expect(r.feeMinor).toBe(17523); // 10% of 175234, not of 58412
expect(r.amountDueMinor).toBe(17523 + 58412);
});
it('applies the fee floor when the percentage falls below it', () => {
const r = computeUpgradeAmounts(flooredFee, 100000, 150000);
expect(r.feeMinor).toBe(50000); // max(10% of 100000 = 10000, floor 50000)
expect(r.amountDueMinor).toBe(100000);
});
it('never lets a negative difference reduce the amount due', () => {
// Refused upstream, but the arithmetic must not produce a credit if it ever gets here.
const r = computeUpgradeAmounts(percentOnly, 200000, 150000);
expect(r.fareDifferenceMinor).toBe(-50000);
expect(r.amountDueMinor).toBe(r.feeMinor);
expect(r.amountDueMinor).toBeGreaterThanOrEqual(0);
});
it('charges the full target fare for a free child', () => {
const r = computeUpgradeAmounts(free, 0, 233646);
expect(r.feeMinor).toBe(0); // a percentage of zero is zero
expect(r.amountDueMinor).toBe(233646);
});
});

View File

@@ -0,0 +1,874 @@
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, holdId: true, bookingId: true, leg: true, amountDueMinor: true, items: 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' },
});
}
// Release the seat the instant the request dies instead of leaving it to the hold's own
// TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at
// creation, and nothing keeps them in step afterwards — so without this the seat can sit
// unsellable long after the link that pays for it has expired. deleteMany: an already-swept
// hold must not throw.
if (u.holdId) {
await this.prisma.seatHold.deleteMany({ where: { id: u.holdId } });
}
}
// After the loop on purpose: the rows are already committed, so a notification failure
// cannot leave a request half-expired. Fire-and-forget — the listener swallows its own errors.
for (const s of stale) {
this.eventEmitter.emit('booking.upgrade.expired', { bookingId: s.bookingId, request: s });
}
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 },
};
}
}