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

@@ -470,10 +470,11 @@ export class BookingsController {
@ApiOperation({
description: 'Permanently deletes a booking record'
})
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Booking deleted successfully' })
@ApiResponse({ status: 404, description: 'Booking not found' })
delete(@Param('id') id: string) {
return this.service.delete(id);
delete(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.delete(id, cascade === 'true');
}
@Patch(':id')

View File

@@ -31,9 +31,10 @@ function resolvePackageRoundTripTotal(
if (!booking.packageId || booking.bookingType !== 'ROUND_TRIP' || !tierPriceMinor) {
return booking.totalMinor;
}
// First child per adult is free; additional children pay full adult fare
const adultFareMinor = tierPriceMinor * 2;
const childFareMinor = Math.round(adultFareMinor * 0.1);
return adultCount * adultFareMinor + childCount * childFareMinor;
const paidChildren = Math.max(0, childCount - adultCount);
return adultCount * adultFareMinor + paidChildren * adultFareMinor;
}
function calculateAge(dateOfBirth: Date): number {
@@ -557,7 +558,10 @@ export class BookingsService {
if (p.category === PassengerCategory.ADULT) {
fareMinor = fareCalculation.baseFareMinor;
} else if (dto.packageId) {
fareMinor = Math.round(fareCalculation.baseFareMinor * 0.1);
// Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
// passengersWithFares is built in adult-first order so we track paid children by count.
const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
fareMinor = childIdx < adultCount ? 0 : fareCalculation.baseFareMinor;
} else {
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
else fareMinor = fareCalculation.baseFareMinor;
@@ -704,8 +708,11 @@ export class BookingsService {
outboundFareMinor = outboundFare.baseFareMinor;
returnFareMinor = returnFare.baseFareMinor;
} else if (dto.packageId) {
outboundFareMinor = Math.round(outboundFare.baseFareMinor * 0.1);
returnFareMinor = Math.round(returnFare.baseFareMinor * 0.1);
// Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
const isFreeChild = childIdx < adultCount;
outboundFareMinor = isFreeChild ? 0 : outboundFare.baseFareMinor;
returnFareMinor = isFreeChild ? 0 : returnFare.baseFareMinor;
} else {
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
else outboundFareMinor = outboundFare.baseFareMinor;
@@ -1255,19 +1262,19 @@ export class BookingsService {
childCount: number,
) {
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } });
// For round-trip packages the caller splits the tier price across legs, so
// priceMinor here is already the per-leg amount. Children pay 10% of adult fare.
const childFareMinor = Math.round(tier.priceMinor * 0.1);
// First child per adult travels free (no seat); additional children pay full adult fare.
const freeChildrenCount = Math.min(childCount, adultCount);
const paidChildrenCount = Math.max(0, childCount - adultCount);
const adultFareMinor = tier.priceMinor * adultCount;
const childTotalMinor = childFareMinor * childCount;
const childTotalMinor = tier.priceMinor * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childTotalMinor;
return {
baseFareMinor: tier.priceMinor,
adultCount,
adultFareMinor,
childCount,
freeChildrenCount: 0,
paidChildrenCount: childCount,
freeChildrenCount,
paidChildrenCount,
childFareMinor: childTotalMinor,
totalBaseFareMinor,
discountMinor: 0,
@@ -1555,21 +1562,51 @@ export class BookingsService {
});
}
async delete(id: string) {
async delete(id: string, cascade = false) {
const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found');
// Check usage before allowing deletion
const usage = await this.checkBookingUsage(id);
if (usage.isInUse && usage.constraints) {
throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints);
if (!cascade) {
const usage = await this.checkBookingUsage(id);
if (usage.isInUse && usage.constraints) {
throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints);
}
}
await this.seatsService.releaseSeats(booking.id);
if (cascade) {
// Delete all child records that reference this booking (no onDelete: Cascade in schema)
const paymentIntent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: id } });
if (paymentIntent) {
await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: paymentIntent.id } });
await this.prisma.paymentIntent.delete({ where: { bookingId: id } });
}
const tickets = await this.prisma.ticket.findMany({ where: { bookingId: id }, select: { id: true } });
for (const t of tickets) {
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: t.id } });
}
await this.prisma.ticket.deleteMany({ where: { bookingId: id } });
await this.prisma.bookingModification.deleteMany({ where: { bookingId: id } });
await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: id } });
await this.prisma.agentBooking.deleteMany({ where: { bookingId: id } });
const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: id }, select: { id: true } });
for (const fo of foodOrders) {
await this.prisma.foodOrderItem.deleteMany({ where: { orderId: fo.id } });
}
await this.prisma.foodOrder.deleteMany({ where: { bookingId: id } });
await this.prisma.baggageBooking.deleteMany({ where: { bookingId: id } });
await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: id } });
const journey = await this.prisma.journey.findUnique({ where: { bookingId: id } });
if (journey) {
await this.prisma.journeySegment.deleteMany({ where: { journeyId: journey.id } });
await this.prisma.journey.delete({ where: { bookingId: id } });
}
}
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
await this.prisma.booking.delete({ where: { id } });
return { deleted: true, bookingRef: booking.bookingRef };
}

