mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1090 lines
53 KiB
TypeScript
1090 lines
53 KiB
TypeScript
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||
import { PrismaService } from '../../common/prisma.service';
|
||
import { SeatsService } from '../seats/seats.service';
|
||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||
import { CurrencyService } from '../currency/currency.service';
|
||
import { PassengerAuthService } from '../auth/passenger-auth.service';
|
||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||
|
||
/** Booking cutoff: reject new bookings within this many ms of departure. */
|
||
const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
|
||
|
||
function generateRef(): string {
|
||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||
}
|
||
|
||
// Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx)
|
||
const ETH_MOBILE_PREFIXES = ['911','912','913','914','915','916','917','921','922','923','924','930','931','932','933','934','935','936','937','938','939','961','962','963','964'];
|
||
|
||
function generateEthiopianPhone(): string {
|
||
const prefix = ETH_MOBILE_PREFIXES[Math.floor(Math.random() * ETH_MOBILE_PREFIXES.length)];
|
||
const suffix = String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0');
|
||
return `+251${prefix}${suffix}`;
|
||
}
|
||
|
||
function generateGuestEmail(uniqueId: string): string {
|
||
const domains = ['gmail.com', 'yahoo.com', 'ethionet.et', 'telecom.et'];
|
||
const domain = domains[Math.floor(Math.random() * domains.length)];
|
||
return `guest.edr.${uniqueId}@${domain}`;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
@Injectable()
|
||
export class GuestBookingService {
|
||
constructor(
|
||
private prisma: PrismaService,
|
||
private seatsService: SeatsService,
|
||
private verifaydaService: VerifaydaService,
|
||
private currencyService: CurrencyService,
|
||
private passengerAuthService: PassengerAuthService,
|
||
private fareEngine: FareEngineService,
|
||
private eventEmitter: EventEmitter2,
|
||
) {}
|
||
|
||
async createGuestBooking(dto: CreateGuestBookingDto, req?: any) {
|
||
if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req);
|
||
if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req);
|
||
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto, req);
|
||
return this.createGuestOneWayBooking(dto, req);
|
||
}
|
||
|
||
private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) {
|
||
// Validate hold
|
||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||
if (!hold || hold.expiresAt < new Date()) {
|
||
throw new BadRequestException('Seat hold expired or not found');
|
||
}
|
||
|
||
// Get schedule
|
||
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');
|
||
|
||
if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||
}
|
||
|
||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId)
|
||
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined);
|
||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId)
|
||
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined);
|
||
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
||
|
||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||
|
||
// Process passengers with Verifayda verification
|
||
const passengersData = [];
|
||
let adultCount = 0, childCount = 0;
|
||
|
||
for (const passenger of dto.passengers) {
|
||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||
const age = calculateAge(dateOfBirth);
|
||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
|
||
|
||
let passengerName = passenger.passengerName;
|
||
let verifaydaVerified = false;
|
||
let verifaydaData: Record<string, any> | undefined;
|
||
let nationality = passenger.nationality;
|
||
|
||
const isEthiopian = passenger.nationality === 'Ethiopian' ||
|
||
passenger.nationality === 'ETHIOPIAN' ||
|
||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||
|
||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||
if (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 = 'Ethiopian';
|
||
} else if (!isEthiopian && 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');
|
||
} else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||
nationality = 'Ethiopian';
|
||
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||
nationality = nationality || 'Other';
|
||
}
|
||
|
||
passengersData.push({
|
||
...passenger,
|
||
passengerName,
|
||
dateOfBirth,
|
||
category,
|
||
verifaydaVerified,
|
||
verifaydaData,
|
||
nationality,
|
||
});
|
||
}
|
||
|
||
// Calculate fare — package bookings use the fixed tier price, bypassing the fare engine
|
||
const isPackageOneway = !!dto.packageId && !!dto.priceTierId;
|
||
let baseFareMinor: number;
|
||
let paidChildrenCount: number;
|
||
let childUnitFare: number;
|
||
|
||
if (isPackageOneway) {
|
||
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } });
|
||
baseFareMinor = tier.priceMinor;
|
||
paidChildrenCount = childCount;
|
||
childUnitFare = Math.round(baseFareMinor * 0.1);
|
||
} else {
|
||
const primaryNationality = passengersData[0]?.nationality;
|
||
baseFareMinor = await this.getBaseFare(
|
||
dto.scheduleId,
|
||
dto.seatClassId,
|
||
segmentRoute,
|
||
fullRoute,
|
||
primaryNationality,
|
||
dto.originStationId,
|
||
dto.destinationStationId,
|
||
);
|
||
paidChildrenCount = Math.max(0, childCount - 1);
|
||
childUnitFare = baseFareMinor;
|
||
}
|
||
|
||
const adultFareMinor = baseFareMinor * adultCount;
|
||
const childFareMinor = childUnitFare * paidChildrenCount;
|
||
const totalBaseFareMinor = adultFareMinor + childFareMinor;
|
||
|
||
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(totalBaseFareMinor * promo.percentOff / 100)
|
||
: (promo.amountOffMinor ?? 0);
|
||
}
|
||
}
|
||
|
||
const taxesMinor = 0;
|
||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
|
||
|
||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||
let displayTotalMinor = totalMinor;
|
||
if (displayCurrency !== Currency.ETB) {
|
||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||
}
|
||
|
||
// Resolve or create the guest Passenger record
|
||
const firstPassenger = passengersData[0];
|
||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req);
|
||
|
||
// Save passenger details for future use (if requested)
|
||
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
|
||
for (const passenger of passengersData) {
|
||
await this.prisma.savedPassengerProfile.create({
|
||
data: {
|
||
userId: iamUserId ?? undefined,
|
||
deviceId: dto.deviceId,
|
||
passengerName: passenger.passengerName,
|
||
dateOfBirth: passenger.dateOfBirth,
|
||
idDocumentType: passenger.idDocumentType,
|
||
passportNumber: passenger.passportNumber,
|
||
passportCountry: passenger.passportCountry,
|
||
nationality: passenger.nationality,
|
||
phone: passenger.phone,
|
||
email: passenger.email,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
// Create booking
|
||
const booking = await this.prisma.booking.create({
|
||
data: {
|
||
bookingRef: generateRef(),
|
||
passengerId: guestPassengerId,
|
||
scheduleId: dto.scheduleId,
|
||
status: 'PENDING_PAYMENT',
|
||
totalMinor,
|
||
adultCount,
|
||
childCount,
|
||
displayCurrency,
|
||
displayTotalMinor,
|
||
bookingType: 'ONE_WAY',
|
||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||
userAgent: dto.deviceId,
|
||
contactEmail: firstPassenger.email || null,
|
||
contactPhone: firstPassenger.phone || null,
|
||
seats: {
|
||
create: passengersData.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 || undefined,
|
||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
|
||
displayCurrency,
|
||
})),
|
||
},
|
||
},
|
||
include: {
|
||
seats: { include: { seat: { include: { coach: true } } } },
|
||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||
},
|
||
});
|
||
|
||
// Save passenger details as traveler profiles
|
||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||
|
||
// Confirm seats
|
||
await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId));
|
||
this.eventEmitter.emit('booking.created', { booking });
|
||
|
||
return {
|
||
...booking,
|
||
createdAccount,
|
||
iamUserId,
|
||
fareBreakdown: {
|
||
baseFareMinor,
|
||
adultCount,
|
||
adultFareMinor,
|
||
childCount,
|
||
freeChildrenCount: isPackageOneway ? 0 : Math.min(childCount, 1),
|
||
paidChildrenCount,
|
||
childFareMinor,
|
||
totalBaseFareMinor,
|
||
discountMinor,
|
||
taxesFeesMinor: taxesMinor,
|
||
totalMinor,
|
||
currency: 'ETB',
|
||
displayCurrency,
|
||
displayTotalMinor,
|
||
},
|
||
};
|
||
}
|
||
|
||
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) {
|
||
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
|
||
}
|
||
|
||
// Validate both holds
|
||
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 or not found');
|
||
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found');
|
||
|
||
// Validate passengers have returnSeatId
|
||
for (const p of dto.passengers) {
|
||
if (!p.returnSeatId) throw new BadRequestException(`returnSeatId is required for each passenger in a ROUND_TRIP booking (missing for ${p.passengerName})`);
|
||
}
|
||
|
||
// Load both schedules
|
||
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) throw new NotFoundException('Outbound schedule not found');
|
||
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
|
||
|
||
if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||
}
|
||
|
||
const synth = (sched: any, stationId: string, seq: number) => {
|
||
const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation;
|
||
return { stationId, sequence: seq, station };
|
||
};
|
||
const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)];
|
||
const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)];
|
||
const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0];
|
||
const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1];
|
||
const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0];
|
||
const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1];
|
||
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
|
||
if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
|
||
|
||
const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`;
|
||
const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
|
||
const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
|
||
const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`;
|
||
|
||
// Process passengers (verify identity once — same person travels both legs)
|
||
const passengersData: any[] = [];
|
||
let adultCount = 0, childCount = 0;
|
||
|
||
for (const passenger of dto.passengers) {
|
||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||
const age = calculateAge(dateOfBirth);
|
||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
|
||
|
||
let passengerName = passenger.passengerName;
|
||
let verifaydaVerified = false;
|
||
let verifaydaData: Record<string, any> | undefined;
|
||
let nationality = passenger.nationality;
|
||
|
||
const isEthiopian = passenger.nationality === 'Ethiopian' ||
|
||
passenger.nationality === 'ETHIOPIAN' ||
|
||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||
|
||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||
if (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 = 'Ethiopian';
|
||
} else if (!isEthiopian && 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');
|
||
} else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||
nationality = 'Ethiopian';
|
||
} else {
|
||
nationality = nationality || 'Other';
|
||
}
|
||
|
||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||
}
|
||
|
||
// Calculate fares for both legs — package bookings use the fixed tier price split across legs
|
||
const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
|
||
const isPackageRoundTrip = !!dto.packageId && !!dto.priceTierId;
|
||
let outboundBaseFare: number;
|
||
let returnBaseFare: number;
|
||
let paidChildrenCount: number;
|
||
let outboundChildUnitFare: number;
|
||
let returnChildUnitFare: number;
|
||
|
||
if (isPackageRoundTrip) {
|
||
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } });
|
||
// tier.priceMinor is the full round-trip price per adult; split evenly across legs
|
||
const halfMinor = Math.round(tier.priceMinor / 2);
|
||
outboundBaseFare = halfMinor;
|
||
returnBaseFare = tier.priceMinor - halfMinor;
|
||
paidChildrenCount = childCount;
|
||
outboundChildUnitFare = Math.round(outboundBaseFare * 0.1);
|
||
returnChildUnitFare = Math.round(returnBaseFare * 0.1);
|
||
} else {
|
||
const primaryNationality = passengersData[0]?.nationality;
|
||
[outboundBaseFare, returnBaseFare] = await Promise.all([
|
||
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
|
||
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
|
||
]);
|
||
paidChildrenCount = Math.max(0, childCount - 1);
|
||
outboundChildUnitFare = outboundBaseFare;
|
||
returnChildUnitFare = returnBaseFare;
|
||
}
|
||
const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount;
|
||
const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount;
|
||
const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
|
||
|
||
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(combinedBaseFareMinor * promo.percentOff / 100)
|
||
: (promo.amountOffMinor ?? 0);
|
||
}
|
||
}
|
||
|
||
const taxesMinor = 0;
|
||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
||
|
||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||
: totalMinor;
|
||
|
||
// Create or resolve guest passenger (same as one-way)
|
||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||
|
||
// Create booking with outbound seats; return seats confirmed separately
|
||
const outboundSeatIds = dto.passengers.map(p => p.seatId);
|
||
const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
|
||
|
||
const booking = await this.prisma.booking.create({
|
||
data: {
|
||
bookingRef: generateRef(),
|
||
passengerId: guestPassengerId,
|
||
scheduleId: dto.scheduleId,
|
||
status: 'PENDING_PAYMENT',
|
||
bookingType: 'ROUND_TRIP',
|
||
totalMinor,
|
||
adultCount,
|
||
childCount,
|
||
displayCurrency,
|
||
displayTotalMinor,
|
||
returnScheduleId: dto.returnScheduleId,
|
||
returnOriginStationId: dto.returnOriginStationId,
|
||
returnDestinationStationId: dto.returnDestinationStationId,
|
||
returnHoldId: dto.returnHoldId,
|
||
returnSeatClassId,
|
||
returnLegStatus: 'NEITHER_USED',
|
||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||
userAgent: dto.deviceId,
|
||
contactEmail: passengersData[0]?.email || null,
|
||
contactPhone: passengersData[0]?.phone || null,
|
||
seats: {
|
||
create: [
|
||
...passengersData.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 || undefined,
|
||
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
|
||
displayCurrency,
|
||
})),
|
||
...passengersData.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 || undefined,
|
||
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
|
||
displayCurrency,
|
||
})),
|
||
],
|
||
},
|
||
} as any,
|
||
include: {
|
||
seats: { include: { seat: { include: { coach: true } } } },
|
||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||
},
|
||
});
|
||
|
||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||
|
||
await Promise.all([
|
||
this.seatsService.confirmSeats(outboundSeatIds),
|
||
this.seatsService.confirmSeats(returnSeatIds),
|
||
]);
|
||
this.eventEmitter.emit('booking.created', { booking });
|
||
|
||
return {
|
||
...booking,
|
||
createdAccount,
|
||
iamUserId,
|
||
fareBreakdown: {
|
||
outboundBaseFareMinor: outboundBaseFare,
|
||
returnBaseFareMinor: returnBaseFare,
|
||
adultCount,
|
||
childCount,
|
||
freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1),
|
||
paidChildrenCount,
|
||
combinedBaseFareMinor,
|
||
discountMinor,
|
||
taxesFeesMinor: taxesMinor,
|
||
totalMinor,
|
||
currency: 'ETB',
|
||
displayCurrency,
|
||
displayTotalMinor,
|
||
},
|
||
};
|
||
}
|
||
|
||
private async createGuestTransitBooking(dto: CreateGuestBookingDto, req?: any) {
|
||
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 or not found');
|
||
if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired or not found');
|
||
|
||
for (const p of dto.passengers) {
|
||
if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId is required for each passenger in a TRANSIT booking (missing for ${p.passengerName})`);
|
||
}
|
||
|
||
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');
|
||
|
||
if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||
}
|
||
|
||
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 not found on leg-2 schedule');
|
||
|
||
// Process passengers (verify identity once)
|
||
const passengersData: any[] = [];
|
||
let adultCount = 0, childCount = 0;
|
||
for (const passenger of dto.passengers) {
|
||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||
const age = calculateAge(dateOfBirth);
|
||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
|
||
|
||
let passengerName = passenger.passengerName;
|
||
let verifaydaVerified = false;
|
||
let verifaydaData: Record<string, any> | undefined;
|
||
let nationality = passenger.nationality;
|
||
|
||
const isEthiopian = passenger.nationality === 'Ethiopian' || passenger.nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||
if (isEthiopian && 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 = 'Ethiopian';
|
||
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`);
|
||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||
} else {
|
||
nationality = nationality || 'Other';
|
||
}
|
||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||
}
|
||
|
||
const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId;
|
||
const primaryNationality = passengersData[0]?.nationality;
|
||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||
|
||
const [leg1BaseFare, leg2BaseFare] = await Promise.all([
|
||
this.getBaseFare(dto.scheduleId, dto.seatClassId,
|
||
`${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
|
||
`${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
|
||
primaryNationality, dto.originStationId, dto.transitStationId),
|
||
this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
|
||
`${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
|
||
`${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
|
||
primaryNationality, dto.transitStationId, dto.leg2DestinationStationId),
|
||
]);
|
||
|
||
const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
|
||
const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount;
|
||
const combinedBase = leg1Total + leg2Total;
|
||
|
||
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 taxesMinor = 0;
|
||
const totalMinor = Math.max(0, combinedBase - discountMinor);
|
||
|
||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||
: totalMinor;
|
||
|
||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||
|
||
// 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: guestPassengerId,
|
||
scheduleId: dto.scheduleId,
|
||
status: 'PENDING_PAYMENT',
|
||
bookingType: 'TRANSIT',
|
||
totalMinor,
|
||
adultCount,
|
||
childCount,
|
||
displayCurrency,
|
||
displayTotalMinor,
|
||
leg2ScheduleId: dto.leg2ScheduleId,
|
||
leg2OriginStationId: dto.transitStationId,
|
||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||
leg2SeatClassId: leg2SeatClassId,
|
||
userAgent: dto.deviceId,
|
||
contactEmail: passengersData[0]?.email || null,
|
||
contactPhone: passengersData[0]?.phone || null,
|
||
seats: {
|
||
create: [
|
||
...passengersData.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 || undefined,
|
||
fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0),
|
||
displayCurrency,
|
||
})),
|
||
...passengersData.map(p => ({
|
||
seat: { connect: { id: p.leg2SeatId! } },
|
||
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 || undefined,
|
||
fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0),
|
||
displayCurrency,
|
||
})),
|
||
],
|
||
},
|
||
} as any,
|
||
include: {
|
||
seats: { include: { seat: { include: { coach: true } } } },
|
||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||
},
|
||
});
|
||
|
||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||
|
||
await Promise.all([
|
||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
|
||
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
|
||
]);
|
||
this.eventEmitter.emit('booking.created', { booking });
|
||
|
||
return {
|
||
...booking,
|
||
createdAccount,
|
||
iamUserId,
|
||
fareBreakdown: {
|
||
leg1BaseFareMinor: leg1BaseFare,
|
||
leg2BaseFareMinor: leg2BaseFare,
|
||
adultCount, childCount,
|
||
freeChildrenCount: Math.min(childCount, 1),
|
||
paidChildrenCount,
|
||
combinedBaseFareMinor: combinedBase,
|
||
discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
|
||
currency: 'ETB', displayCurrency, displayTotalMinor,
|
||
},
|
||
};
|
||
}
|
||
|
||
private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) {
|
||
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 all 4 holds and all transit/return station fields',
|
||
);
|
||
}
|
||
for (const p of dto.passengers) {
|
||
if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`);
|
||
if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`);
|
||
if (!p.returnLeg2SeatId) throw new BadRequestException(`returnLeg2SeatId required for ${p.passengerName}`);
|
||
}
|
||
|
||
const now = new Date();
|
||
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 } }),
|
||
]);
|
||
if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired');
|
||
if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired');
|
||
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 hold expired');
|
||
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired');
|
||
|
||
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');
|
||
|
||
if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||
}
|
||
|
||
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 stop not found');
|
||
if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found');
|
||
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found');
|
||
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found');
|
||
|
||
// Process passengers (verify once)
|
||
const passengersData: any[] = [];
|
||
let adultCount = 0, childCount = 0;
|
||
for (const passenger of dto.passengers) {
|
||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||
const category: PassengerCategory = calculateAge(dateOfBirth) < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
|
||
let passengerName = passenger.passengerName;
|
||
let verifaydaVerified = false;
|
||
let verifaydaData: Record<string, any> | undefined;
|
||
let nationality = passenger.nationality;
|
||
const isEthiopian = nationality === 'Ethiopian' || nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
|
||
const v = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||
if (!v.verified) throw new BadRequestException(`Verifayda failed for ${passenger.passengerName}: ${v.failureReason}`);
|
||
passengerName = v.passengerData?.fullName || passengerName;
|
||
verifaydaVerified = true;
|
||
verifaydaData = v.passengerData?.profileData;
|
||
nationality = 'Ethiopian';
|
||
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`);
|
||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||
} else {
|
||
nationality = nationality || 'Other';
|
||
}
|
||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||
}
|
||
|
||
const nat = passengersData[0]?.nationality;
|
||
const paidChildren = Math.max(0, childCount - 1);
|
||
const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId;
|
||
const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId;
|
||
const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
|
||
|
||
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
|
||
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId),
|
||
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId),
|
||
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId),
|
||
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId),
|
||
]);
|
||
|
||
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
|
||
(obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren;
|
||
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 taxesMinor = Math.round(combinedBase * 0.05);
|
||
const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
|
||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||
: totalMinor;
|
||
|
||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||
|
||
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: 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 || undefined,
|
||
fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0),
|
||
displayCurrency,
|
||
});
|
||
|
||
const booking = await this.prisma.booking.create({
|
||
data: {
|
||
bookingRef: generateRef(),
|
||
passengerId: guestPassengerId,
|
||
scheduleId: dto.scheduleId,
|
||
status: 'PENDING_PAYMENT',
|
||
bookingType: 'ROUND_TRIP_TRANSIT',
|
||
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||
leg2ScheduleId: dto.leg2ScheduleId,
|
||
leg2OriginStationId: dto.transitStationId,
|
||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||
leg2SeatClassId: obL2ClassId,
|
||
returnScheduleId: dto.returnScheduleId,
|
||
returnOriginStationId: dto.returnOriginStationId,
|
||
returnDestinationStationId: dto.returnDestinationStationId,
|
||
returnSeatClassId: retL1ClassId,
|
||
returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
|
||
returnLeg2OriginStationId: dto.returnTransitStationId,
|
||
returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
|
||
returnLeg2SeatClassId: retL2ClassId,
|
||
returnLegStatus: 'NEITHER_USED',
|
||
userAgent: dto.deviceId,
|
||
contactEmail: passengersData[0]?.email || null,
|
||
contactPhone: passengersData[0]?.phone || null,
|
||
seats: {
|
||
create: [
|
||
...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
|
||
...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)),
|
||
...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)),
|
||
...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!,4, dto.returnLeg2ScheduleId!,retL2Fare)),
|
||
],
|
||
},
|
||
} as any,
|
||
include: {
|
||
seats: { include: { seat: { include: { coach: true } } } },
|
||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||
},
|
||
});
|
||
|
||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||
|
||
await Promise.all([
|
||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
|
||
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
|
||
this.seatsService.confirmSeats(dto.passengers.map(p => p.returnSeatId!)),
|
||
this.seatsService.confirmSeats(dto.passengers.map(p => p.returnLeg2SeatId!)),
|
||
]);
|
||
this.eventEmitter.emit('booking.created', { booking });
|
||
|
||
return {
|
||
...booking,
|
||
createdAccount,
|
||
iamUserId,
|
||
fareBreakdown: {
|
||
outboundLeg1FareMinor: obL1Fare,
|
||
outboundLeg2FareMinor: obL2Fare,
|
||
returnLeg1FareMinor: retL1Fare,
|
||
returnLeg2FareMinor: retL2Fare,
|
||
adultCount, childCount,
|
||
freeChildrenCount: Math.min(childCount, 1),
|
||
paidChildrenCount: paidChildren,
|
||
combinedBaseFareMinor: combinedBase,
|
||
discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
|
||
currency: 'ETB', displayCurrency, displayTotalMinor,
|
||
},
|
||
};
|
||
}
|
||
|
||
private async resolveGuestPassenger(
|
||
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
|
||
firstPassenger: any,
|
||
req?: any,
|
||
): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> {
|
||
if (dto.createAccount && firstPassenger.email && dto.password) {
|
||
const guestName = firstPassenger.passengerName ?? 'Guest';
|
||
const result = await this.passengerAuthService.registerWithPassword(
|
||
{
|
||
email: firstPassenger.email,
|
||
username: firstPassenger.email,
|
||
phoneNumber: firstPassenger.phone || `+251900000000`,
|
||
name: { en: guestName, am: guestName },
|
||
password: dto.password,
|
||
},
|
||
req,
|
||
);
|
||
return { guestPassengerId: result.passengerId, iamUserId: result.iamUserId, createdAccount: true };
|
||
}
|
||
|
||
// Create guest passenger with basic profile
|
||
const guestPassenger = await this.prisma.passenger.create({ data: {} });
|
||
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
|
||
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
|
||
|
||
return { guestPassengerId: guestPassenger.id, iamUserId: null, createdAccount: false };
|
||
}
|
||
|
||
private async createTravelerProfiles(passengerId: string, passengersData: any[]): Promise<void> {
|
||
for (const passenger of passengersData) {
|
||
let gender: string | null = null;
|
||
if (passenger.verifaydaData && typeof passenger.verifaydaData === 'object') {
|
||
gender = passenger.verifaydaData.gender || passenger.verifaydaData.Gender || null;
|
||
}
|
||
|
||
await this.prisma.travelerProfile.create({
|
||
data: {
|
||
passengerId,
|
||
fullName: passenger.passengerName,
|
||
gender,
|
||
dateOfBirth: passenger.dateOfBirth,
|
||
nationalId: passenger.idDocumentType === IdDocumentType.NATIONAL_ID ? passenger.idDocumentNumber : null,
|
||
relationship: 'self',
|
||
notes: JSON.stringify({
|
||
idDocumentType: passenger.idDocumentType,
|
||
idDocumentNumber: passenger.idDocumentNumber,
|
||
passportNumber: passenger.passportNumber,
|
||
passportCountry: passenger.passportCountry,
|
||
nationality: passenger.nationality,
|
||
phone: passenger.phone,
|
||
email: passenger.email,
|
||
verifaydaVerified: passenger.verifaydaVerified,
|
||
}),
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> {
|
||
if (!userId && !deviceId) {
|
||
throw new BadRequestException('Either userId or deviceId is required');
|
||
}
|
||
|
||
const profiles = await this.prisma.savedPassengerProfile.findMany({
|
||
where: {
|
||
OR: [
|
||
userId ? { userId } : {},
|
||
deviceId ? { deviceId } : {},
|
||
],
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
});
|
||
|
||
return profiles.map((p: any) => ({
|
||
passengerName: p.passengerName,
|
||
dateOfBirth: p.dateOfBirth.toISOString().split('T')[0],
|
||
idDocumentType: p.idDocumentType,
|
||
idDocumentNumber: undefined,
|
||
passportNumber: p.passportNumber || undefined,
|
||
passportCountry: p.passportCountry || undefined,
|
||
nationality: p.nationality || undefined,
|
||
phone: p.phone || undefined,
|
||
email: p.email || undefined,
|
||
}));
|
||
}
|
||
|
||
private async getBaseFare(
|
||
scheduleId: string,
|
||
seatClassId: string,
|
||
segmentRoute?: string,
|
||
fullRoute?: string,
|
||
nationality?: string,
|
||
originStationId?: string,
|
||
destinationStationId?: string,
|
||
): Promise<number> {
|
||
const now = new Date();
|
||
|
||
// 1. FareRule table — explicit override rules (same priority logic as the fare engine)
|
||
const [candidates, seatClass] = await Promise.all([
|
||
this.prisma.fareRule.findMany({
|
||
where: {
|
||
seatClassId,
|
||
validFrom: { lte: now },
|
||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||
},
|
||
}),
|
||
this.prisma.seatClass.findUnique({
|
||
where: { id: seatClassId },
|
||
select: { premiumMinor: true, insuranceFeeMinor: true },
|
||
}),
|
||
]);
|
||
|
||
const premiumMinor = seatClass?.premiumMinor ?? 0;
|
||
const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0;
|
||
|
||
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,
|
||
);
|
||
// Return base fare + seat-class surcharges so the booking total matches the quoted fare
|
||
if (match) return match.baseFareMinor + premiumMinor + insuranceMinor;
|
||
}
|
||
|
||
// 2. FareEngine — distance × rate-per-km from the booking's actual segment stations
|
||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||
where: { id: scheduleId },
|
||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||
});
|
||
|
||
if (schedule?.routeId) {
|
||
try {
|
||
const fare = await this.fareEngine.calculate({
|
||
routeId: schedule.routeId,
|
||
// Use the booking's boarding/alighting stations so the distance reflects the
|
||
// passenger's actual segment, not the full schedule route.
|
||
originStationId: originStationId ?? schedule.originStationId,
|
||
destinationStationId: destinationStationId ?? schedule.destinationStationId,
|
||
seatClassId,
|
||
nationality,
|
||
});
|
||
// farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor
|
||
return fare.farePerPassengerMinor;
|
||
} 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.`,
|
||
);
|
||
}
|
||
}
|