Files
edr-platform/apps/edr-passenger-api/src/modules/packages/packages.service.ts
2026-07-06 08:53:56 +03:00

519 lines
21 KiB
TypeScript

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.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: 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 },
};
}
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<CreatePackageDto>) {
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 };
}
}