View File

@@ -76,10 +76,11 @@ export class FleetController {
@Delete('classes/:id')
@ApiOperation({ summary: 'Delete a class' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Class deleted' })
@ApiResponse({ status: 404, description: 'Class not found' })
deleteClass(@Param('id') id: string) {
return this.service.deleteClass(id);
deleteClass(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.deleteClass(id, cascade === 'true');
}
// Seat Class Endpoints (DEPRECATED - use Classes endpoints instead)
@@ -112,10 +113,11 @@ export class FleetController {
@Delete('seat-classes/:id')
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Class deleted' })
@ApiResponse({ status: 404, description: 'Class not found' })
deleteSeatClass(@Param('id') id: string) {
return this.service.deleteClass(id);
deleteSeatClass(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.deleteClass(id, cascade === 'true');
}
// Train Endpoints
@@ -147,10 +149,11 @@ export class FleetController {
@Delete('trains/:id')
@ApiOperation({ summary: 'Delete a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Train deleted' })
@ApiResponse({ status: 404, description: 'Train not found' })
deleteTrain(@Param('id') id: string) {
return this.service.deleteTrain(id);
deleteTrain(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.deleteTrain(id, cascade === 'true');
}
@Patch('trains/:id/restore')
@@ -309,10 +312,11 @@ export class FleetController {
@Delete('coaches/:id')
@ApiOperation({ summary: 'Delete a coach' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Coach deleted successfully' })
@ApiResponse({ status: 404, description: 'Coach not found' })
deleteCoach(@Param('id') id: string) {
return this.service.deleteCoach(id);
deleteCoach(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.deleteCoach(id, cascade === 'true');
}
@Post('assignments')

View File

@@ -273,7 +273,7 @@ export class FleetService {
});
}
async deleteClass(id: string) {
async deleteClass(id: string, cascade = false) {
const seatClass = await this.prisma.seatClass.findUnique({
where: { id },
include: {
@@ -284,19 +284,27 @@ export class FleetService {
});
if (!seatClass) throw new NotFoundException('Seat class not found');
const totalFareRules = seatClass.fareRules.length + seatClass.routeFareRules.length + seatClass.segmentFares.length;
const constraints = [];
if (totalFareRules > 0) {
constraints.push({
entityName: 'fare rule',
count: totalFareRules,
action: 'delete' as const
});
if (!cascade) {
const totalFareRules = seatClass.fareRules.length + seatClass.routeFareRules.length + seatClass.segmentFares.length;
const constraints = [];
if (totalFareRules > 0) {
constraints.push({
entityName: 'fare rule',
count: totalFareRules,
action: 'delete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Seat Class', seatClass.name, constraints);
}
}
if (constraints.length > 0) {
throw new DeleteOperationException('Seat Class', seatClass.name, constraints);
if (cascade) {
await this.prisma.fareRule.deleteMany({ where: { seatClassId: id } });
await this.prisma.routeFareRule.deleteMany({ where: { seatClassId: id } });
await this.prisma.segmentFareRule.deleteMany({ where: { seatClassId: id } });
}
return this.prisma.seatClass.delete({ where: { id } });
@@ -354,24 +362,78 @@ export class FleetService {
});
}
async deleteTrain(id: string) {
async deleteTrain(id: string, cascade = false) {
const train = await this.prisma.train.findUnique({
where: { id },
include: { schedules: true },
});
if (!train) throw new NotFoundException('Train not found');
const constraints = [];
if (train.schedules.length > 0) {
constraints.push({
entityName: 'schedule',
count: train.schedules.length,
action: 'delete' as const
});
if (!cascade) {
const constraints = [];
if (train.schedules.length > 0) {
constraints.push({
entityName: 'schedule',
count: train.schedules.length,
action: 'delete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Train', `${train.number} (${train.name})`, constraints);
}
}
if (constraints.length > 0) {
throw new DeleteOperationException('Train', `${train.number} (${train.name})`, constraints);
if (cascade && train.schedules.length > 0) {
const scheduleIds = train.schedules.map((s: any) => s.id);
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.menuItem.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
const bookings = await this.prisma.booking.findMany({
where: { OR: [{ scheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] },
select: { id: true },
});
if (bookings.length > 0) {
const bookingIds = bookings.map(b => b.id);
const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (tickets.length > 0) {
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } });
}
await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (foodOrders.length > 0) {
await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } });
}
await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (paymentIntents.length > 0) {
await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } });
}
await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
}
const packages = await this.prisma.travelPackage.findMany({
where: { OR: [{ outboundScheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] },
select: { id: true },
});
if (packages.length > 0) {
const packageIds = packages.map(p => p.id);
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } });
await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } });
}
await this.prisma.trainSchedule.deleteMany({ where: { id: { in: scheduleIds } } });
}
return this.prisma.train.delete({ where: { id } });
@@ -477,7 +539,7 @@ export class FleetService {
});
}
async deleteCoach(id: string) {
async deleteCoach(id: string, cascade = false) {
const coach = await this.prisma.coach.findUnique({
where: { id },
include: {
@@ -493,47 +555,60 @@ export class FleetService {
});
if (!coach) throw new NotFoundException('Coach not found');
const constraints = [];
if ((coach as any).assignments.length > 0) {
constraints.push({
entityName: 'schedule assignment',
count: (coach as any).assignments.length,
action: 'reassign' as const
});
if (!cascade) {
const constraints = [];
if ((coach as any).assignments.length > 0) {
constraints.push({
entityName: 'schedule assignment',
count: (coach as any).assignments.length,
action: 'reassign' as const
});
}
const bookedSeats = (coach as any).seats.filter((seat: any) => seat.bookingSeats.length > 0);
if (bookedSeats.length > 0) {
constraints.push({
entityName: 'booked seat',
count: bookedSeats.length,
action: 'complete' as const
});
}
const blockedSeats = (coach as any).seats.filter((seat: any) => seat.blocks.length > 0);
if (blockedSeats.length > 0) {
constraints.push({
entityName: 'blocked seat',
count: blockedSeats.length,
action: 'delete' as const
});
}
const seatsWithTickets = (coach as any).seats.filter((seat: any) => seat.tickets.length > 0);
if (seatsWithTickets.length > 0) {
constraints.push({
entityName: 'seat with issued ticket',
count: seatsWithTickets.length,
action: 'complete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Coach', coach.number, constraints);
}
}
const bookedSeats = (coach as any).seats.filter((seat: any) => seat.bookingSeats.length > 0);
if (bookedSeats.length > 0) {
constraints.push({
entityName: 'booked seat',
count: bookedSeats.length,
action: 'complete' as const
});
if (cascade) {
const seatIds = (coach as any).seats.map((s: any) => s.id);
if (seatIds.length > 0) {
await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } });
await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } });
await this.prisma.ticket.deleteMany({ where: { seatId: { in: seatIds } } });
}
await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } });
await this.prisma.routeCoachTemplate.deleteMany({ where: { coachId: id } });
}
const blockedSeats = (coach as any).seats.filter((seat: any) => seat.blocks.length > 0);
if (blockedSeats.length > 0) {
constraints.push({
entityName: 'blocked seat',
count: blockedSeats.length,
action: 'delete' as const
});
}
const seatsWithTickets = (coach as any).seats.filter((seat: any) => seat.tickets.length > 0);
if (seatsWithTickets.length > 0) {
constraints.push({
entityName: 'seat with issued ticket',
count: seatsWithTickets.length,
action: 'complete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Coach', coach.number, constraints);
}
await this.prisma.seat.deleteMany({ where: { coachId: id } });
return this.prisma.coach.delete({ where: { id } });

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,

View File

@@ -508,10 +508,11 @@ Returns saved passenger details with generated IDs and confirmation.`,
summary: 'Delete passenger (admin only)',
description: 'Permanently deletes a passenger record and associated data'
})
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related bookings and data' })
@ApiResponse({ status: 200, description: 'Passenger deleted successfully' })
@ApiResponse({ status: 404, description: 'Passenger not found' })
deletePassenger(@Param('id') id: string) {
return this.service.deletePassenger(id);
deletePassenger(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.deletePassenger(id, cascade === 'true');
}
@Get(':id/usage')

View File

@@ -432,7 +432,7 @@ export class PassengersService {
};
}
async deletePassenger(id: string) {
async deletePassenger(id: string, cascade = false) {
// id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id
let passenger = await this.prisma.passenger.findUnique({
where: { id },
@@ -451,29 +451,67 @@ export class PassengersService {
const passengerId = passenger.id;
// Check usage before allowing deletion
const usage = await this.checkPassengerUsage(passengerId);
if (usage.isInUse && usage.constraints) {
const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`;
throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
if (!cascade) {
const usage = await this.checkPassengerUsage(passengerId);
if (usage.isInUse && usage.constraints) {
const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`;
throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
}
}
await this.prisma.$transaction([
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }),
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }),
this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }),
this.prisma.walletAccount.deleteMany({ where: { passengerId } }),
this.prisma.notification.deleteMany({ where: { passengerId } }),
this.prisma.travelerProfile.deleteMany({ where: { passengerId } }),
this.prisma.savedRoute.deleteMany({ where: { passengerId } }),
this.prisma.packageBooking.deleteMany({ where: { passengerId } }),
this.prisma.ticket.deleteMany({ where: { booking: { passengerId } } }),
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId } } }),
this.prisma.booking.deleteMany({ where: { passengerId } }),
this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId } } }),
this.prisma.journey.deleteMany({ where: { passengerId } }),
this.prisma.passenger.delete({ where: { id: passengerId } }),
]);
// Resolve booking IDs first (needed for multi-step child deletion)
const bookings = await this.prisma.booking.findMany({
where: { passengerId },
select: { id: true },
});
const bookingIds = bookings.map(b => b.id);
if (bookingIds.length > 0) {
const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (tickets.length > 0) {
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } });
}
await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (foodOrders.length > 0) {
await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } });
}
await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (paymentIntents.length > 0) {
await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } });
}
await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
}
// Package bookings
const packageBookings = await this.prisma.packageBooking.findMany({ where: { passengerId }, select: { id: true } });
if (packageBookings.length > 0) {
const pbIds = packageBookings.map(pb => pb.id);
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 } } });
}
await this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } });
await this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } });
await this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } });
await this.prisma.walletAccount.deleteMany({ where: { passengerId } });
await this.prisma.notification.deleteMany({ where: { passengerId } });
await this.prisma.travelerProfile.deleteMany({ where: { passengerId } });
await this.prisma.savedRoute.deleteMany({ where: { passengerId } });
await this.prisma.passenger.delete({ where: { id: passengerId } });
return { deleted: true, passengerId };
}

View File

@@ -51,9 +51,10 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Route deleted' })
@ApiResponse({ status: 404, description: 'Route not found' })
deleteRoute(@Param('id') id: string) { return this.service.deleteRoute(id); }
deleteRoute(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteRoute(id, cascade === 'true'); }
// ── Route Stops ────────────────────────────────────────────────────────────

View File

@@ -110,7 +110,7 @@ export class RoutesService {
});
}
async deleteRoute(id: string) {
async deleteRoute(id: string, cascade = false) {
const route = await this.prisma.route.findUnique({
where: { id },
include: {
@@ -120,19 +120,77 @@ export class RoutesService {
});
if (!route) throw new NotFoundException('Route not found');
const constraints = [];
if (route.schedules.length > 0) {
constraints.push({
entityName: 'schedule',
count: route.schedules.length,
action: 'delete' as const
});
if (!cascade) {
const constraints = [];
if (route.schedules.length > 0) {
constraints.push({
entityName: 'schedule',
count: route.schedules.length,
action: 'delete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Route', `${route.code} (${route.name})`, constraints);
}
}
if (constraints.length > 0) {
throw new DeleteOperationException('Route', `${route.code} (${route.name})`, constraints);
if (cascade) {
const scheduleIds = route.schedules.map(s => s.id);
if (scheduleIds.length > 0) {
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.menuItem.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
const bookings = await this.prisma.booking.findMany({
where: { OR: [{ scheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] },
select: { id: true },
});
if (bookings.length > 0) {
const bookingIds = bookings.map(b => b.id);
const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (tickets.length > 0) {
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } });
}
await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (foodOrders.length > 0) {
await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } });
}
await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (paymentIntents.length > 0) {
await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } });
}
await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
}
const packages = await this.prisma.travelPackage.findMany({
where: { OR: [{ outboundScheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] },
select: { id: true },
});
if (packages.length > 0) {
const packageIds = packages.map(p => p.id);
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } });
await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } });
}
await this.prisma.trainSchedule.deleteMany({ where: { id: { in: scheduleIds } } });
}
await this.prisma.routeStop.deleteMany({ where: { routeId: id } });
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId: id } });
}
await this.prisma.route.delete({ where: { id } });
return { deleted: true, id };
}

View File

@@ -113,7 +113,8 @@ export class SchedulesController {
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
deleteSchedule(@Param('id') id: string) { return this.service.deleteSchedule(id); }
@ApiQuery({ name: 'cascade', required: false, type: Boolean })
deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); }
@Get(':id/stops')
@IsPublic()

View File

@@ -332,7 +332,7 @@ export class SchedulesService {
return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } });
}
async deleteSchedule(id: string) {
async deleteSchedule(id: string, cascade = false) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id },
include: {
@@ -344,18 +344,20 @@ export class SchedulesService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
const constraints = [];
if ((schedule as any)._count.bookings > 0) {
constraints.push({
entityName: 'booking',
count: (schedule as any)._count.bookings,
action: 'cancel' as const
});
}
if (constraints.length > 0) {
const scheduleName = `${schedule.train.number} (${schedule.originStation.name}${schedule.destinationStation.name})`;
throw new DeleteOperationException('Schedule', scheduleName, constraints);
if (!cascade) {
const constraints = [];
if ((schedule as any)._count.bookings > 0) {
constraints.push({
entityName: 'booking',
count: (schedule as any)._count.bookings,
action: 'cancel' as const
});
}
if (constraints.length > 0) {
const scheduleName = `${schedule.train.number} (${schedule.originStation.name}${schedule.destinationStation.name})`;
throw new DeleteOperationException('Schedule', scheduleName, constraints);
}
}
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
@@ -363,30 +365,49 @@ export class SchedulesService {
await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: id } });
await this.prisma.menuItem.deleteMany({ where: { scheduleId: id } });
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
// Delete travel packages that reference this schedule (required fields cannot be nulled)
// First get packages that reference this schedule
const packagesToDelete = await this.prisma.travelPackage.findMany({
where: {
OR: [
{ outboundScheduleId: id },
{ returnScheduleId: id }
]
},
select: { id: true }
// Delete bookings and all their children
const bookings = await this.prisma.booking.findMany({
where: { OR: [{ scheduleId: id }, { returnScheduleId: id }] },
select: { id: true },
});
if (bookings.length > 0) {
const bookingIds = bookings.map(b => b.id);
// Leaf tables first
const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (tickets.length > 0) {
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } });
}
await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (foodOrders.length > 0) {
await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } });
}
await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (paymentIntents.length > 0) {
await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } });
}
await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
}
// Delete travel packages that reference this schedule
const packagesToDelete = await this.prisma.travelPackage.findMany({
where: { OR: [{ outboundScheduleId: id }, { returnScheduleId: id }] },
select: { id: true },
});
// Delete price tiers first (they have foreign key to packages)
if (packagesToDelete.length > 0) {
const packageIds = packagesToDelete.map(p => p.id);
await this.prisma.packagePriceTier.deleteMany({
where: { packageId: { in: packageIds } }
});
// Now delete the packages
await this.prisma.travelPackage.deleteMany({
where: { id: { in: packageIds } }
});
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } });
await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } });
}
return this.prisma.trainSchedule.delete({ where: { id } });
}

View File

@@ -136,9 +136,10 @@ export class StationsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete station' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Station deleted successfully' })
@ApiResponse({ status: 404, description: 'Station not found' })
remove(@Param('id') id: string) {
return this.service.remove(id);
remove(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.remove(id, cascade === 'true');
}
}

View File

@@ -96,7 +96,7 @@ export class StationsService {
return updatedStation;
}
async remove(id: string) {
async remove(id: string, cascade = false) {
const station = await this.prisma.station.findUnique({
where: { id },
include: {
@@ -107,24 +107,34 @@ export class StationsService {
});
if (!station) throw new NotFoundException('Station not found');
const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([
this.prisma.routeStop.count({ where: { stationId: id } }),
this.prisma.trainSchedule.count({ where: { originStationId: id } }),
this.prisma.trainSchedule.count({ where: { destinationStationId: id } }),
(station as any)._count.stopTimes as number,
]);
if (!cascade) {
const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([
this.prisma.routeStop.count({ where: { stationId: id } }),
this.prisma.trainSchedule.count({ where: { originStationId: id } }),
this.prisma.trainSchedule.count({ where: { destinationStationId: id } }),
(station as any)._count.stopTimes as number,
]);
const constraints = [];
if (routeStopCount > 0)
constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const });
const scheduleCount = originCount + destCount;
if (scheduleCount > 0)
constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const });
if (stopTimeCount > 0)
constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const });
const constraints = [];
if (routeStopCount > 0)
constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const });
const scheduleCount = originCount + destCount;
if (scheduleCount > 0)
constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const });
if (stopTimeCount > 0)
constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const });
if (constraints.length > 0)
throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints);
if (constraints.length > 0)
throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints);
}
if (cascade) {
await this.prisma.tripStopTime.deleteMany({ where: { stationId: id } });
await this.prisma.trainSchedule.deleteMany({
where: { OR: [{ originStationId: id }, { destinationStationId: id }] },
});
await this.prisma.routeStop.deleteMany({ where: { stationId: id } });
}
const deleted = await this.prisma.station.delete({ where: { id } });