import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { CurrencyService } from '../currency/currency.service'; import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto } from './packages.dto'; import { Currency } from '@prisma/client'; 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, ) {} listActive() { const now = new Date(); return this.prisma.travelPackage.findMany({ where: { status: 'ACTIVE', validFrom: { lte: now }, validUntil: { gte: now } }, include: { priceTiers: true, outboundSchedule: { include: { originStation: true, destinationStation: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } }, }, orderBy: { validFrom: 'asc' }, }); } 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; } 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 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'); if (new Date() > pkg.validUntil) throw new BadRequestException('Package has expired'); const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId); if (!tier) throw new NotFoundException('Price tier not found'); const passengerCount = dto.passengers.length; const remaining = tier.availableSeats - tier.bookedSeats; if (passengerCount > remaining) { throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); } const totalMinor = tier.priceMinor * passengerCount; 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; } 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 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 }; } }