import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { CurrencyService } from '../currency/currency.service'; import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto'; import { Currency } from '@prisma/client'; import { BookingsService } from '../bookings/bookings.service'; import { GuestBookingService } from '../bookings/guest-booking.service'; import { AuditService } from '../../common/audit.service'; import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils'; /** Package-specific fare rules */ const PKG_MAX_ADULTS = 5; const PKG_CHILDREN_PER_ADULT = 5; // max 5 children per adult /** * First child per adult travels FREE (no seat). * Additional children beyond one per adult pay the full adult fare. */ function calculatePackageFareBreakdown( priceMinor: number, isRoundTrip: boolean, adultCount: number, childCount: number, ) { const multiplier = isRoundTrip ? 2 : 1; const adultFareMinor = priceMinor * multiplier; const freeChildren = Math.min(childCount, adultCount); const paidChildren = Math.max(0, childCount - adultCount); const totalMinor = adultCount * adultFareMinor + paidChildren * adultFareMinor; return { adultFareMinor, freeChildren, paidChildren, totalMinor, multiplier }; } function deriveAge(dateOfBirth: string | Date): number { const today = new Date(); const dob = new Date(dateOfBirth); let age = today.getFullYear() - dob.getFullYear(); if (today < new Date(today.getFullYear(), dob.getMonth(), dob.getDate())) age--; return age; } function generateRef(): string { return 'PKG-' + Array.from({ length: 6 }, () => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)], ).join(''); } @Injectable() export class PackagesService { constructor( private readonly prisma: PrismaService, private readonly currencyService: CurrencyService, private readonly bookingsService: BookingsService, private readonly guestBookingService: GuestBookingService, private readonly auditService: AuditService, ) {} async getBookingContext(packageId: string, tierId: string, adultCount: number, childCount = 0) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id: packageId }, include: { priceTiers: true, outboundSchedule: { include: { originStation: true, destinationStation: true, coachAssignments: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } }, }, }, returnSchedule: { include: { originStation: true, destinationStation: true } }, }, }); if (!pkg || pkg.status !== 'ACTIVE') throw new NotFoundException('Package not available'); const tier = pkg.priceTiers.find((t) => t.id === tierId); if (!tier) throw new NotFoundException('Price tier not found'); if (adultCount < 1) throw new BadRequestException('At least one adult passenger required'); if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`); const maxChildren = adultCount * PKG_CHILDREN_PER_ADULT; if (childCount > maxChildren) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildren} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`); const isRoundTrip = !!pkg.returnScheduleId; const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown( tier.priceMinor, isRoundTrip, adultCount, childCount, ); // Only adults and paid children need seats; free children travel without a seat const seatsNeeded = adultCount + paidChildren; const passengerCount = adultCount + childCount; // Re-fetch tier from DB to get accurate live counts const liveTier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } }); if (!liveTier) throw new NotFoundException('Price tier not found'); const remaining = liveTier.availableSeats - liveTier.bookedSeats; if (seatsNeeded > remaining) throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); // Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches let seatClassId: string | null = tier.seatClassId ?? null; let seatClassName: string | null = null; let coachTypeId: string | null = null; for (const a of pkg.outboundSchedule.coachAssignments) { const sc = seatClassId ? a.coach.coachType?.seatClasses?.find((s: any) => s.id === seatClassId) : a.coach.coachType?.seatClasses?.find( (s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) || tier.seatType.toLowerCase().includes(s.name.toLowerCase()), ); if (sc) { seatClassId = sc.id; seatClassName = sc.name; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; } } if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) { const first = pkg.outboundSchedule.coachAssignments[0]; coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null; } return { packageId: pkg.id, packageName: pkg.name, priceTierId: tier.id, tierLabel: tier.label, seatType: tier.seatType, seatClassId, seatClassName, coachTypeId, adultCount, childCount, passengerCount, isRoundTrip, pricePerAdultMinor: adultFareMinor, pricePerChildMinor: adultFareMinor, // paid children pay full adult fare freeChildrenCount: freeChildren, paidChildrenCount: paidChildren, childFareNote: `First child per adult travels free (no seat); additional children pay full adult fare`, maxAdults: PKG_MAX_ADULTS, maxChildren: adultCount * PKG_CHILDREN_PER_ADULT, totalMinor, currency: tier.currency, remainingSeats: remaining, outboundSchedule: { scheduleId: pkg.outboundScheduleId, originStationId: pkg.outboundSchedule.originStationId, destinationStationId: pkg.outboundSchedule.destinationStationId, departureAt: pkg.outboundSchedule.departureAt, arrivalAt: pkg.outboundSchedule.arrivalAt, originStation: pkg.outboundSchedule.originStation, destinationStation: pkg.outboundSchedule.destinationStation, }, returnSchedule: pkg.returnSchedule ? { scheduleId: pkg.returnScheduleId, originStationId: pkg.returnSchedule.originStationId, destinationStationId: pkg.returnSchedule.destinationStationId, departureAt: pkg.returnSchedule.departureAt, arrivalAt: pkg.returnSchedule.arrivalAt, originStation: pkg.returnSchedule.originStation, destinationStation: pkg.returnSchedule.destinationStation, } : null, includedServices: pkg.includedServices, busTransferIncluded: pkg.busTransferIncluded, busTransferRoute: pkg.busTransferRoute, }; } async createInquiry(dto: CreateInquiryDto) { return this.prisma.packageInquiry.create({ data: { packageId: dto.packageId, priceTierId: dto.priceTierId ?? null, travelerCount: dto.travelerCount, contactName: dto.contactName, contactEmail: dto.contactEmail ?? null, contactPhone: dto.contactPhone ?? null, notes: dto.notes ?? null, enquiredAt: new Date(), }, include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true } } }, }); } async listInquiries({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) { const where: any = {}; if (packageId) where.packageId = packageId; if (status) where.status = status; const skip = (page - 1) * pageSize; const [items, total] = await Promise.all([ this.prisma.packageInquiry.findMany({ where, include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true, priceMinor: true } } }, orderBy: { enquiredAt: 'desc' }, skip, take: pageSize, }), this.prisma.packageInquiry.count({ where }), ]); return { items, total, page, pageSize }; } async updateInquiryStatus(id: string, status: string) { const inquiry = await this.prisma.packageInquiry.findUnique({ where: { id } }); if (!inquiry) throw new NotFoundException('Inquiry not found'); return this.prisma.packageInquiry.update({ where: { id }, data: { status } }); } async deleteInquiry(id: string) { const inquiry = await this.prisma.packageInquiry.findUnique({ where: { id } }); if (!inquiry) throw new NotFoundException('Inquiry not found'); await this.prisma.packageInquiry.delete({ where: { id } }); return { deleted: true }; } listActive() { const now = new Date(); return this.prisma.travelPackage.findMany({ where: { status: 'ACTIVE', validUntil: { gte: now } }, include: { priceTiers: true, outboundSchedule: { include: { originStation: true, destinationStation: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } }, }, orderBy: { validFrom: 'asc' }, }).then(pkgs => pkgs.map(p => ({ ...p, journeyType: p.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' }))); } async getById(id: string) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, include: { priceTiers: { include: { seatClass: { include: { coachType: true } } } }, outboundSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, }, }, returnSchedule: { include: { originStation: true, destinationStation: true, train: true } }, }, }); if (!pkg) throw new NotFoundException('Package not found'); // Fetch live route stops so the departure station dropdown always reflects // the current route definition, not stale TripStopTime snapshots. let routeStops: { sequence: number; station: any }[] = []; if (pkg.outboundSchedule.routeId) { const stops = await this.prisma.routeStop.findMany({ where: { routeId: pkg.outboundSchedule.routeId }, orderBy: { sequence: 'asc' }, }); const stationIds = stops.map((s) => s.stationId); const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } }); const stationMap = Object.fromEntries(stations.map((s) => [s.id, s])); routeStops = stops.map((s) => ({ sequence: s.sequence, station: stationMap[s.stationId] })); } return { ...pkg, journeyType: pkg.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY', outboundSchedule: { ...pkg.outboundSchedule, routeStops }, }; } async create(dto: CreatePackageDto) { const pkg = await this.prisma.travelPackage.create({ data: { code: dto.code, name: dto.name, description: dto.description, outboundScheduleId: dto.outboundScheduleId, returnScheduleId: dto.returnScheduleId, originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, boardingTime: new Date(dto.boardingTime), departureTime: new Date(dto.departureTime), arrivalTime: new Date(dto.arrivalTime), totalCapacity: dto.totalCapacity, coachConfiguration: dto.coachConfiguration, includedServices: dto.includedServices, busTransferIncluded: dto.busTransferIncluded ?? false, busTransferRoute: dto.busTransferRoute, validFrom: new Date(dto.validFrom), validUntil: new Date(dto.validUntil), status: 'DRAFT', priceTiers: { create: dto.priceTiers }, }, include: { priceTiers: true }, }); await this.auditService.log({ action: 'CREATE', entityType: 'Package', entityId: pkg.id, newData: { code: pkg.code, name: pkg.name } }); return pkg; } async update(id: string, dto: Partial) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException('Package not found'); const updated = await this.prisma.travelPackage.update({ where: { id }, data: { ...(dto.code && { code: dto.code }), ...(dto.name && { name: dto.name }), ...(dto.description !== undefined && { description: dto.description }), ...(dto.outboundScheduleId && { outboundScheduleId: dto.outboundScheduleId }), ...(dto.returnScheduleId && { returnScheduleId: dto.returnScheduleId }), ...(dto.originStationId && { originStationId: dto.originStationId }), ...(dto.destinationStationId && { destinationStationId: dto.destinationStationId }), ...(dto.boardingTime && { boardingTime: new Date(dto.boardingTime) }), ...(dto.departureTime && { departureTime: new Date(dto.departureTime) }), ...(dto.arrivalTime && { arrivalTime: new Date(dto.arrivalTime) }), ...(dto.totalCapacity && { totalCapacity: dto.totalCapacity }), ...(dto.coachConfiguration !== undefined && { coachConfiguration: dto.coachConfiguration }), ...(dto.includedServices && { includedServices: dto.includedServices }), ...(dto.busTransferIncluded !== undefined && { busTransferIncluded: dto.busTransferIncluded }), ...(dto.busTransferRoute !== undefined && { busTransferRoute: dto.busTransferRoute }), ...(dto.validFrom && { validFrom: new Date(dto.validFrom) }), ...(dto.validUntil && { validUntil: new Date(dto.validUntil) }), }, include: { priceTiers: true }, }); await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { code: dto.code, name: dto.name } }); return updated; } async addTier(packageId: string, dto: CreatePriceTierDto) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id: packageId } }); if (!pkg) throw new NotFoundException('Package not found'); return this.prisma.packagePriceTier.create({ data: { ...dto, packageId } }); } async updateTier(tierId: string, dto: UpdatePriceTierDto) { const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } }); if (!tier) throw new NotFoundException('Price tier not found'); return this.prisma.packagePriceTier.update({ where: { id: tierId }, data: dto }); } async deleteTier(tierId: string) { const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } }); if (!tier) throw new NotFoundException('Price tier not found'); if (tier.bookedSeats > 0) throw new BadRequestException('Cannot delete a tier that has bookings'); return this.prisma.packagePriceTier.delete({ where: { id: tierId } }); } async remove(id: string, cascade = false) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException('Package not found'); const packageBookings = await this.prisma.packageBooking.findMany({ where: { packageId: id }, select: { id: true, status: true }, }); if (!cascade) { const hasActive = packageBookings.some(b => b.status === 'PENDING_PAYMENT' || b.status === 'CONFIRMED'); if (hasActive) throw new BadRequestException('Cannot delete a package with active bookings. Use cascade=true to force delete.'); } const pbIds = packageBookings.map(b => b.id); if (pbIds.length > 0) { await this.prisma.packageBookingPassenger.deleteMany({ where: { bookingId: { in: pbIds } } }); await this.prisma.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: pbIds } } }); await this.prisma.packageBooking.deleteMany({ where: { id: { in: pbIds } } }); } // PackageInquiry references packageId and priceTierId await this.prisma.packageInquiry.deleteMany({ where: { packageId: id } }); await this.prisma.packagePriceTier.deleteMany({ where: { packageId: id } }); await this.prisma.travelPackage.delete({ where: { id } }); await this.auditService.log({ action: 'DELETE', entityType: 'Package', entityId: id }); return { deleted: true }; } async activate(id: string) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException('Package not found'); const activated = await this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } }); await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { status: 'ACTIVE' } }); return activated; } async deactivate(id: string) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException('Package not found'); const deactivated = await this.prisma.travelPackage.update({ where: { id }, data: { status: 'DRAFT' } }); await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { status: 'DRAFT' } }); return deactivated; } async book(dto: BookPackageDto, passengerId?: string) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id: dto.packageId }, include: { priceTiers: true, outboundSchedule: { select: { departureAt: true, route: { select: { checkinMinutesBefore: true } } } }, }, }); if (!pkg) throw new NotFoundException('Package not found'); if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking'); const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId); if (!tier) throw new NotFoundException('Price tier not found'); // Derive adult/child counts from the passengers array (dateOfBirth-based) let adultCount = 0, childCount = 0; for (const p of dto.passengers) { if (p.dateOfBirth && deriveAge(p.dateOfBirth) < 5) childCount++; else adultCount++; } // Allow explicit override from mobile app (e.g. when dateOfBirth is not provided per passenger) if (dto.adultCount !== undefined) adultCount = dto.adultCount; if (dto.childCount !== undefined) childCount = dto.childCount; if (adultCount < 1) throw new BadRequestException('At least one adult passenger required'); if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`); const maxChildrenBook = adultCount * PKG_CHILDREN_PER_ADULT; if (childCount > maxChildrenBook) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildrenBook} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`); const passengerCount = adultCount + childCount; const isRoundTrip = !!pkg.returnScheduleId; const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown( tier.priceMinor, isRoundTrip, adultCount, childCount, ); // Only adults and paid children need seats; free children travel without a seat const seatsNeeded = adultCount + paidChildren; const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; const [booking] = await this.prisma.$transaction(async (tx) => { // Re-fetch tier inside transaction for race-condition-safe availability check const freshTier = await tx.packagePriceTier.findUnique({ where: { id: dto.priceTierId } }); if (!freshTier) throw new NotFoundException('Price tier not found'); const remaining = freshTier.availableSeats - freshTier.bookedSeats; if (seatsNeeded > remaining) { throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); } return Promise.all([ tx.packageBooking.create({ data: { bookingRef: generateRef(), packageId: dto.packageId, priceTierId: dto.priceTierId, passengerId: passengerId ?? null, contactEmail: dto.contactEmail, contactPhone: dto.contactPhone, promoCode: dto.promoCode, passengerCount, adultCount, childCount, totalMinor: displayTotalMinor, currency: displayCurrency, displayCurrency, displayTotalMinor, status: 'PENDING_PAYMENT', passengers: { create: dto.passengers.map((p) => ({ passengerName: p.passengerName, dateOfBirth: p.dateOfBirth ? new Date(p.dateOfBirth) : undefined, idDocumentType: p.idDocumentType as any, idDocumentNumber: p.idDocumentNumber, passportNumber: p.passportNumber, passportCountry: p.passportCountry, })), }, }, include: { passengers: true, priceTier: true, package: { include: { outboundSchedule: { include: { originStation: true, destinationStation: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } }, }, }, }, }), tx.packagePriceTier.update({ where: { id: dto.priceTierId }, data: { bookedSeats: { increment: seatsNeeded }, availableSeats: { decrement: seatsNeeded }, }, }), ]); }); // Extend the seatmap SeatHold (if one was passed) to the payment deadline so the // specific seat remains visually reserved on the seatmap during the full payment // window — matching the behaviour of normal bookings (which call confirmSeats). if (dto.holdId) { const dep = (pkg as any).outboundSchedule?.departureAt as Date | undefined; if (dep) { const checkinMinutes = (pkg as any).outboundSchedule?.route?.checkinMinutesBefore ?? CUTOFF_MINUTES; const paymentDeadline = computePaymentDeadline(booking.createdAt as Date, dep, checkinMinutes); const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId }, select: { expiresAt: true }, }); if (hold && paymentDeadline > hold.expiresAt) { await this.prisma.seatHold.update({ where: { id: dto.holdId }, data: { expiresAt: paymentDeadline }, }); } } } return { ...booking, fareBreakdown: { isRoundTrip, adultCount, adultFareMinor, childCount, freeChildrenCount: freeChildren, paidChildrenCount: paidChildren, paidChildFareMinor: adultFareMinor, childFareNote: `First child per adult travels free (no seat); additional children pay full adult fare`, totalMinor, currency: booking.displayCurrency, displayCurrency, displayTotalMinor, }, }; } getMyBookings(passengerId: string) { return this.prisma.packageBooking.findMany({ where: { passengerId }, include: { package: true, priceTier: true, passengers: true, paymentIntent: true }, orderBy: { createdAt: 'desc' }, }); } async getBookingByRef(bookingRef: string) { const booking = await this.prisma.packageBooking.findUnique({ where: { bookingRef }, include: { package: { include: { outboundSchedule: { include: { originStation: true, destinationStation: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } }, }, }, priceTier: true, passengers: true, paymentIntent: true, }, }); if (!booking) throw new NotFoundException('Package booking not found'); return booking; } async listBookings({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) { const where: any = {}; if (packageId) where.packageId = packageId; if (status) where.status = status; const skip = (page - 1) * pageSize; const [items, total] = await Promise.all([ this.prisma.packageBooking.findMany({ where, include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true, seatType: true } }, passengers: true, paymentIntent: true, }, orderBy: { createdAt: 'desc' }, skip, take: pageSize, }), this.prisma.packageBooking.count({ where }), ]); return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }; } async listAll(page = 1, pageSize = 20) { const skip = (page - 1) * pageSize; const [items, total] = await Promise.all([ this.prisma.travelPackage.findMany({ skip, take: pageSize, include: { priceTiers: true, outboundSchedule: { include: { originStation: true, destinationStation: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } }, }, orderBy: { createdAt: 'desc' }, }), this.prisma.travelPackage.count(), ]); return { items, total, page, pageSize }; } }