Files
edr-platform/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts

2309 lines
110 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
/**
* For package round-trip bookings, totalMinor in the DB may have been stored as a
* single-leg amount before the server fix. Recompute from the tier price when needed.
* tierPriceMinor is the per-leg per-adult price from PackagePackagePriceTier.
*/
function resolvePackageRoundTripTotal(
booking: { totalMinor: number; bookingType: string; packageId?: string | null },
tierPriceMinor: number | null | undefined,
adultCount: number,
childCount: number,
): number {
if (!booking.packageId || booking.bookingType !== 'ROUND_TRIP' || !tierPriceMinor) {
return booking.totalMinor;
}
// First child per adult is free; additional children pay full adult fare
const adultFareMinor = tierPriceMinor * 2;
const paidChildren = Math.max(0, childCount - adultCount);
return adultCount * adultFareMinor + paidChildren * adultFareMinor;
}
/**
* Returns all plausible normalised variants of a raw phone string so that the
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
* Returns an empty array when the input is clearly invalid (< 7 digits).
*/
function normalizePhoneVariants(raw: string): string[] {
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
const stripped = raw.replace(/[^\d+]/g, '');
const digits = stripped.replace(/^\+/, '');
if (digits.length < 7) return [];
const variants = new Set<string>([stripped]);
if (stripped.startsWith('+251') && digits.length === 12) {
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
variants.add(digits); // 251XXXXXXXXX
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
} else if (stripped.startsWith('251') && digits.length === 12) {
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
variants.add('+' + stripped); // +251XXXXXXXXX
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
} else if (stripped.startsWith('0') && digits.length === 10) {
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
} else if (!stripped.startsWith('+') && digits.length >= 9) {
// bare international digits without +
variants.add('+' + digits);
}
return [...variants];
}
function calculateAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) age--;
return age;
}
interface BookingFilters {
search?: string;
status?: string;
returnLegStatus?: string;
bookingType?: string;
paymentStatus?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
pageSize?: number;
}
@Injectable()
export class BookingsService {
private readonly logger = new Logger(BookingsService.name);
constructor(
private readonly prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
private readonly seatsService: SeatsService,
private readonly ticketsService: TicketsService,
private readonly eventEmitter: EventEmitter2,
private readonly verifaydaService: VerifaydaService,
private readonly currencyService: CurrencyService,
private readonly fareEngine: FareEngineService,
private readonly auditService: AuditService,
) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } });
return this.findByPassengerId(passenger.id, filters);
}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = { passengerId };
if (search) {
where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) {
where.status = status;
}
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
priceTier: { select: { priceMinor: true } },
},
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => {
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
arrivalAt: segment.arrivalAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
}),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async findByPhone(rawPhone: string, filters: BookingFilters = {}) {
const variants = normalizePhoneVariants(rawPhone);
if (variants.length === 0) return { items: [], meta: { page: 1, pageSize: 20, total: 0, totalPages: 0 } };
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 }[];
});
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 } }] : []),
],
};
if (status) where.status = status;
// PackageBooking is a separate table with its own contactPhone field —
// must be queried independently or guest package bookings are invisible.
const pkgWhere: any = {
OR: [
{ contactPhone: { in: variants } },
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
],
};
if (status) pkgWhere.status = status;
const [items, total, pkgItems, pkgTotal] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } },
seats: { select: { id: true } },
priceTier: { select: { priceMinor: true } },
},
}),
this.prisma.booking.count({ where }),
this.prisma.packageBooking.findMany({
where: pkgWhere,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
package: {
include: {
outboundSchedule: { include: { originStation: true, destinationStation: true } },
},
},
paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } },
},
}),
this.prisma.packageBooking.count({ where: pkgWhere }),
]);
const mappedBookings = items.map(booking => {
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
arrivalAt: segment.arrivalAt,
},
payment: booking.paymentIntent ?? undefined,
seatCount: booking.seats.length,
};
});
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id,
bookingRef: b.bookingRef,
status: b.status,
totalMinor: b.displayTotalMinor ?? b.totalMinor,
currency: b.displayCurrency ?? b.currency ?? null,
displayCurrency: b.displayCurrency ?? null,
displayTotalMinor: b.displayTotalMinor ?? null,
adultCount: b.adultCount,
childCount: b.childCount,
bookingType: 'PACKAGE',
returnLegStatus: null,
createdAt: b.createdAt,
schedule: b.package?.outboundSchedule
? {
train: null,
originStation: b.package.outboundSchedule.originStation,
destinationStation: b.package.outboundSchedule.destinationStation,
departureAt: b.package.outboundSchedule.departureAt,
arrivalAt: b.package.outboundSchedule.arrivalAt,
}
: null,
payment: b.paymentIntent ?? undefined,
seatCount: b.passengerCount,
}));
const allItems = [...mappedBookings, ...mappedPkg]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, pageSize);
return {
items: allItems,
meta: {
page,
pageSize,
total: total + pkgTotal,
totalPages: Math.ceil((total + pkgTotal) / pageSize),
},
};
}
async findByDeviceId(deviceId: string, filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
// Find passenger linked to this device via iamUserId
const device = await this.prisma.device.findUnique({ where: { id: deviceId } }).catch(() => null);
const passenger = device?.iamUserId
? await this.prisma.passenger.findUnique({ where: { iamUserId: device.iamUserId } }).catch(() => null)
: null;
const searchConditions = search ? [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
] : [];
const where: any = {
OR: [
{ userAgent: deviceId },
...(passenger ? [{ passengerId: passenger.id }] : []),
],
};
if (search) {
where.AND = [{ OR: searchConditions }];
}
if (status) {
where.status = status;
}
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
priceTier: { select: { priceMinor: true } },
},
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => {
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
arrivalAt: segment.arrivalAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
}),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async findAll(filters: BookingFilters = {}) {
const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const onlyPackages = bookingType === 'PACKAGE';
const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT';
const where: any = {};
if (search) {
const iamRows = await this.dataSource.query<{ id: string }[]>(
`SELECT u.id FROM iam.users u
WHERE (u.name->>'en') ILIKE $1 OR (u.name->>'am') ILIKE $1
OR u.email ILIKE $1 OR u.phone_number ILIKE $1`,
[`%${search}%`],
);
const matchedPassengers = iamRows.length > 0
? await this.prisma.passenger.findMany({
where: { iamUserId: { in: iamRows.map(r => r.id) } },
select: { id: true },
})
: [];
where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ contactEmail: { contains: search, mode: 'insensitive' } },
{ contactPhone: { contains: search, mode: 'insensitive' } },
...(matchedPassengers.length > 0
? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }]
: []),
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) where.status = status;
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
if (bookingType && !onlyPackages) where.bookingType = bookingType;
if (dateFrom || dateTo) {
where.createdAt = {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
};
}
if (paymentStatus) {
const statusMap: Record<string, string> = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' };
const mapped = statusMap[paymentStatus] ?? paymentStatus;
where.paymentIntent = { is: { status: mapped } };
}
const pkgWhere: any = {};
if (search) {
pkgWhere.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ contactEmail: { contains: search, mode: 'insensitive' } },
{ contactPhone: { contains: search, mode: 'insensitive' } },
{ passengers: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) pkgWhere.status = status;
if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt;
if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } };
if (onlyPackages) {
// Package bookings live in two places:
// 1. PackageBooking table (dedicated package bookings)
// 2. Booking table with packageId != null (round-trip bookings linked to a package)
const bookingPkgWhere: any = { packageId: { not: null } };
if (status) bookingPkgWhere.status = status;
if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt;
if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent;
if (search) bookingPkgWhere.OR = where.OR;
const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([
this.prisma.packageBooking.findMany({
where: pkgWhere,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, seatType: true } },
passengers: true,
paymentIntent: true,
},
}),
this.prisma.packageBooking.count({ where: pkgWhere }),
this.prisma.booking.findMany({
where: bookingPkgWhere,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
passenger: { select: { id: true, iamUserId: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
priceTier: { select: { priceMinor: true } },
},
}),
this.prisma.booking.count({ where: bookingPkgWhere }),
]);
const iamUserIds = regPkgItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
)
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
const mappedRegPkg = regPkgItems.map((booking: any) => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
const segment = booking.schedule ? resolveBookingSegment(booking.schedule, booking.originStationId, booking.destinationStationId) : null;
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true,
returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount, childCount: booking.childCount,
createdAt: booking.createdAt,
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers,
schedule: segment ? {
train: booking.schedule.train,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
} : null,
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
});
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status,
totalMinor: b.displayTotalMinor ?? b.totalMinor, currency: b.displayCurrency || b.currency,
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
isPackageBooking: true, packageName: b.package?.name, packageCode: b.package?.code,
tierLabel: b.priceTier?.label ?? null,
returnLegStatus: null, adultCount: b.passengerCount, childCount: 0,
createdAt: b.createdAt, passenger: null,
passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [],
passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [],
schedule: null, paymentIntent: b.paymentIntent, seatCount: b.passengerCount,
}));
const total = pkgTotal + regPkgTotal;
const allItems = [...mappedPkg, ...mappedRegPkg]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, pageSize);
return {
items: allItems,
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
};
}
const [regularItems, regularTotal, pkgItems, pkgTotal] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
passenger: { select: { id: true, iamUserId: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: { include: { coach: true } } } },
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, priceMinor: true } },
},
}),
this.prisma.booking.count({ where }),
includePackageBookings
? this.prisma.packageBooking.findMany({
where: pkgWhere,
orderBy: { createdAt: 'desc' },
include: {
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, seatType: true } },
passengers: true,
paymentIntent: true,
},
})
: Promise.resolve([] as any[]),
includePackageBookings ? this.prisma.packageBooking.count({ where: pkgWhere }) : Promise.resolve(0),
]);
const iamUserIds = regularItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
).catch(() => [] as { id: string; email: string; name: any; phone_number: string | null }[])
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
// For bookings that have no contactEmail/contactPhone and no IAM match,
// fall back to TravelerProfile.notes JSON.
// Covers: (1) legacy authenticated bookings where IAM returns nothing,
// (2) guest bookings where passenger.iamUserId is null.
const passengerIdsNeedingFallback = regularItems
.filter((b: any) => !b.contactEmail && !b.contactPhone && (!b.passenger?.iamUserId || !iamMap.has(b.passenger.iamUserId)))
.map((b: any) => b.passengerId)
.filter(Boolean) as string[];
const travelerProfileMap = new Map<string, { phone: string | null; email: string | null }>();
if (passengerIdsNeedingFallback.length > 0) {
const profiles = await this.prisma.travelerProfile.findMany({
where: { passengerId: { in: passengerIdsNeedingFallback } },
select: { passengerId: true, notes: true },
orderBy: { createdAt: 'asc' },
});
for (const profile of profiles) {
if (travelerProfileMap.has(profile.passengerId)) continue;
try {
const notes = profile.notes ? (typeof profile.notes === 'string' ? JSON.parse(profile.notes) : profile.notes) : null;
if (notes?.phone || notes?.email) {
travelerProfileMap.set(profile.passengerId, { phone: notes.phone ?? null, email: notes.email ?? null });
}
} catch { /* ignore */ }
}
}
const mappedRegular = regularItems.map((booking: any) => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory, seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: s.seat.coach?.number ?? null } : null }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
// Resolve contact: DB row → IAM → TravelerProfile notes → seat name fallback
const fallback = booking.passengerId ? travelerProfileMap.get(booking.passengerId) : null;
const resolvedEmail = booking.contactEmail ?? iam?.email ?? fallback?.email ?? null;
const resolvedPhone = booking.contactPhone ?? iam?.phone_number ?? fallback?.phone ?? null;
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
contactEmail: resolvedEmail,
contactPhone: resolvedPhone,
bookingType: booking.bookingType,
packageId: booking.packageId ?? null,
priceTierId: (booking as any).priceTierId ?? null,
packageName: (booking as any).package?.name ?? null,
packageCode: (booking as any).package?.code ?? null,
tierLabel: (booking as any).priceTier?.label ?? null,
isPackageBooking: !!booking.packageId,
returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
originStationId: (booking as any).originStationId ?? null,
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers,
seats: booking.seats.map((s: any) => ({
passengerName: s.passengerName,
passengerCategory: s.passengerCategory,
leg: s.leg ?? 1,
fareMinor: s.fareMinor,
idDocumentType: s.idDocumentType,
verifaydaVerified: s.verifaydaVerified,
seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null,
})),
schedule: (() => {
const segment = resolveBookingSegment(booking.schedule, (booking as any).originStationId, (booking as any).destinationStationId);
return {
train: booking.schedule.train,
originStation: segment.origin,
destinationStation: segment.destination,
departureAt: segment.departureAt,
};
})(),
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
});
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id,
bookingRef: b.bookingRef,
status: b.status,
totalMinor: b.displayTotalMinor ?? b.totalMinor,
currency: b.displayCurrency || b.currency,
displayCurrency: b.displayCurrency,
displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail,
contactPhone: b.contactPhone,
bookingType: 'PACKAGE',
packageId: b.packageId,
priceTierId: b.priceTierId,
isPackageBooking: true,
packageName: b.package?.name,
packageCode: b.package?.code,
tierLabel: b.priceTier?.label ?? null,
returnLegStatus: null,
adultCount: b.passengerCount,
childCount: 0,
createdAt: b.createdAt,
passenger: null,
passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [],
passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [],
schedule: null,
paymentIntent: b.paymentIntent,
seatCount: b.passengerCount,
}));
const total = regularTotal + pkgTotal;
const allItems = [...mappedRegular, ...mappedPkg]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, pageSize);
return {
items: allItems,
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
};
}
async create(dto: CreateBookingDto) {
// Resolve passengerId from iamUserId when the caller is authenticated
if (dto.passengerId && !dto.passengerId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
// passengerId is actually an iamUserId — resolve the passenger record
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId: dto.passengerId }, select: { id: true } });
if (!passenger) throw new NotFoundException('Passenger profile not found for this account');
dto = { ...dto, passengerId: passenger.id };
}
if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto);
if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto);
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto);
return this.createOneWayBooking(dto);
}
private validateSeatIdsAgainstHold(holdId: string, holdSeatIds: string[], requestedSeatIds: string[]) {
for (const seatId of requestedSeatIds) {
if (!holdSeatIds.includes(seatId)) {
throw new BadRequestException(
`Seat ${seatId} is not part of hold ${holdId}. Use seat IDs returned from POST /seats/hold.`,
);
}
}
}
/** Resolves contactEmail/contactPhone for an IAM-authenticated passenger booking. */
private async resolveIamContact(passengerId?: string): Promise<{ contactEmail: string | null; contactPhone: string | null }> {
if (!passengerId) return { contactEmail: null, contactPhone: null };
const passenger = await this.prisma.passenger.findUnique({ where: { id: passengerId }, select: { iamUserId: true } });
if (!passenger?.iamUserId) return { contactEmail: null, contactPhone: null };
const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>(
`SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
[passenger.iamUserId],
);
return { contactEmail: rows[0]?.email ?? null, contactPhone: rows[0]?.phone_number ?? null };
}
private async createOneWayBooking(dto: CreateBookingDto) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
const requestedSeatIds = (dto.passengers as any[]).map(p => p.seatId);
this.validateSeatIdsAgainstHold(dto.holdId, hold.seatIds, requestedSeatIds);
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
});
if (!schedule) throw new NotFoundException('Schedule not found');
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
const [passengersData, iamContact] = await Promise.all([
this.processPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId),
]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const fareCalculation = dto.packageId && dto.priceTierId
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
const passengersWithFares = passengersData.map(p => {
let fareMinor: number;
if (p.category === PassengerCategory.ADULT) {
fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
} else {
// Free children have no seat (frontend excludes them from the DTO).
// Guard by seatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
fareMinor = p.seatId ? (p.seatFareMinor ?? fareCalculation.baseFareMinor) : 0;
}
return { ...p, fareMinor };
});
// Determine the authoritative total.
// Priority (one-way, non-package):
// 1. Server-computed sum of per-seat fares when every seated passenger supplied
// seatFareMinor — this captures berth-specific pricing (Upper/Middle/Lower)
// exactly as shown to the user and cannot be corrupted by a frontend race
// condition that sends reviewedTotalMinor before all fares are resolved.
// 2. reviewedTotalMinor from the frontend — fallback when the server doesn't
// have complete per-seat data (e.g. auto-assign with no seat map loaded).
// 3. Fare engine total — last resort when neither is available.
// For package bookings reviewedTotalMinor always wins because the tier price
// may include berth-specific adjustments the server cannot derive alone.
// Free children have no seatId and no seatFareMinor — exclude from the check.
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
let resolvedTotalMinor: number;
let displayTotalMinor: number;
// True when the total came from a client-summed subtotal (per-seat sum or reviewedTotalMinor),
// which the portal computes UNDISCOUNTED — the promo must still be applied to it (H-13).
let usedClientSubtotal = false;
if (allFaresProvided && !dto.packageId) {
// Server has every passenger's berth fare — sum is the authoritative display total.
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
usedClientSubtotal = true;
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
this.logger.warn(`createOneWayBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
}
} else if (dto.packageId && dto.reviewedTotalMinor != null) {
// Package booking: client-supplied tier-adjusted total.
displayTotalMinor = dto.reviewedTotalMinor;
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
if (seatedPassengers.length > 0) {
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
passengersWithFares.forEach(p => {
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
});
}
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
// Partial data on server: use client's total as best available.
displayTotalMinor = dto.reviewedTotalMinor;
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
usedClientSubtotal = true;
} else {
resolvedTotalMinor = fareCalculation.totalMinor;
displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
: resolvedTotalMinor;
}
// H-13 fix: the portal sums UNDISCOUNTED per-passenger fares into the total it sends, silently
// dropping the promo the fare engine recognized (the discount lives only in the fare-breakdown).
// When the total came from that client subtotal, apply the authoritative promo discount so the
// customer is charged the discounted price. No-op when no promo applies (discountMinor === 0).
// The fallback branch above already books fareCalculation.totalMinor (discount included), so it is
// excluded via usedClientSubtotal to avoid double-subtracting.
if (usedClientSubtotal && fareCalculation.discountMinor > 0) {
resolvedTotalMinor = Math.max(0, resolvedTotalMinor - fareCalculation.discountMinor);
const discountDisplayMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(fareCalculation.discountMinor, Currency.ETB, displayCurrency)
: fareCalculation.discountMinor;
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
}
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} discountMinor=${fareCalculation.discountMinor} fareEngine=${fareCalculation.totalMinor})`);
// C-1 guard: never charge less than the server-recomputed authoritative fare. resolvedTotalMinor
// is the ETB charge basis; fareCalculation.totalMinor is the authoritative ETB fare (already net
// of promo/loyalty/free-child). A client that forges seatFareMinor / reviewedTotalMinor below it
// is rejected. Floor (not equality) so legitimate berth surcharges — which only raise the total —
// still pass; the tolerance absorbs FX-conversion rounding.
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, fareCalculation.totalMinor, 'createOneWayBooking');
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
totalMinor: resolvedTotalMinor,
// Charge basis is ETB (resolvedTotalMinor). The passenger's currency and amount live in
// displayCurrency/displayTotalMinor — keep currency coherent with totalMinor's units.
currency: Currency.ETB,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
contactEmail: iamContact.contactEmail,
contactPhone: iamContact.contactPhone,
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
seats: {
create: passengersWithFares.map(p => ({
seat: { connect: { id: p.seatId } },
scheduleId: dto.scheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.fareMinor,
displayCurrency
}))
}
},
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
});
await this.seatsService.confirmSeats(passengersData.map(p => p.seatId));
if (dto.packageId && dto.priceTierId) {
await this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId },
data: { bookedSeats: { increment: passengersData.length } },
});
}
this.eventEmitter.emit('booking.created', { booking });
await this.auditService.log({ userId: dto.passengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ONE_WAY', totalMinor: resolvedTotalMinor } });
return { ...booking, fareBreakdown: fareCalculation };
}
private async createRoundTripBooking(dto: CreateBookingDto) {
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
throw new BadRequestException('Return trip details required for round-trip booking');
}
const [outboundHold, returnHold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } })
]);
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired');
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired');
const holdObSeatIds = (dto.passengers as any[]).map((p: any) => p.seatId ?? p.outboundSeatId).filter(Boolean);
const holdRetSeatIds = (dto.passengers as any[]).map((p: any) => p.returnSeatId).filter(Boolean);
if (holdObSeatIds.length) this.validateSeatIdsAgainstHold(dto.holdId, outboundHold.seatIds, holdObSeatIds);
if (holdRetSeatIds.length) this.validateSeatIdsAgainstHold(dto.returnHoldId!, returnHold.seatIds, holdRetSeatIds);
const [outboundSchedule, returnSchedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
}),
this.prisma.trainSchedule.findUnique({
where: { id: dto.returnScheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
})
]);
if (!outboundSchedule || !returnSchedule) throw new NotFoundException('Schedule not found');
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
if (!outboundOriginStop || !outboundDestStop || !returnOriginStop || !returnDestStop) {
throw new NotFoundException('Origin or destination stops not found');
}
const [passengersData, iamContact] = await Promise.all([
this.processRoundTripPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId),
]);
const { adultCount, childCount } = this.countPassengers(passengersData);
// Package bookings use fixed tier price split equally across both legs
let outboundFare: Awaited<ReturnType<typeof this.calculateFare>>;
let returnFare: Awaited<ReturnType<typeof this.calculateFare>>;
let combinedBaseFareMinor: number;
let discountMinor = 0;
let loyaltyMinor = 0;
let totalMinor: number;
if (dto.packageId && dto.priceTierId) {
const pkgFare = await this.calculatePackageFare(dto.priceTierId, adultCount, childCount);
// pkgFare covers one leg; round-trip = both legs combined
const roundTripTotal = pkgFare.totalMinor * 2;
// Split evenly across both legs for per-seat fare recording
const halfMinor = Math.round(pkgFare.baseFareMinor / 2);
outboundFare = { ...pkgFare, baseFareMinor: halfMinor, totalBaseFareMinor: Math.round(pkgFare.totalBaseFareMinor / 2) };
returnFare = { ...pkgFare, baseFareMinor: pkgFare.baseFareMinor - halfMinor, totalBaseFareMinor: pkgFare.totalBaseFareMinor - Math.round(pkgFare.totalBaseFareMinor / 2) };
combinedBaseFareMinor = pkgFare.totalBaseFareMinor * 2;
totalMinor = roundTripTotal;
} else {
[outboundFare, returnFare] = await Promise.all([
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount)
]);
combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
}
// C-1 guard: authoritative ETB fare for both legs, captured before the client-driven branches
// below may overwrite totalMinor with a per-seat sum or reviewedTotalMinor.
const authoritativeTotalMinor = totalMinor;
const taxesMinor = 0;
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
// displayTotalMinor will be overridden below when reviewedTotalMinor is provided.
let displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
// present (berth-specific pricing). Fall back to fare engine values.
const passengersWithFares = passengersData.map(p => {
let outboundFareMinor: number;
let returnFareMinor: number;
if (p.category === PassengerCategory.ADULT) {
outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
} else {
// Free children have no outbound seat (frontend excludes them).
// Guard by outboundSeatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
outboundFareMinor = p.outboundSeatId ? (p.seatFareMinor ?? outboundFare.baseFareMinor) : 0;
returnFareMinor = p.outboundSeatId ? (p.returnSeatFareMinor ?? returnFare.baseFareMinor) : 0;
}
return { ...p, outboundFareMinor, returnFareMinor };
});
// Override totalMinor with the sum of actual per-seat fares when all seated passengers
// supplied their fares — free children (no seatId) are excluded from the check.
// Same priority logic as one-way: server-computed sum wins when all per-seat fares
// are present; reviewedTotalMinor is used only as fallback to avoid a frontend
// race condition from under-counting passengers.
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
if (allRTFaresProvided && !dto.packageId) {
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
this.logger.warn(`createRoundTripBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
}
} else if (dto.packageId && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
const seatedCount = rtSeatedPassengers.length;
if (seatedCount > 0) {
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
passengersWithFares.forEach(p => {
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
});
}
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
}
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createRoundTripBooking');
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP',
totalMinor,
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
returnScheduleId: dto.returnScheduleId,
returnOriginStationId: dto.returnOriginStationId,
returnDestinationStationId: dto.returnDestinationStationId,
returnHoldId: dto.returnHoldId,
returnSeatClassId: dto.returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
contactEmail: iamContact.contactEmail,
contactPhone: iamContact.contactPhone,
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
seats: {
create: [
...passengersWithFares.map(p => ({
seat: { connect: { id: p.outboundSeatId } },
leg: 1,
scheduleId: dto.scheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.outboundFareMinor,
displayCurrency,
})),
...passengersWithFares.map(p => ({
seat: { connect: { id: p.returnSeatId } },
leg: 2,
scheduleId: dto.returnScheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.returnFareMinor,
displayCurrency,
})),
],
},
} as any,
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
});
const outboundSeatIds = passengersData.map(p => p.outboundSeatId);
const returnSeatIds = passengersData.map(p => p.returnSeatId);
await Promise.all([
this.seatsService.confirmSeats(outboundSeatIds),
this.seatsService.confirmSeats(returnSeatIds)
]);
if (dto.packageId && dto.priceTierId) {
await this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId },
data: { bookedSeats: { increment: passengersData.length } },
});
}
this.eventEmitter.emit('booking.created', { booking });
await this.auditService.log({ userId: dto.passengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ROUND_TRIP', totalMinor } });
return {
...booking,
fareBreakdown: {
outboundFare: outboundFare.baseFareMinor,
returnFare: returnFare.baseFareMinor,
combinedBaseFareMinor,
discountMinor,
loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor,
totalMinor,
currency: booking.displayCurrency,
displayCurrency,
displayTotalMinor
}
};
}
private async createTransitBooking(dto: CreateBookingDto) {
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
}
const [leg1Hold, leg2Hold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
]);
if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired');
if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired');
const leg1SeatIds = (dto.passengers as any[]).map(p => p.seatId);
const leg2SeatIds = (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId);
this.validateSeatIdsAgainstHold(dto.holdId, leg1Hold.seatIds, leg1SeatIds);
this.validateSeatIdsAgainstHold(dto.leg2HoldId!, leg2Hold.seatIds, leg2SeatIds);
const [leg1Schedule, leg2Schedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
}),
this.prisma.trainSchedule.findUnique({
where: { id: dto.leg2ScheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
}),
]);
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination station not found on leg-2 schedule');
const [passengersData, iamContact] = await Promise.all([
this.processPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId),
]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
const [leg1Fare, leg2Fare] = await Promise.all([
this.calculateFare(dto.scheduleId, dto.seatClassId, leg1OriginStop, leg1DestStop, passengersData[0]?.nationality, adultCount, childCount),
this.calculateFare(dto.leg2ScheduleId, leg2SeatClassId, leg2OriginStop, leg2DestStop, passengersData[0]?.nationality, adultCount, childCount),
]);
const combinedBase = leg1Fare.totalBaseFareMinor + leg2Fare.totalBaseFareMinor;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
// Track which child gets free fare for leg1 and leg2
let leg1FreeChildUsed = false;
let leg2FreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let leg1FareMinor: number;
let leg2FareMinor: number;
if (p.category === PassengerCategory.ADULT) {
leg1FareMinor = leg1Fare.baseFareMinor;
leg2FareMinor = leg2Fare.baseFareMinor;
} else {
// Child fare for leg1
if (!leg1FreeChildUsed) {
leg1FareMinor = 0;
leg1FreeChildUsed = true;
} else {
leg1FareMinor = leg1Fare.baseFareMinor;
}
// Child fare for leg2
if (!leg2FreeChildUsed) {
leg2FareMinor = 0;
leg2FreeChildUsed = true;
} else {
leg2FareMinor = leg2Fare.baseFareMinor;
}
}
return { ...p, leg1FareMinor, leg2FareMinor };
});
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.transitStationId,
status: 'PENDING_PAYMENT',
bookingType: 'TRANSIT',
totalMinor,
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
leg2ScheduleId: dto.leg2ScheduleId,
leg2OriginStationId: dto.transitStationId,
leg2DestinationStationId: dto.leg2DestinationStationId,
leg2SeatClassId,
contactEmail: iamContact.contactEmail,
contactPhone: iamContact.contactPhone,
seats: {
create: [
...passengersWithFares.map(p => ({
seat: { connect: { id: p.seatId } },
leg: 1,
scheduleId: dto.scheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.leg1FareMinor,
displayCurrency,
})),
...passengersWithFares.map(p => ({
seat: { connect: { id: p.leg2SeatId ?? p.seatId } },
leg: 2,
scheduleId: dto.leg2ScheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.leg2FareMinor,
displayCurrency,
})),
],
},
} as any,
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
});
await Promise.all([
this.seatsService.confirmSeats(passengersData.map(p => p.seatId)),
this.seatsService.confirmSeats(passengersData.map(p => p.leg2SeatId ?? p.seatId)),
]);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
fareBreakdown: {
leg1BaseFareMinor: leg1Fare.baseFareMinor,
leg2BaseFareMinor: leg2Fare.baseFareMinor,
adultCount, childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount: leg1Fare.paidChildrenCount,
combinedBaseFareMinor: combinedBase,
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor, totalMinor,
currency: displayCurrency, displayTotalMinor,
},
};
}
private async createRoundTripTransitBooking(dto: CreateBookingDto) {
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
!dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
throw new BadRequestException(
'ROUND_TRIP_TRANSIT requires outbound transit fields (leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId) ' +
'AND return transit fields (returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, ' +
'returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId)',
);
}
// Validate all 4 holds
const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }),
]);
const now = new Date();
if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 seat hold expired');
if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 seat hold expired');
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired');
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired');
this.validateSeatIdsAgainstHold(dto.holdId, obL1Hold.seatIds, (dto.passengers as any[]).map(p => p.seatId));
this.validateSeatIdsAgainstHold(dto.leg2HoldId!, obL2Hold.seatIds, (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId));
this.validateSeatIdsAgainstHold(dto.returnHoldId!, retL1Hold.seatIds, (dto.passengers as any[]).map(p => p.returnSeatId));
this.validateSeatIdsAgainstHold(dto.returnLeg2HoldId!, retL2Hold.seatIds, (dto.passengers as any[]).map(p => p.returnLeg2SeatId ?? p.returnSeatId));
// Load all 4 schedules
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
]);
if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found');
if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found');
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId);
if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit station not found');
if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination not found');
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found');
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found');
const [passengersData, iamContact] = await Promise.all([
this.processRoundTripPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId),
]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const nat = passengersData[0]?.nationality;
const obL2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
const retL1SeatClassId = dto.returnSeatClassId ?? dto.seatClassId;
const retL2SeatClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
this.calculateFare(dto.scheduleId, dto.seatClassId, obL1Origin, obL1Dest, nat, adultCount, childCount),
this.calculateFare(dto.leg2ScheduleId, obL2SeatClassId, obL2Origin, obL2Dest, nat, adultCount, childCount),
this.calculateFare(dto.returnScheduleId, retL1SeatClassId, retL1Origin, retL1Dest, nat, adultCount, childCount),
this.calculateFare(dto.returnLeg2ScheduleId, retL2SeatClassId, retL2Origin, retL2Dest, nat, adultCount, childCount),
]);
const combinedBase = obL1Fare.totalBaseFareMinor + obL2Fare.totalBaseFareMinor +
retL1Fare.totalBaseFareMinor + retL2Fare.totalBaseFareMinor;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(nat);
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
// Track which child gets free fare for all 4 legs
let obL1FreeChildUsed = false;
let obL2FreeChildUsed = false;
let retL1FreeChildUsed = false;
let retL2FreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let obL1FareMinor: number, obL2FareMinor: number, retL1FareMinor: number, retL2FareMinor: number;
if (p.category === PassengerCategory.ADULT) {
obL1FareMinor = obL1Fare.baseFareMinor;
obL2FareMinor = obL2Fare.baseFareMinor;
retL1FareMinor = retL1Fare.baseFareMinor;
retL2FareMinor = retL2Fare.baseFareMinor;
} else {
// Child fares for each leg
obL1FareMinor = !obL1FreeChildUsed ? (obL1FreeChildUsed = true, 0) : obL1Fare.baseFareMinor;
obL2FareMinor = !obL2FreeChildUsed ? (obL2FreeChildUsed = true, 0) : obL2Fare.baseFareMinor;
retL1FareMinor = !retL1FreeChildUsed ? (retL1FreeChildUsed = true, 0) : retL1Fare.baseFareMinor;
retL2FareMinor = !retL2FreeChildUsed ? (retL2FreeChildUsed = true, 0) : retL2Fare.baseFareMinor;
}
return { ...p, obL1FareMinor, obL2FareMinor, retL1FareMinor, retL2FareMinor };
});
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fareMinor: number) => ({
seat: { connect: { id: seatId } },
leg,
scheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor,
displayCurrency,
});
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.leg2DestinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP_TRANSIT',
totalMinor, currency: Currency.ETB, adultCount, childCount, displayCurrency, displayTotalMinor, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
// Outbound transit leg-2
leg2ScheduleId: dto.leg2ScheduleId,
leg2OriginStationId: dto.transitStationId,
leg2DestinationStationId: dto.leg2DestinationStationId,
leg2SeatClassId: obL2SeatClassId,
// Return transit
returnScheduleId: dto.returnScheduleId,
returnOriginStationId: dto.returnOriginStationId,
returnDestinationStationId: dto.returnDestinationStationId,
returnSeatClassId: retL1SeatClassId,
returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
returnLeg2OriginStationId: dto.returnTransitStationId,
returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
returnLeg2SeatClassId: retL2SeatClassId,
returnLegStatus: 'NEITHER_USED',
contactEmail: iamContact.contactEmail,
contactPhone: iamContact.contactPhone,
seats: {
create: [
// Outbound leg-1 (sequence 1)
...passengersWithFares.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, p.obL1FareMinor)),
// Outbound leg-2 (sequence 2)
...passengersWithFares.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, p.obL2FareMinor)),
// Return leg-1 (sequence 3)
...passengersWithFares.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, p.retL1FareMinor)),
// Return leg-2 (sequence 4)
...passengersWithFares.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, p.retL2FareMinor)),
],
},
} as any,
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
});
await Promise.all([
this.seatsService.confirmSeats(passengersData.map(p => p.outboundSeatId)),
this.seatsService.confirmSeats(passengersData.map(p => p.outboundLeg2SeatId ?? p.outboundSeatId)),
this.seatsService.confirmSeats(passengersData.map(p => p.returnSeatId)),
this.seatsService.confirmSeats(passengersData.map(p => p.returnLeg2SeatId ?? p.returnSeatId)),
]);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
fareBreakdown: {
outboundLeg1FareMinor: obL1Fare.baseFareMinor,
outboundLeg2FareMinor: obL2Fare.baseFareMinor,
returnLeg1FareMinor: retL1Fare.baseFareMinor,
returnLeg2FareMinor: retL2Fare.baseFareMinor,
adultCount, childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount: obL1Fare.paidChildrenCount,
combinedBaseFareMinor: combinedBase,
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor, totalMinor,
currency: displayCurrency, displayTotalMinor,
},
};
}
private async processPassengers(passengers: any[]) {
const processedPassengers = [];
for (const passenger of passengers) {
const dateOfBirth = new Date(passenger.dateOfBirth);
const age = calculateAge(dateOfBirth);
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
let passengerName = passenger.passengerName;
let verifaydaVerified = false;
let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality;
if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = verification.passengerData?.profileData;
nationality = nationality || 'Ethiopian';
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
}
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
return processedPassengers;
}
private async processRoundTripPassengers(passengers: any[]) {
const processedPassengers = [];
for (const passenger of passengers) {
const dateOfBirth = new Date(passenger.dateOfBirth);
const age = calculateAge(dateOfBirth);
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
let passengerName = passenger.passengerName;
let verifaydaVerified = false;
let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality;
if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = verification.passengerData?.profileData;
nationality = nationality || 'Ethiopian';
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
}
processedPassengers.push({
...passenger,
passengerName,
dateOfBirth,
category,
verifaydaVerified,
verifaydaData,
nationality,
// Normalise: PassengerInputDto uses seatId/returnSeatId; RoundTripPassengerDto uses
// outboundSeatId/returnSeatId. Accept either form so both DTOs work.
outboundSeatId: passenger.outboundSeatId ?? passenger.seatId,
outboundLeg2SeatId: passenger.outboundLeg2SeatId ?? passenger.leg2SeatId,
returnSeatId: passenger.returnSeatId,
returnLeg2SeatId: passenger.returnLeg2SeatId,
});
}
return processedPassengers;
}
private countPassengers(passengersData: any[]) {
let adultCount = 0, childCount = 0;
for (const passenger of passengersData) {
if (passenger.category === PassengerCategory.ADULT) adultCount++;
else childCount++;
}
return { adultCount, childCount };
}
private async calculatePackageFare(
priceTierId: string,
adultCount: number,
childCount: number,
) {
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } });
// First child per adult travels free (no seat); additional children pay full adult fare.
const freeChildrenCount = Math.min(childCount, adultCount);
const paidChildrenCount = Math.max(0, childCount - adultCount);
const adultFareMinor = tier.priceMinor * adultCount;
const childTotalMinor = tier.priceMinor * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childTotalMinor;
return {
baseFareMinor: tier.priceMinor,
adultCount,
adultFareMinor,
childCount,
freeChildrenCount,
paidChildrenCount,
childFareMinor: childTotalMinor,
totalBaseFareMinor,
discountMinor: 0,
loyaltyRedemptionMinor: 0,
taxesFeesMinor: 0,
totalMinor: totalBaseFareMinor,
};
}
/**
* C-1 protection: reject a booking whose ETB charge basis is below the server-recomputed
* authoritative fare. A floor (not equality) so legitimate berth surcharges — which only raise
* the total — still pass; a 1% tolerance absorbs FX-conversion rounding. A forged seatFareMinor /
* reviewedTotalMinor that lowers the charge (e.g. to 1 or 0) is refused with a 400 and nothing is
* persisted.
*/
private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void {
const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01));
if (resolvedTotalMinor < authoritativeMinor - tolerance) {
this.logger.warn(`${context}: rejecting booking — resolvedTotalMinor=${resolvedTotalMinor} below authoritative fare=${authoritativeMinor}`);
throw new BadRequestException('Booking total does not match the authoritative fare');
}
}
private async calculateFare(
scheduleId: string,
seatClassId: string,
originStop: any,
destStop: any,
nationality?: string,
adultCount = 1,
childCount = 0,
promoCode?: string,
loyaltyRedemptionPoints?: number
) {
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId);
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
const childFareMinor = baseFareMinor * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childFareMinor;
let discountMinor = 0;
if (promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
return {
baseFareMinor,
adultCount,
adultFareMinor,
childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount,
childFareMinor,
totalBaseFareMinor,
discountMinor,
loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor,
totalMinor
};
}
private async getBaseFare(
scheduleId: string,
seatClassId: string,
segmentRoute?: string,
fullRoute?: string,
nationality?: string,
originStopSeq?: number,
destStopSeq?: number,
originStationId?: string,
destinationStationId?: string,
): Promise<number> {
const now = new Date();
// 1. SegmentFareRule — most specific explicit price
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) {
const segmentFare = await this.prisma.segmentFareRule.findFirst({
where: {
routeId: schedule.routeId,
originStopSequence: originStopSeq,
destinationStopSequence: destStopSeq,
seatClassId,
nationality: nationality ?? null,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
}) ?? (nationality ? await this.prisma.segmentFareRule.findFirst({
where: {
routeId: schedule.routeId,
originStopSequence: originStopSeq,
destinationStopSequence: destStopSeq,
seatClassId,
nationality: null,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
}) : null);
if (segmentFare) return segmentFare.baseFareMinor;
}
// 2. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
if (bestMatch) return bestMatch.baseFareMinor;
// 3. FareEngine — distance × rate-per-km from the booking's actual segment stations
if (schedule?.routeId) {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: originStationId ?? schedule.originStationId,
destinationStationId: destinationStationId ?? schedule.destinationStationId,
seatClassId,
nationality,
});
return fare.baseFarePerPassengerMinor;
} catch {
// FareEngine throws if distanceKm is missing; fall through to error
}
}
throw new BadRequestException(
`No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
);
}
/**
* Resolves a reservation booking by its standalone payToken — the public entry point for
* the portal's pay-by-link page (a traveler who never had a portal session, texted a link
* by a reservation-issuing staff member). Payment itself still goes through the already-
* public /payments/* endpoints (initiate/methods/booking-amount/status), keyed by the
* booking id this returns — no change needed there.
*/
async getByPayToken(token: string) {
const booking = await this.prisma.booking.findUnique({
where: { payToken: token },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: true } } } },
},
});
if (!booking) throw new NotFoundException('Payment link not found');
if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('This booking is no longer awaiting payment');
if ((booking as any).payTokenExpiresAt && (booking as any).payTokenExpiresAt < new Date()) {
throw new BadRequestException('This payment link has expired');
}
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: booking.currency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
schedule: {
trainNumber: (booking as any).schedule.train.number,
trainName: (booking as any).schedule.train.name,
origin: { id: segment.origin.id, name: segment.origin.name, code: segment.origin.code },
destination: { id: segment.destination.id, name: segment.destination.name, code: segment.destination.code },
departureAt: segment.departureAt,
arrivalAt: segment.arrivalAt,
},
seats: (booking as any).seats.map((s: any) => ({
passengerName: s.passengerName,
seatNumber: s.seat?.seatNumber ?? null,
coach: s.seat?.coach?.number ?? null,
})),
};
}
async getByRef(bookingRefOrId: string) {
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId);
const booking = await this.prisma.booking.findUnique({
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
priceTier: { select: { priceMinor: true } },
},
});
if (!booking) {
// Fall back to PackageBooking
const pkgBooking = await this.prisma.packageBooking.findUnique({
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
include: {
package: { include: { outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } } } },
priceTier: true,
passengers: true,
paymentIntent: true,
},
});
if (!pkgBooking) throw new NotFoundException('Booking not found');
return {
id: pkgBooking.id,
bookingRef: pkgBooking.bookingRef,
status: pkgBooking.status,
totalMinor: pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor,
currency: pkgBooking.displayCurrency || pkgBooking.currency,
adultCount: pkgBooking.passengerCount,
childCount: 0,
displayCurrency: pkgBooking.displayCurrency,
displayTotalMinor: pkgBooking.displayTotalMinor ?? undefined,
bookingType: 'PACKAGE',
packageId: pkgBooking.packageId,
priceTierId: pkgBooking.priceTierId,
packageName: (pkgBooking as any).package?.name,
packageCode: (pkgBooking as any).package?.code,
tierLabel: (pkgBooking as any).priceTier?.label,
isPackageBooking: true,
returnLegStatus: null,
contactEmail: pkgBooking.contactEmail,
contactPhone: pkgBooking.contactPhone,
createdAt: pkgBooking.createdAt,
schedule: (pkgBooking as any).package?.outboundSchedule ? {
id: (pkgBooking as any).package.outboundSchedule.id,
trainNumber: (pkgBooking as any).package.outboundSchedule.train?.number,
trainName: (pkgBooking as any).package.outboundSchedule.train?.name,
origin: (pkgBooking as any).package.outboundSchedule.originStation,
destination: (pkgBooking as any).package.outboundSchedule.destinationStation,
departureAt: (pkgBooking as any).package.outboundSchedule.departureAt,
arrivalAt: (pkgBooking as any).package.outboundSchedule.arrivalAt,
} : null,
passengers: (pkgBooking as any).passengers?.map((p: any) => ({
fullName: p.passengerName,
category: 'ADULT',
leg: 1,
fareMinor: Math.round((pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor) / pkgBooking.passengerCount),
verifaydaVerified: false,
seat: null,
})),
payment: (pkgBooking as any).paymentIntent
? {
method: (pkgBooking as any).paymentIntent.method,
status: (pkgBooking as any).paymentIntent.status,
amountMinor: (pkgBooking as any).paymentIntent.amountMinor,
currency: (pkgBooking as any).paymentIntent.currency,
}
: undefined,
tickets: [],
};
}
// Auto-heal: if booking is CONFIRMED, payment SUCCEEDED, but tickets are missing
// (ticket generation failed silently after payment — see finalizePaymentSuccess in
// payments.service.ts), attempt to generate them now so the confirmation page
// doesn't show "Not yet issued".
if (
booking.status === 'CONFIRMED' &&
(booking as any).tickets?.length === 0 &&
(booking as any).paymentIntent?.status === 'SUCCEEDED'
) {
try {
await this.ticketsService.generate(booking.id);
} catch (err) {
this.logger.warn(`getByRef: generate failed for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}. Trying smart assign.`);
try {
await this.ticketsService.smartAssignAndGenerate(booking.id);
} catch (retryErr) {
this.logger.error(`getByRef: smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
}
}
// Re-fetch to include any newly created tickets
const refreshed = await this.prisma.booking.findUnique({
where: { id: booking.id },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
priceTier: { select: { priceMinor: true } },
},
});
if (refreshed) Object.assign(booking, refreshed);
}
const outboundSegment = resolveBookingSegment(
(booking as any).schedule,
(booking as any).originStationId,
(booking as any).destinationStationId,
);
const returnSegment = (booking as any).returnSchedule
? resolveBookingSegment(
(booking as any).returnSchedule,
(booking as any).returnOriginStationId,
(booking as any).returnDestinationStationId,
)
: null;
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
bookingType: booking.bookingType,
packageId: (booking as any).packageId ?? null,
isPackageBooking: !!(booking as any).packageId,
returnLegStatus: (booking as any).returnLegStatus ?? null,
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (booking as any).returnBoardedAt ?? null,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
createdAt: booking.createdAt,
schedule: {
id: (booking as any).schedule.id,
trainNumber: (booking as any).schedule.train.number,
trainName: (booking as any).schedule.train.name,
origin: { id: outboundSegment.origin.id, name: outboundSegment.origin.name, code: outboundSegment.origin.code, city: outboundSegment.origin.city },
destination: { id: outboundSegment.destination.id, name: outboundSegment.destination.name, code: outboundSegment.destination.code, city: outboundSegment.destination.city },
departureAt: outboundSegment.departureAt, arrivalAt: outboundSegment.arrivalAt,
},
returnSchedule: (booking as any).returnSchedule
? {
id: (booking as any).returnSchedule.id,
trainNumber: (booking as any).returnSchedule.train.number,
trainName: (booking as any).returnSchedule.train.name,
origin: { id: returnSegment!.origin.id, name: returnSegment!.origin.name, code: returnSegment!.origin.code, city: returnSegment!.origin.city },
destination: { id: returnSegment!.destination.id, name: returnSegment!.destination.name, code: returnSegment!.destination.code, city: returnSegment!.destination.city },
departureAt: returnSegment!.departureAt, arrivalAt: returnSegment!.arrivalAt,
}
: null,
passengers: (booking as any).seats?.map((bs: any) => {
// A coach type can have several seat classes (e.g. a VIP Bed coach has separate
// Upper/Lower classes) — seatClasses[0] is whichever was seeded first, so it always
// showed the SAME class for every seat in the coach regardless of that seat's own
// bed position. Match against the seat's actual bedPosition instead (Seat.bedPosition
// is lowercase, SeatClass.bedPosition is uppercase — compare case-insensitively).
// Falls back to [0] for non-bed seats (bedPosition is null, single class per coach).
const classes = bs.seat.coach.coachType?.seatClasses ?? [];
const matchedClass = bs.seat.bedPosition
? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === bs.seat.bedPosition.toLowerCase())
: null;
return {
fullName: bs.passengerName,
category: bs.passengerCategory,
leg: bs.leg ?? 1,
fareMinor: bs.fareMinor,
verifaydaVerified: bs.verifaydaVerified,
seat: {
id: bs.seat.id,
number: bs.seat.seatNumber,
coach: bs.seat.coach.number,
coachId: bs.seat.coach.id,
seatClass: (matchedClass ?? classes[0])?.name ?? null,
},
};
}),
payment: (booking as any).paymentIntent
? {
method: (booking as any).paymentIntent.method,
status: (booking as any).paymentIntent.status,
amountMinor: (booking as any).paymentIntent.amountMinor,
currency: (booking as any).paymentIntent.currency,
}
: undefined,
// One ticket per passenger per leg (round trips have a separate ticket — and
// barcode — for the return leg) — matched on the frontend by passengerName +
// leg, not array position, since tickets are grouped/created independently of
// the passengers array.
tickets: (booking as any).tickets?.map((t: any) => ({
id: t.id,
passengerName: t.passengerName,
leg: t.leg ?? 1,
qrPayload: t.qrPayload,
barcodePayload: t.barcodePayload,
status: t.status,
})) ?? [],
};
}
async modify(dto: ModifyBookingDto, iamUserId?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, schedule: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status !== 'CONFIRMED') throw new BadRequestException('Only confirmed bookings can be modified');
if (booking.schedule.departureAt < new Date()) throw new BadRequestException('Cannot modify past bookings');
const oldSeats = booking.seats.map(s => s.seatId);
await this.prisma.bookingModification.create({
data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason },
});
await this.seatsService.releaseSeats(booking.id);
await this.seatsService.confirmSeats(dto.newSeatIds);
await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'UPDATE', entityType: 'Booking', entityId: booking.id, oldData: { seatIds: oldSeats }, newData: { seatIds: dto.newSeatIds, reason: dto.reason } });
return { modified: true, bookingRef: dto.bookingRef };
}
async cancel(bookingRef: string, reason?: string, iamUserId?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0;
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
await this.seatsService.releaseSeats(booking.id);
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
this.eventEmitter.emit('booking.cancelled', { booking, refundAmount });
await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'DELETE', entityType: 'Booking', entityId: booking.id, oldData: { bookingRef, status: booking.status }, newData: { status: 'CANCELLED', reason, refundAmount } });
return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency};
}
async update(id: string, dto: any) {
const booking = await this.prisma.booking.findUnique({ where: { id } });
if (!booking) throw new NotFoundException('Booking not found');
return this.prisma.booking.update({
where: { id },
data: {
status: dto.status || booking.status,
totalMinor: dto.totalMinor !== undefined ? dto.totalMinor : booking.totalMinor,
displayCurrency: dto.displayCurrency || booking.displayCurrency,
displayTotalMinor: dto.displayTotalMinor !== undefined ? dto.displayTotalMinor : booking.displayTotalMinor,
},
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
},
});
}
async delete(id: string, cascade = false) {
const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (!cascade) {
const usage = await this.checkBookingUsage(id);
if (usage.isInUse && usage.constraints) {
throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints);
}
}
await this.seatsService.releaseSeats(booking.id);
if (cascade) {
// Delete all child records that reference this booking (no onDelete: Cascade in schema)
const paymentIntent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: id } });
if (paymentIntent) {
await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: paymentIntent.id } });
await this.prisma.paymentIntent.delete({ where: { bookingId: id } });
}
const tickets = await this.prisma.ticket.findMany({ where: { bookingId: id }, select: { id: true } });
for (const t of tickets) {
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: t.id } });
}
await this.prisma.ticket.deleteMany({ where: { bookingId: id } });
await this.prisma.bookingModification.deleteMany({ where: { bookingId: id } });
await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: id } });
await this.prisma.agentBooking.deleteMany({ where: { bookingId: id } });
const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: id }, select: { id: true } });
for (const fo of foodOrders) {
await this.prisma.foodOrderItem.deleteMany({ where: { orderId: fo.id } });
}
await this.prisma.foodOrder.deleteMany({ where: { bookingId: id } });
await this.prisma.baggageBooking.deleteMany({ where: { bookingId: id } });
await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: id } });
const journey = await this.prisma.journey.findUnique({ where: { bookingId: id } });
if (journey) {
await this.prisma.journeySegment.deleteMany({ where: { journeyId: journey.id } });
await this.prisma.journey.delete({ where: { bookingId: id } });
}
}
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
await this.prisma.booking.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'Booking', entityId: id, oldData: { bookingRef: booking.bookingRef } });
return { deleted: true, bookingRef: booking.bookingRef };
}
async checkBookingUsage(id: string) {
const booking = await this.prisma.booking.findUnique({ where: { id } });
if (!booking) throw new NotFoundException('Booking not found');
const [ticketCount, paymentIntentCount, modificationsCount, cancellationCount] = await Promise.all([
this.prisma.ticket.count({ where: { bookingId: id } }),
this.prisma.paymentIntent.count({ where: { bookingId: id } }),
this.prisma.bookingModification.count({ where: { bookingId: id } }),
this.prisma.bookingCancellation.count({ where: { bookingId: id } }),
]);
const constraints = [];
if (ticketCount > 0) constraints.push({ entityName: 'ticket', count: ticketCount, action: 'complete' as const });
if (paymentIntentCount > 0) constraints.push({ entityName: 'payment record', count: paymentIntentCount, action: 'complete' as const });
if (modificationsCount > 0) constraints.push({ entityName: 'modification record', count: modificationsCount, action: 'complete' as const });
if (cancellationCount > 0) constraints.push({ entityName: 'cancellation record', count: cancellationCount, action: 'complete' as const });
return {
isInUse: constraints.length > 0,
affectedModules: constraints.map(c => `${c.count} ${c.entityName}${c.count > 1 ? 's' : ''}`),
constraints
};
}
@Cron(CronExpression.EVERY_MINUTE)
async expirePendingBookings() {
const cutoff = new Date(Date.now() - 20 * 60 * 1000);
const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
for (const b of expired) {
await this.seatsService.releaseSeats(b.id);
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
}
}
// Mark round-trip bookings where the return train has departed but the return leg
// was never scanned. Runs every minute; only acts on CONFIRMED bookings whose
// returnSchedule.departureAt is in the past and returnBoardedAt is still null.
@Cron(CronExpression.EVERY_MINUTE)
async markReturnLegNoShows() {
const now = new Date();
const graceCutoff = new Date(now.getTime() - 30 * 60 * 1000);
const candidates = await this.prisma.booking.findMany({
where: {
bookingType: 'ROUND_TRIP',
status: 'CONFIRMED',
returnLegStatus: 'NEITHER_USED' as any,
outboundBoardedAt: { not: null },
returnBoardedAt: null,
returnScheduleId: { not: null },
},
include: { returnSchedule: { select: { departureAt: true } } },
} as any);
for (const b of candidates) {
const returnDep: Date | undefined = (b as any).returnSchedule?.departureAt;
if (returnDep && returnDep < graceCutoff) {
await this.prisma.booking.update({
where: { id: b.id },
data: { returnLegStatus: 'OUTBOUND_ONLY' } as any,
});
}
}
}
private selectBestFareRule(
candidates: any[],
scheduleId: string,
segmentRoute?: string,
fullRoute?: string,
nationality?: string,
): any | null {
const priorities = [
{ tripId: scheduleId, route: segmentRoute, nationality },
{ tripId: scheduleId, route: segmentRoute, nationality: null },
{ tripId: scheduleId, route: fullRoute, nationality },
{ tripId: scheduleId, route: fullRoute, nationality: null },
{ tripId: scheduleId, route: null, nationality },
{ tripId: scheduleId, route: null, nationality: null },
{ tripId: null, route: segmentRoute, nationality },
{ tripId: null, route: segmentRoute, nationality: null },
{ tripId: null, route: fullRoute, nationality },
{ tripId: null, route: fullRoute, nationality: null },
{ tripId: null, route: null, nationality },
{ tripId: null, route: null, nationality: null },
];
for (const priority of priorities) {
const match = candidates.find(
(c) =>
c.tripId === priority.tripId &&
c.route === priority.route &&
c.nationality === priority.nationality,
);
if (match) return match;
}
return null;
}
}