Package booking new rules update, cascade delete option updates

This commit is contained in:
Stephanos A
2026-07-06 22:12:04 +03:00
parent f52d7442ac
commit 36055d0d82
31 changed files with 777 additions and 339 deletions

View File

@@ -142,8 +142,9 @@ export class PackagesController {
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete package (admin)' })
remove(@Param('id') id: string) {
return this.service.remove(id);
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' })
remove(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.remove(id, cascade === 'true');
}
@Patch(':id/activate')

View File

@@ -8,9 +8,12 @@ import { GuestBookingService } from '../bookings/guest-booking.service';
/** Package-specific fare rules */
const PKG_MAX_ADULTS = 5;
const PKG_CHILDREN_PER_ADULT = 2; // 2 children allowed per adult
const PKG_CHILD_FARE_RATIO = 0.1;
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,
@@ -19,9 +22,10 @@ function calculatePackageFareBreakdown(
) {
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 };
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 {
@@ -71,16 +75,18 @@ export class PackagesService {
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 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(
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;
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;
@@ -113,8 +119,10 @@ export class PackagesService {
passengerCount,
isRoundTrip,
pricePerAdultMinor: adultFareMinor,
pricePerChildMinor: childFareMinor,
childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`,
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,
@@ -317,25 +325,31 @@ export class PackagesService {
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 } } },
});
async remove(id: string, cascade = false) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
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 } });
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 } });
return { deleted: true };
}
@@ -379,14 +393,16 @@ export class PackagesService {
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(
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;
if (seatsNeeded > remaining) {
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`);
}
const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB;
const displayTotalMinor =
displayCurrency !== Currency.ETB
@@ -435,7 +451,7 @@ export class PackagesService {
}),
this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId },
data: { bookedSeats: { increment: passengerCount } },
data: { bookedSeats: { increment: seatsNeeded } },
}),
]);
@@ -446,8 +462,10 @@ export class PackagesService {
adultCount,
adultFareMinor,
childCount,
childFareMinor,
childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`,
freeChildrenCount: freeChildren,
paidChildrenCount: paidChildren,
paidChildFareMinor: adultFareMinor,
childFareNote: `First child per adult travels free (no seat); additional children pay full adult fare`,
totalMinor,
currency: 'ETB',
displayCurrency,