mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1758 lines
81 KiB
TypeScript
1758 lines
81 KiB
TypeScript
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 { 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';
|
||
|
||
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;
|
||
}
|
||
|
||
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 eventEmitter: EventEmitter2,
|
||
private readonly verifaydaService: VerifaydaService,
|
||
private readonly currencyService: CurrencyService,
|
||
private readonly fareEngine: FareEngineService,
|
||
) {}
|
||
|
||
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 } },
|
||
paymentIntent: true,
|
||
seats: { include: { seat: true } },
|
||
priceTier: { select: { priceMinor: true } },
|
||
},
|
||
}),
|
||
this.prisma.booking.count({ where }),
|
||
]);
|
||
|
||
return {
|
||
items: items.map(booking => ({
|
||
id: booking.id,
|
||
bookingRef: booking.bookingRef,
|
||
status: booking.status,
|
||
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||
currency: 'ETB',
|
||
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: booking.schedule.originStation,
|
||
destinationStation: booking.schedule.destinationStation,
|
||
departureAt: booking.schedule.departureAt,
|
||
arrivalAt: booking.schedule.arrivalAt,
|
||
},
|
||
paymentIntent: booking.paymentIntent,
|
||
seatCount: booking.seats.length,
|
||
})),
|
||
meta: {
|
||
page,
|
||
pageSize,
|
||
total,
|
||
totalPages: Math.ceil(total / 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 } },
|
||
paymentIntent: true,
|
||
seats: { include: { seat: true } },
|
||
priceTier: { select: { priceMinor: true } },
|
||
},
|
||
}),
|
||
this.prisma.booking.count({ where }),
|
||
]);
|
||
|
||
return {
|
||
items: items.map(booking => ({
|
||
id: booking.id,
|
||
bookingRef: booking.bookingRef,
|
||
status: booking.status,
|
||
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||
currency: 'ETB',
|
||
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: booking.schedule.originStation,
|
||
destinationStation: booking.schedule.destinationStation,
|
||
departureAt: booking.schedule.departureAt,
|
||
arrivalAt: booking.schedule.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 } },
|
||
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());
|
||
return {
|
||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||
totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
|
||
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: booking.schedule ? {
|
||
train: booking.schedule.train,
|
||
originStation: booking.schedule.originStation,
|
||
destinationStation: booking.schedule.destinationStation,
|
||
departureAt: booking.schedule.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.totalMinor, currency: b.currency || 'ETB',
|
||
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: 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],
|
||
)
|
||
: [];
|
||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||
|
||
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 }));
|
||
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
|
||
return {
|
||
id: booking.id,
|
||
bookingRef: booking.bookingRef,
|
||
status: booking.status,
|
||
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||
currency: 'ETB',
|
||
displayCurrency: booking.displayCurrency,
|
||
displayTotalMinor: booking.displayTotalMinor,
|
||
contactEmail: booking.contactEmail,
|
||
contactPhone: booking.contactPhone,
|
||
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,
|
||
schedule: {
|
||
train: booking.schedule.train,
|
||
originStation: (booking as any).originStationId
|
||
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation)
|
||
: booking.schedule.originStation,
|
||
destinationStation: (booking as any).destinationStationId
|
||
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation)
|
||
: booking.schedule.destinationStation,
|
||
departureAt: booking.schedule.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.totalMinor,
|
||
currency: b.currency || 'ETB',
|
||
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) {
|
||
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.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
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 = await this.processPassengers(dto.passengers as any[]);
|
||
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.
|
||
let freeChildUsed = false;
|
||
let pkgChildIdx = 0;
|
||
const passengersWithFares = passengersData.map(p => {
|
||
let fareMinor: number;
|
||
if (p.category === PassengerCategory.ADULT) {
|
||
fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
|
||
} else if (dto.packageId) {
|
||
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor);
|
||
pkgChildIdx++;
|
||
} else {
|
||
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||
else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
|
||
}
|
||
return { ...p, fareMinor };
|
||
});
|
||
|
||
// Use the sum of per-seat fares as the authoritative total when the client supplied
|
||
// seatFareMinor for every seat-holding passenger — this captures berth-specific pricing
|
||
// (Upper/Middle/Lower) that the fare engine cannot resolve from seatClassId alone.
|
||
// Free children have no seatId and no seatFareMinor — exclude them from the check.
|
||
const seatedPassengers = passengersData.filter(p => p.seatId);
|
||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||
const resolvedTotalMinor = dto.reviewedTotalMinor ??
|
||
(allFaresProvided
|
||
? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
|
||
: fareCalculation.totalMinor);
|
||
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
|
||
|
||
let displayTotalMinor = resolvedTotalMinor;
|
||
if (displayCurrency !== Currency.ETB) {
|
||
displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
|
||
}
|
||
|
||
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 / 100,
|
||
adultCount,
|
||
childCount,
|
||
displayCurrency,
|
||
displayTotalMinor,
|
||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||
seats: {
|
||
create: passengersWithFares.map(p => ({
|
||
seat: { connect: { id: p.seatId } },
|
||
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 });
|
||
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 = await this.processRoundTripPassengers(dto.passengers as any[]);
|
||
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);
|
||
}
|
||
const taxesMinor = 0;
|
||
|
||
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
|
||
let displayTotalMinor = totalMinor;
|
||
if (displayCurrency !== Currency.ETB) {
|
||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||
}
|
||
|
||
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
|
||
// present (berth-specific pricing). Fall back to fare engine values.
|
||
let outboundFreeChildUsed = false;
|
||
let returnFreeChildUsed = false;
|
||
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 if (dto.packageId) {
|
||
outboundFareMinor = 0;
|
||
returnFareMinor = 0;
|
||
} else {
|
||
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||
else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
|
||
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||
else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
|
||
}
|
||
|
||
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.
|
||
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
|
||
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||
if (dto.reviewedTotalMinor) {
|
||
totalMinor = dto.reviewedTotalMinor;
|
||
displayTotalMinor = displayCurrency !== Currency.ETB
|
||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||
: totalMinor;
|
||
} else if (allRTFaresProvided && !dto.packageId) {
|
||
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||
if (displayCurrency !== Currency.ETB) {
|
||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||
} else {
|
||
displayTotalMinor = totalMinor;
|
||
}
|
||
}
|
||
|
||
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,
|
||
adultCount,
|
||
childCount,
|
||
displayCurrency,
|
||
displayTotalMinor,
|
||
returnScheduleId: dto.returnScheduleId,
|
||
returnOriginStationId: dto.returnOriginStationId,
|
||
returnDestinationStationId: dto.returnDestinationStationId,
|
||
returnHoldId: dto.returnHoldId,
|
||
returnSeatClassId: dto.returnSeatClassId,
|
||
returnLegStatus: 'NEITHER_USED',
|
||
...(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 });
|
||
|
||
return {
|
||
...booking,
|
||
fareBreakdown: {
|
||
outboundFare: outboundFare.baseFareMinor,
|
||
returnFare: returnFare.baseFareMinor,
|
||
combinedBaseFareMinor,
|
||
discountMinor,
|
||
loyaltyRedemptionMinor: loyaltyMinor,
|
||
taxesFeesMinor: taxesMinor,
|
||
totalMinor,
|
||
currency: 'ETB',
|
||
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 = await this.processPassengers(dto.passengers as any[]);
|
||
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,
|
||
adultCount,
|
||
childCount,
|
||
displayCurrency,
|
||
displayTotalMinor,
|
||
leg2ScheduleId: dto.leg2ScheduleId,
|
||
leg2OriginStationId: dto.transitStationId,
|
||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||
leg2SeatClassId,
|
||
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: 'ETB', 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 = await this.processRoundTripPassengers(dto.passengers as any[]);
|
||
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, adultCount, childCount, 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',
|
||
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: 'ETB', 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,
|
||
};
|
||
}
|
||
|
||
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.`,
|
||
);
|
||
}
|
||
|
||
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 } },
|
||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||
paymentIntent: true, tickets: { take: 1 },
|
||
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.totalMinor,
|
||
currency: pkgBooking.currency || 'ETB',
|
||
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.totalMinor / pkgBooking.passengerCount),
|
||
verifaydaVerified: false,
|
||
seat: null,
|
||
})),
|
||
payment: (pkgBooking as any).paymentIntent
|
||
? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status }
|
||
: undefined,
|
||
ticket: undefined,
|
||
};
|
||
}
|
||
|
||
return {
|
||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
|
||
adultCount: booking.adultCount, childCount: booking.childCount,
|
||
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
|
||
bookingType: booking.bookingType,
|
||
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: (booking as any).schedule.originStation.id, name: (booking as any).schedule.originStation.name, code: (booking as any).schedule.originStation.code, city: (booking as any).schedule.originStation.city },
|
||
destination: { id: (booking as any).schedule.destinationStation.id, name: (booking as any).schedule.destinationStation.name, code: (booking as any).schedule.destinationStation.code, city: (booking as any).schedule.destinationStation.city },
|
||
departureAt: (booking as any).schedule.departureAt, arrivalAt: (booking as any).schedule.arrivalAt,
|
||
},
|
||
passengers: (booking as any).seats?.map((bs: any) => ({
|
||
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: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null,
|
||
},
|
||
})),
|
||
payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status } : undefined,
|
||
ticket: (booking as any).tickets?.[0] ? { id: (booking as any).tickets[0].id, qrPayload: (booking as any).tickets[0].qrPayload, barcodePayload: (booking as any).tickets[0].barcodePayload, status: (booking as any).tickets[0].status } : undefined,
|
||
};
|
||
}
|
||
|
||
async modify(dto: ModifyBookingDto) {
|
||
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);
|
||
return { modified: true, bookingRef: dto.bookingRef };
|
||
}
|
||
|
||
async cancel(bookingRef: string, reason?: 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 });
|
||
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
|
||
}
|
||
|
||
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 } });
|
||
|
||
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;
|
||
}
|
||
}
|