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'; /** Package-specific fare rules */ const PKG_MAX_ADULTS = 5; const PKG_MAX_CHILDREN = 2; const PKG_CHILD_FARE_RATIO = 0.1; function calculatePackageFareBreakdown( priceMinor: number, isRoundTrip: boolean, adultCount: number, childCount: number, ) { const multiplier = isRoundTrip ? 2 : 1; const adultFareMinor = priceMinor * multiplier; const childFareMinor = Math.round(adultFareMinor * PKG_CHILD_FARE_RATIO); const totalMinor = adultCount * adultFareMinor + childCount * childFareMinor; return { adultFareMinor, childFareMinor, 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, ) {} 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`); if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`); const passengerCount = adultCount + childCount; const remaining = tier.availableSeats - tier.bookedSeats; if (passengerCount > remaining) throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); const isRoundTrip = !!pkg.returnScheduleId; const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown( tier.priceMinor, isRoundTrip, adultCount, childCount, ); // Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches let seatClassId: string | null = null; let coachTypeId: string | null = null; for (const a of pkg.outboundSchedule.coachAssignments) { const sc = 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; 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, coachTypeId, adultCount, childCount, passengerCount, isRoundTrip, pricePerAdultMinor: adultFareMinor, pricePerChildMinor: childFareMinor, childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, maxAdults: PKG_MAX_ADULTS, maxChildren: PKG_MAX_CHILDREN, totalMinor, currency: tier.currency, remainingSeats: remaining, outboundSchedule: { scheduleId: pkg.outboundScheduleId, originStationId: pkg.originStationId, destinationStationId: pkg.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.destinationStationId, destinationStationId: pkg.originStationId, departureAt: pkg.returnSchedule.departureAt, arrivalAt: pkg.returnSchedule.arrivalAt, originStation: pkg.returnSchedule.destinationStation, destinationStation: pkg.returnSchedule.originStation, } : 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: true, outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, returnSchedule: { include: { originStation: true, destinationStation: true, train: true } }, }, }); if (!pkg) throw new NotFoundException('Package not found'); return { ...pkg, journeyType: pkg.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' }; } create(dto: CreatePackageDto) { return 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 }, }); } async update(id: string, dto: Partial) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException('Package not found'); return 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 }, }); } 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) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, include: { bookings: { select: { id: true, status: true } } }, }); if (!pkg) throw new NotFoundException('Package not found'); const hasActive = pkg.bookings.some((b) => b.status === 'PENDING_PAYMENT' || b.status === 'CONFIRMED'); if (hasActive) throw new BadRequestException('Cannot delete a package with active bookings'); await this.prisma.$transaction(async (tx) => { const bookingIds = pkg.bookings.map((b) => b.id); if (bookingIds.length > 0) { await tx.packageBookingPassenger.deleteMany({ where: { bookingId: { in: bookingIds } } }); await tx.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: bookingIds } } }); await tx.packageBooking.deleteMany({ where: { packageId: id } }); } await tx.packagePriceTier.deleteMany({ where: { packageId: id } }); await tx.travelPackage.delete({ where: { 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'); return this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } }); } async deactivate(id: string) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException('Package not found'); return this.prisma.travelPackage.update({ where: { id }, data: { status: 'DRAFT' } }); } async book(dto: BookPackageDto, passengerId?: string) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id: dto.packageId }, include: { priceTiers: 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`); if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`); const passengerCount = adultCount + childCount; const remaining = tier.availableSeats - tier.bookedSeats; if (passengerCount > remaining) { throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); } const isRoundTrip = !!pkg.returnScheduleId; const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown( tier.priceMinor, isRoundTrip, adultCount, childCount, ); 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([ this.prisma.packageBooking.create({ data: { bookingRef: generateRef(), packageId: dto.packageId, priceTierId: dto.priceTierId, passengerId: passengerId ?? null, contactEmail: dto.contactEmail, contactPhone: dto.contactPhone, promoCode: dto.promoCode, passengerCount, totalMinor, currency: 'ETB', 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 } }, }, }, }, }), this.prisma.packagePriceTier.update({ where: { id: dto.priceTierId }, data: { bookedSeats: { increment: passengerCount } }, }), ]); return { ...booking, fareBreakdown: { isRoundTrip, adultCount, adultFareMinor, childCount, childFareMinor, childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, totalMinor, currency: 'ETB', 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 }; } }