diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 96bd4b387..9a2053943 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -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') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 57097c9cd..a2ea23e09 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -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 }; } diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 24f9981fc..7422e45b0 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -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') diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index a725b9cd4..dc29f4ecc 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -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 } }); diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index de61f92ca..07e324336 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -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') diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index da97dcfa9..a474b1edf 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -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, diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index a9fbfc7d2..f0b719386 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -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') diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index d41fcbfed..84be880be 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -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 }; } diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts index 1cd382862..f432cf072 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -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 ──────────────────────────────────────────────────────────── diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 479f6b0ca..e459e3663 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -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 }; } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 1a5547d48..3cb95104d 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -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() diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index bd7cc7258..92a0a66db 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -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 } }); } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts index a89ecd87d..2b1842c2a 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts @@ -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'); } } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index ec2480b21..ff40a30ab 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -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 } }); diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 974c25470..be2785d43 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -38,6 +38,8 @@ function BookingsPageContent() { const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [bookingToDelete, setBookingToDelete] = useState(null); const [deleteError, setDeleteError] = useState(null); + const [deleteCascade, setDeleteCascade] = useState(false); + const [deleteCascadeChecked, setDeleteCascadeChecked] = useState(false); const [successMessage, setSuccessMessage] = useState(''); const [exportModalOpen, setExportModalOpen] = useState(false); const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); @@ -62,17 +64,27 @@ function BookingsPageContent() { }); const deleteMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/bookings/${id}`), + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/bookings/${id}${cascade ? '?cascade=true' : ''}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bookings'] }); setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); + setDeleteCascade(false); + setDeleteCascadeChecked(false); setSuccessMessage('Booking deleted successfully'); setTimeout(() => setSuccessMessage(''), 3000); }, onError: (error: any) => { - setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete booking'); + const msg = error?.response?.data?.message || error?.message || 'Failed to delete booking'; + const isFkError = msg?.includes('Cannot delete') || error?.response?.status === 400; + if (isFkError && !deleteCascade) { + setDeleteCascade(true); + setDeleteCascadeChecked(false); + setDeleteError(Array.isArray(msg) ? msg.join(' ') : msg); + } else { + setDeleteError(Array.isArray(msg) ? msg.join(' ') : msg); + } }, }); @@ -246,7 +258,7 @@ function BookingsPageContent() { const actions = [ { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, - { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, + { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, ]; return ( @@ -493,12 +505,16 @@ function BookingsPageContent() { { setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); }} - onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync(bookingToDelete.id); }} + onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); }} + onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync({ id: bookingToDelete.id, cascade: deleteCascade && deleteCascadeChecked }); }} title="Delete Booking" message={`Permanently delete booking ${bookingToDelete?.bookingRef}? This cannot be undone and will release all associated seats.`} confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger error={deleteError ?? undefined} + warning={!deleteCascade ? undefined : undefined} + cascadeWarning={deleteCascade ? "This booking has related tickets, payments, or modification records that will also be permanently deleted." : undefined} + cascadeChecked={deleteCascadeChecked} + onCascadeChange={(checked) => setDeleteCascadeChecked(checked)} /> setExportModalOpen(false)} title="Export Bookings" size="md"> diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx index 65671959b..348268ca2 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -15,7 +15,7 @@ export default function ClassesPage() { const [filters, setFilters] = useState({ search: '' }); const [showModal, setShowModal] = useState(false); const [editingClass, setEditingClass] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null; error?: string }>({ isOpen: false, class: null }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, class: null }); const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); const queryClient = useQueryClient(); @@ -56,13 +56,18 @@ export default function ClassesPage() { }); const deleteMutation = useMutation({ - mutationFn: seatClassesApi.delete, + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => seatClassesApi.delete(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['classes'] }); }, onError: (e: any) => { const msg = e?.response?.data?.message || e?.message || 'Failed to delete class'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const isFkError = msg?.includes('Cannot delete') || e?.response?.status === 400; + if (isFkError && !deleteConfirm.cascade) { + setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } else { + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } }, }); @@ -99,7 +104,7 @@ export default function ClassesPage() { const confirmDelete = async () => { if (!deleteConfirm.class) return; try { - await deleteMutation.mutateAsync(deleteConfirm.class.id); + await deleteMutation.mutateAsync({ id: deleteConfirm.class.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); setDeleteConfirm({ isOpen: false, class: null }); } catch { // error is set by onError handler @@ -239,7 +244,10 @@ export default function ClassesPage() { isDanger={true} isLoading={deleteMutation.isPending} error={deleteConfirm.error} - warning="This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing." + warning={!deleteConfirm.cascade ? "This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing." : undefined} + cascadeWarning={deleteConfirm.cascade ? "This class has related fare rules that will also be permanently deleted." : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} /> {/* Add/Edit Modal */} diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index f97456a3d..eb8ebf036 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -145,7 +145,7 @@ export default function CoachesPage() { const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); const [editingItem, setEditingItem] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, item: null }); const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); const [isBedCoach, setIsBedCoach] = useState(false); const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false); @@ -217,7 +217,7 @@ export default function CoachesPage() { }); const deleteCoachMutation = useMutation({ - mutationFn: fleetApi.deleteCoach, + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => fleetApi.deleteCoach(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['coaches'] }); }, @@ -277,12 +277,17 @@ export default function CoachesPage() { if (deleteConfirm.item?.isCoachType) { await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id); } else { - await deleteCoachMutation.mutateAsync(deleteConfirm.item.id); + await deleteCoachMutation.mutateAsync({ id: deleteConfirm.item.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); } setDeleteConfirm({ isOpen: false, item: null }); } catch (err: any) { const msg = err?.response?.data?.message || err?.message || 'Delete failed'; - setDeleteConfirm((prev) => ({ ...prev, error: msg })); + const isFkError = msg?.includes('Cannot delete') || err?.response?.status === 400; + if (isFkError && !deleteConfirm.item?.isCoachType && !deleteConfirm.cascade) { + setDeleteConfirm((prev) => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } else { + setDeleteConfirm((prev) => ({ ...prev, error: msg })); + } } }; @@ -689,11 +694,14 @@ export default function CoachesPage() { isDanger={true} isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending} error={deleteConfirm.error} - warning={ + warning={!deleteConfirm.cascade ? ( deleteConfirm.item?.isCoachType ? 'This coach type may have coaches assigned. Deleting it may impact these systems.' : 'This coach may be assigned to schedules. Deleting it may impact these systems.' - } + ) : undefined} + cascadeWarning={deleteConfirm.cascade ? "This coach has related assignments or seats that will also be permanently deleted." : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} /> {/* Add/Edit Modal */} diff --git a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx index ab0386a19..b08be0234 100644 --- a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx @@ -47,6 +47,7 @@ export default function PackagesPage() { const [tierError, setTierError] = useState(null); const [deletePackageConfirm, setDeletePackageConfirm] = useState(null); const [deletePackageError, setDeletePackageError] = useState(null); + const [deletePackageCascade, setDeletePackageCascade] = useState(false); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ @@ -120,11 +121,12 @@ export default function PackagesPage() { }); const deletePackageMutation = useMutation({ - mutationFn: (id: string) => packagesApi.remove(id), + mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => packagesApi.remove(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setDeletePackageConfirm(null); setDeletePackageError(null); + setDeletePackageCascade(false); }, onError: (e: any) => setDeletePackageError(e?.response?.data?.message || e?.message || 'Failed to delete package'), }); @@ -293,7 +295,7 @@ export default function PackagesPage() { }, { label: 'Delete', icon: Trash2, variant: 'danger' as const, - onClick: (p: any) => { setDeletePackageError(null); setDeletePackageConfirm(p); }, + onClick: (p: any) => { setDeletePackageError(null); setDeletePackageCascade(false); setDeletePackageConfirm(p); }, }, ]; @@ -522,14 +524,17 @@ export default function PackagesPage() { {/* Delete Package Confirmation */} { setDeletePackageConfirm(null); setDeletePackageError(null); }} - onConfirm={() => deletePackageMutation.mutate(deletePackageConfirm.id)} + onClose={() => { setDeletePackageConfirm(null); setDeletePackageError(null); setDeletePackageCascade(false); }} + onConfirm={() => deletePackageMutation.mutate({ id: deletePackageConfirm.id, cascade: deletePackageCascade })} title="Delete Package" message={`Delete "${deletePackageConfirm?.name}"? This will also remove all price tiers and cannot be undone.`} confirmText="Delete" isDanger isLoading={deletePackageMutation.isPending} error={deletePackageError ?? undefined} + cascadeWarning={deletePackageError ? 'This package has active bookings or related records. Check the box below to force delete everything.' : undefined} + cascadeChecked={deletePackageCascade} + onCascadeChange={setDeletePackageCascade} /> {/* Delete Tier Confirmation */} diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index 9765195e2..cff41ede2 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -40,6 +40,7 @@ export default function PassengersPage() { const [selectedPassenger, setSelectedPassenger] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); const [deleteError, setDeleteError] = useState(null); + const [deleteCascade, setDeleteCascade] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false); const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); const [exportDateFrom, setExportDateFrom] = useState(''); @@ -51,11 +52,12 @@ export default function PassengersPage() { const queryClient = useQueryClient(); const deleteMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`), + mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => passengersApi.delete(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['passengers'] }); setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteError(null); + setDeleteCascade(false); }, onError: (error: any) => { setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete passenger'); @@ -154,7 +156,7 @@ export default function PassengersPage() { const actions = [ { label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye }, - { label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2 }, + { label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2 }, ]; return ( @@ -229,18 +231,20 @@ export default function PassengersPage() { { setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteError(null); }} + onClose={() => { setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteError(null); setDeleteCascade(false); }} onConfirm={async () => { if (deleteConfirm.passenger) { - await deleteMutation.mutateAsync(deleteConfirm.passenger.id); + await deleteMutation.mutateAsync({ id: deleteConfirm.passenger.id, cascade: deleteCascade }); } }} title="Delete Passenger" message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`} confirmText="Delete" isDanger isLoading={deleteMutation.isPending} - warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records." error={deleteError ?? undefined} + cascadeWarning={deleteError ? 'This passenger has related records (bookings, loyalty, wallet). Check the box below to force delete everything.' : undefined} + cascadeChecked={deleteCascade} + onCascadeChange={setDeleteCascade} /> {/* Passenger Details Modal */} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 43592c4f7..2524c20b0 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -171,7 +171,7 @@ export default function RoutesPage() { const [originStationId, setOriginStationId] = useState(''); const [destinationStationId, setDestinationStationId] = useState(''); const [destinationDistance, setDestinationDistance] = useState(undefined); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string }>({ isOpen: false, route: null }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null }); const [search, setSearch] = useState(''); const queryClient = useQueryClient(); @@ -207,13 +207,18 @@ export default function RoutesPage() { }); const deleteMutation = useMutation({ - mutationFn: routesApi.delete, + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => routesApi.delete(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routes'] }); }, onError: (e: any) => { const msg = e?.response?.data?.message || e?.message || 'Failed to delete route'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const isFkError = msg?.includes('Cannot delete') || e?.response?.status === 400; + if (isFkError && !deleteConfirm.cascade) { + setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } else { + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } }, }); @@ -325,7 +330,7 @@ export default function RoutesPage() { const confirmDelete = async () => { if (!deleteConfirm.route) return; try { - await deleteMutation.mutateAsync(deleteConfirm.route.id); + await deleteMutation.mutateAsync({ id: deleteConfirm.route.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); setDeleteConfirm({ isOpen: false, route: null }); } catch { // error is set by onError handler @@ -471,7 +476,10 @@ export default function RoutesPage() { isDanger={true} isLoading={deleteMutation.isPending} error={deleteConfirm.error} - warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems." + warning={!deleteConfirm.cascade ? "This route may be referenced by schedules and bookings. Deleting it may impact these systems." : undefined} + cascadeWarning={deleteConfirm.cascade ? "This route has related schedules that will also be permanently deleted." : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} /> (null); const [selectedSchedules, setSelectedSchedules] = useState>(new Set()); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string }>( + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string; cascade?: boolean; cascadeChecked?: boolean }>( { isOpen: false, item: null } ); const [error, setError] = useState(null); @@ -196,13 +196,18 @@ export default function SchedulesPage() { }); const deleteScheduleMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/schedules/${id}`), + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/schedules/${id}${cascade ? '?cascade=true' : ''}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['schedules'] }); }, onError: (err: any) => { const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedule'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const isFkError = msg?.includes('Cannot delete') || err?.response?.status === 400; + if (isFkError && !deleteConfirm.cascade) { + setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } else { + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } }, }); @@ -310,7 +315,7 @@ export default function SchedulesPage() { const ids = deleteConfirm.item as string[]; await bulkDeleteMutation.mutateAsync(ids); } else if (deleteConfirm.item) { - await deleteScheduleMutation.mutateAsync(deleteConfirm.item.id); + await deleteScheduleMutation.mutateAsync({ id: deleteConfirm.item.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); } setDeleteConfirm({ isOpen: false, item: null }); } catch { @@ -658,7 +663,10 @@ export default function SchedulesPage() { isDanger={true} isLoading={deleteScheduleMutation.isPending || bulkDeleteMutation.isPending} error={deleteConfirm.error} - warning="Schedules with existing bookings cannot be deleted." + warning={!deleteConfirm.cascade ? "Schedules with existing bookings cannot be deleted." : undefined} + cascadeWarning={deleteConfirm.cascade ? "This schedule has related bookings or tickets that will also be permanently deleted." : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} /> (null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null; error?: string }>({ isOpen: false, station: null }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, station: null }); const [formError, setFormError] = useState(null); const queryClient = useQueryClient(); @@ -46,13 +46,18 @@ export default function StationsPage() { }); const deleteMutation = useMutation({ - mutationFn: stationsApi.delete, + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => stationsApi.delete(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['stations'] }); }, onError: (e: any) => { const msg = e?.response?.data?.message || e?.message || 'Failed to delete station'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const isFkError = msg?.includes('Cannot delete') || e?.response?.status === 400; + if (isFkError && !deleteConfirm.cascade) { + setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } else { + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } }, }); @@ -92,7 +97,7 @@ export default function StationsPage() { const confirmDelete = async () => { if (!deleteConfirm.station) return; try { - await deleteMutation.mutateAsync(deleteConfirm.station.id); + await deleteMutation.mutateAsync({ id: deleteConfirm.station.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); setDeleteConfirm({ isOpen: false, station: null }); } catch { // error is set by onError handler @@ -244,7 +249,10 @@ export default function StationsPage() { isDanger={true} isLoading={deleteMutation.isPending} error={deleteConfirm.error} - warning="This station may be referenced by routes, schedules, and bookings. Deleting it may impact these systems." + warning={!deleteConfirm.cascade ? "This station may be referenced by routes, schedules, and bookings. Deleting it may impact these systems." : undefined} + cascadeWarning={deleteConfirm.cascade ? "This station has related records (route stops, schedules, or stop times) that will also be permanently deleted." : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} /> {/* Add/Edit Modal */} diff --git a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx index b54615f3e..9dd17dfb8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx @@ -16,7 +16,7 @@ export default function TrainsPage() { const [showModal, setShowModal] = useState(false); const [editingTrain, setEditingTrain] = useState(null); const [search, setSearch] = useState(''); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null; error?: string }>({ isOpen: false, train: null }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, train: null }); const queryClient = useQueryClient(); @@ -50,13 +50,18 @@ export default function TrainsPage() { }); const deleteTrainMutation = useMutation({ - mutationFn: (id: string) => fleetApi.deleteTrain(id), + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => fleetApi.deleteTrain(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['trains'] }); }, onError: (error: any) => { const msg = error?.response?.data?.message || error?.message || 'Failed to delete train'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const isFkError = msg?.includes('Cannot delete') || error?.response?.status === 400; + if (isFkError && !deleteConfirm.cascade) { + setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } else { + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } }, }); @@ -77,7 +82,7 @@ export default function TrainsPage() { const confirmDelete = async () => { if (!deleteConfirm.train) return; try { - await deleteTrainMutation.mutateAsync(deleteConfirm.train.id); + await deleteTrainMutation.mutateAsync({ id: deleteConfirm.train.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); setDeleteConfirm({ isOpen: false, train: null }); } catch { // error is set by onError handler @@ -237,7 +242,10 @@ export default function TrainsPage() { isDanger={true} isLoading={deleteTrainMutation.isPending} error={deleteConfirm.error} - warning="This train may be assigned to schedules and trips. Deleting it may impact these systems and associated bookings." + warning={!deleteConfirm.cascade ? "This train may be assigned to schedules and trips. Deleting it may impact these systems and associated bookings." : undefined} + cascadeWarning={deleteConfirm.cascade ? "This train has related schedules that will also be permanently deleted." : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} /> {/* Add/Edit Modal */} diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx index 39c68b397..c25863d60 100644 --- a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx @@ -17,6 +17,9 @@ interface ConfirmDialogProps { isDanger?: boolean; warning?: string; error?: string; + cascadeWarning?: string; + onCascadeChange?: (checked: boolean) => void; + cascadeChecked?: boolean; } export default function ConfirmDialog({ @@ -31,6 +34,9 @@ export default function ConfirmDialog({ isDanger = false, warning, error, + cascadeWarning, + onCascadeChange, + cascadeChecked = false, }: ConfirmDialogProps) { useEffect(() => { if (!isOpen) return; @@ -129,6 +135,26 @@ export default function ConfirmDialog({ ); })()} + + {cascadeWarning && ( +
+
+ +

{cascadeWarning}

+
+ +
+ )} {/* Footer */} @@ -140,6 +166,7 @@ export default function ConfirmDialog({ variant={isDanger ? 'danger' : 'primary'} onClick={onConfirm} loading={isLoading} + disabled={!!cascadeWarning && !cascadeChecked} > {confirmText} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index b44e33f74..f611d3083 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -61,6 +61,7 @@ export const passengersApi = { }, getById: (id: string) => apiClient.get(`/passengers/${id}`), verify: (nationalId: string) => apiClient.post('/passengers/verify-fayda', { nationalId }), + delete: (id: string, cascade?: boolean) => apiClient.delete(`/passengers/${id}${cascade ? '?cascade=true' : ''}`), }; // Stations API export const stationsApi = { @@ -79,7 +80,7 @@ export const stationsApi = { getById: (id: string) => apiClient.get(`/stations/${id}`), create: (data: any) => apiClient.post('/stations', data), update: (id: string, data: any) => apiClient.patch(`/stations/${id}`, data), - delete: (id: string) => apiClient.delete(`/stations/${id}`), + delete: (id: string, cascade?: boolean) => apiClient.delete(`/stations/${id}${cascade ? '?cascade=true' : ''}`), }; // Fleet API @@ -108,11 +109,11 @@ export const fleetApi = { }, createTrain: (data: any) => apiClient.post('/fleet/trains', data), updateTrain: (id: string, data: any) => apiClient.patch(`/fleet/trains/${id}`, data), - deleteTrain: (id: string) => apiClient.delete(`/fleet/trains/${id}`), + deleteTrain: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/trains/${id}${cascade ? '?cascade=true' : ''}`), restoreTrain: (id: string) => apiClient.patch(`/fleet/trains/${id}/restore`, {}), createCoach: (data: any) => apiClient.post('/fleet/coaches', data), updateCoach: (id: string, data: any) => apiClient.patch(`/fleet/coaches/${id}`, data), - deleteCoach: (id: string) => apiClient.delete(`/fleet/coaches/${id}`), + deleteCoach: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/coaches/${id}${cascade ? '?cascade=true' : ''}`), generateSeatMap: (data: any) => apiClient.post('/fleet/seatmap/generate', data), }; @@ -362,7 +363,7 @@ export const seatClassesApi = { getById: (id: string) => apiClient.get(`/fleet/classes/${id}`), create: (data: any) => apiClient.post('/fleet/classes', data), update: (id: string, data: any) => apiClient.patch(`/fleet/classes/${id}`, data), - delete: (id: string) => apiClient.delete(`/fleet/classes/${id}`), + delete: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/classes/${id}${cascade ? '?cascade=true' : ''}`), }; // Food & Dining API @@ -409,7 +410,7 @@ export const packagesApi = { update: (id: string, data: any) => apiClient.patch(`/packages/${id}`, data), activate: (id: string) => apiClient.patch(`/packages/${id}/activate`, {}), deactivate: (id: string) => apiClient.patch(`/packages/${id}/deactivate`, {}), - remove: (id: string) => apiClient.delete(`/packages/${id}`), + remove: (id: string, cascade?: boolean) => apiClient.delete(`/packages/${id}${cascade ? '?cascade=true' : ''}`), getBookings: async (params?: any) => { const cleanParams = Object.fromEntries( Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null) diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/routes.ts b/apps/edr-passenger-web/backoffice/src/lib/api/routes.ts index 80c30e08b..638063b84 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/routes.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/routes.ts @@ -30,8 +30,8 @@ export const routesApi = { return apiClient.patch(`/routes/${id}`, data); }, - delete: (id: string) => { - return apiClient.delete(`/routes/${id}`); + delete: (id: string, cascade?: boolean) => { + return apiClient.delete(`/routes/${id}${cascade ? '?cascade=true' : ''}`); }, getFareRules: (routeId: string) => { diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 6ed06039c..af5748ed7 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -10,7 +10,7 @@ import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; import { CheckCircle, Copy, Train, FileText } from 'lucide-react'; import { format } from 'date-fns'; -import { isChild, calculatePassengerFare } from '@/utils/fare-utils'; +import { isChild, isFirstChild, calculatePassengerFare } from '@/utils/fare-utils'; type BookingWithTicket = { id: string; @@ -27,7 +27,7 @@ type BookingWithTicket = { export default function ConfirmationPage() { const router = useRouter(); - const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName, packageTierPriceMinor } = useBookingStore(); + const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName, packageTierPriceMinor, packageId } = useBookingStore(); // The currency/amount actually confirmed for the payment option the user selected — // null when no payment step ran (e.g. a fully-discounted, zero-amount booking). const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore(); @@ -338,9 +338,11 @@ export default function ConfirmationPage() { const isPackage = !!packageTierPriceMinor; const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1; const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; - const pkgChildFare = isPackage ? Math.round(pkgAdultFare * 0.1) : 0; + const adultCount = passengers.filter(p => !isChild(p)).length; + const childCount = passengers.filter(p => isChild(p)).length; + const pkgPaidChildrenCount = Math.max(0, childCount - adultCount); const fallback = isPackage - ? passengers.reduce((sum, p) => sum + (isChild(p) ? pkgChildFare : pkgAdultFare), 0) + ? adultCount * pkgAdultFare + pkgPaidChildrenCount * pkgAdultFare : isRoundTrip ? passengers.reduce((sum, p, i) => { const outFare = (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0); @@ -393,20 +395,27 @@ export default function ConfirmationPage() {

Seat(s)

- {isRoundTrip ? ( -
+ {(() => { + const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; + const isFreeChild = packageId + ? index >= adultCount && (index - adultCount) < adultCount + : isChild(passenger) && isFirstChild(passengers, index); + if (isFreeChild) return

; + return isRoundTrip ? ( +
+

+ Outbound: {(passenger as any).outboundCoachNumber && {(passenger as any).outboundCoachNumber}} — {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'} +

+

+ Return: {(passenger as any).inboundCoachNumber && {(passenger as any).inboundCoachNumber}} — {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'} +

+
+ ) : (

- Outbound: {(passenger as any).outboundCoachNumber && {(passenger as any).outboundCoachNumber}} — {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'} + {passenger.coachNumber && (Coach {passenger.coachNumber})} — {passenger.seatNumber || 'Auto-assigned at boarding'}

-

- Return: {(passenger as any).inboundCoachNumber && {(passenger as any).inboundCoachNumber}} — {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'} -

-
- ) : ( -

- {passenger.coachNumber && (Coach {passenger.coachNumber})} — {passenger.seatNumber || 'Auto-assigned at boarding'} -

- )} + ); + })()}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 8e36d79b6..52ce64a2c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -37,9 +37,6 @@ export default function PaymentPage() { const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const isPackage = !!packageTierPriceMinor; - const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1; - const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; - const pkgChildFare = isPackage ? Math.round(pkgAdultFare * 0.1) : 0; const displayCurrency = 'ETB' as const; @@ -65,12 +62,16 @@ export default function PaymentPage() { }); // Per-leg totals across all passengers. - // Package: one leg = pkgAdultFare/pkgChildFare (already ×1 per leg; pkgAdultFare already has ×2 for round-trip baked in via pkgRoundTripMultiplier — so per-leg is packageTierPriceMinor). + // First child per adult = FREE (no seat); additional children = full adult fare. const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; const childCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; + const pkgPaidChildrenCount = Math.max(0, childCount - adultCount); + const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1; + const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; + const pkgChildFare = pkgAdultFare; // paid children pay full adult fare const pkgPerLegAdultFare = isPackage ? packageTierPriceMinor! : 0; - const pkgPerLegChildFare = isPackage ? Math.round(pkgPerLegAdultFare * 0.1) : 0; - const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + childCount * pkgPerLegChildFare : 0; + const pkgPerLegChildFare = pkgPerLegAdultFare; // paid children pay full adult fare per leg + const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + pkgPaidChildrenCount * pkgPerLegChildFare : 0; // Prefer each passenger's own seat fare (set during seat selection) over the schedule's // flat baseFareAdult — bed coaches price Upper/Middle/Lower berths differently, so a @@ -90,7 +91,7 @@ export default function PaymentPage() { }, 0) : 0); const baseFare = isPackage - ? adultCount * pkgAdultFare + childCount * pkgChildFare + ? adultCount * pkgAdultFare + pkgPaidChildrenCount * pkgChildFare : isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => { const scheduleFare = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0; const farePerPassenger = (p as any).seatFareMinor ?? scheduleFare; @@ -294,11 +295,15 @@ export default function PaymentPage() {

Fare breakdown

{passengers.map((p, i) => { const isChildPassenger = isChild(p); + // For package bookings: children ordered after adults; first adultCount children are free + const childIndex = i - adultCount; + const isPkgFreeChild = isPackage && isChildPassenger && childIndex >= 0 && childIndex < adultCount; let passengerTotal: number; let isFreeChild = false; if (isPackage) { - passengerTotal = isChildPassenger ? pkgChildFare : pkgAdultFare; + isFreeChild = isPkgFreeChild; + passengerTotal = isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare); } else { // Prefer this passenger's actual seat fare (varies by berth for bed coaches) // over the schedule's flat baseFareAdult. @@ -319,9 +324,9 @@ export default function PaymentPage() { {p.name || `Passenger ${i + 1}`} {isChildPassenger && ( - ({isPackage ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'}) + ({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'}) )} @@ -332,19 +337,19 @@ export default function PaymentPage() { {isRoundTrip && (
- Outbound {!isPackage && isFreeChild ? '(Free)' : ''} + Outbound {isFreeChild ? '(Free)' : ''} {formatFare( isPackage - ? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare) + ? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)) : calculatePassengerFare(passengers, i, (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0)), displayCurrency )}
- Return {!isPackage && isFreeChild ? '(Free)' : ''} + Return {isFreeChild ? '(Free)' : ''} {formatFare( isPackage - ? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare) + ? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)) : calculatePassengerFare(passengers, i, (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0)), displayCurrency )} diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 6e057a3e8..64afeb82b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -266,6 +266,16 @@ export default function ReviewPage() { } // Build booking request for authenticated users + // For package bookings, free children (first child per adult, no seat assigned) + // are excluded from the passengers array — the backend derives them from adultCount/childCount. + const bookingPassengers = passengers.filter((p, i) => { + if (packageId) { + const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; + return !isFreePkgChild; + } + return !(isChild(p) && isFirstChild(passengers, i)); + }); + bookingData = { passengerId: passengerId, scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, @@ -275,7 +285,7 @@ export default function ReviewPage() { seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', displayCurrency: displayCurrency, - passengers: passengers.map((p) => { + passengers: bookingPassengers.map((p) => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; return { @@ -311,6 +321,16 @@ export default function ReviewPage() { if (priceTierId) bookingData.priceTierId = priceTierId; } else { // For guests: send full passenger details array + // For package bookings, free children (first child per adult, no seat assigned) + // are excluded from the passengers array — the backend derives them from adultCount/childCount. + const guestBookingPassengers = passengers.filter((p, i) => { + if (packageId) { + const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; + return !isFreePkgChild; + } + return !(isChild(p) && isFirstChild(passengers, i)); + }); + bookingData = { scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, holdId: seatHold?.holdId || '', @@ -319,7 +339,7 @@ export default function ReviewPage() { seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', displayCurrency: displayCurrency, - passengers: passengers.map(p => { + passengers: guestBookingPassengers.map(p => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; return { @@ -449,12 +469,15 @@ export default function ReviewPage() { const isPackageBooking = packageTierPriceMinor !== null; // packageTierPriceMinor is the per-adult fare for ONE leg. - // Round-trip packages multiply by 2; children pay 10% of the adult fare. - const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1; - const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; - const pkgChildFare = isPackageBooking ? Math.round(pkgAdultFare * 0.1) : 0; + // Round-trip packages multiply by 2. + // First child per adult travels FREE; additional children pay full adult fare. const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; + const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1; + const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; + const pkgPaidChildrenCount = Math.max(0, childPassengerCount - adultPassengerCount); + // Paid children pay full adult fare + const pkgChildFare = pkgAdultFare; // full fare for paid children // For package bookings, passengers are initialized without dateOfBirth so isChild() is // unreliable. Use the stored adultCount from searchCriteria to determine category by index. @@ -474,7 +497,7 @@ export default function ReviewPage() { }; const total = isPackageBooking - ? adultPassengerCount * pkgAdultFare + childPassengerCount * pkgChildFare + ? adultPassengerCount * pkgAdultFare + pkgPaidChildrenCount * pkgChildFare : passengers.reduce((sum, p, i) => { const isChildPassenger = isChild(p); const line = fareBreakdown?.passengers?.[i]; @@ -484,6 +507,16 @@ export default function ReviewPage() { return sum + (seatFare ?? line?.fareMinor ?? 0); }, 0); + // For package bookings, determine if a child is free (first per adult) or paid. + // Children are ordered after adults in the passengers array (set on package detail page). + const isPkgFreeChild = (index: number) => { + if (!isPackageBooking) return false; + if (!isPackageChild(index)) return false; + // childIndex = position among children (0-based) + const childIndex = index - adultPassengerCount; + return childIndex < adultPassengerCount; // first adultCount children are free + }; + // Shared fare sidebar — rendered in right column (desktop) and inline (mobile) const FareSidebar = () => (
@@ -493,10 +526,12 @@ export default function ReviewPage() { {passengers.map((p, i) => { const line = fareBreakdown?.passengers?.[i]; const isChildPassenger = isPackageChild(i); - const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); + const isFreeChild = isPackageBooking + ? isPkgFreeChild(i) + : (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); const seatFare = getPassengerSeatFare(p); const passengerTotal = isPackageBooking - ? (isChildPassenger ? pkgChildFare : pkgAdultFare) + ? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare)) : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0)); return ( @@ -506,9 +541,9 @@ export default function ReviewPage() { {p.name || `Passenger ${i + 1}`} {isChildPassenger && ( - ({isPackageBooking ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'}) + ({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'}) )} @@ -817,7 +852,7 @@ export default function ReviewPage() {

Outbound Seat

{(p as any).outboundCoachNumber && {(p as any).outboundCoachNumber} — } - {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : 'Auto-assign'} + {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}

{(p as any).outboundSeatId && (

{formatSeatClass(outboundSchedule)}

@@ -827,7 +862,7 @@ export default function ReviewPage() {

Return Seat

{(p as any).inboundCoachNumber && {(p as any).inboundCoachNumber} —} - {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : 'Auto-assign'} + {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}

{(p as any).inboundSeatId && (

{formatSeatClass(inboundSchedule)}

@@ -839,7 +874,7 @@ export default function ReviewPage() {

Seat

{p.coachNumber && {p.coachNumber} — } - {p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'} + {p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}

{p.seatId && (

{formatSeatClass(selectedSchedule)}

diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index b1987f588..b0a7e8fd8 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -233,22 +233,26 @@ export default function SeatsPage() { ? (currentJourneyType === "inbound" ? originalFaresRef.current.inbound : originalFaresRef.current.outbound) : originalFaresRef.current.oneWay; - // Child seat allocation rule: let A = adults, C = children (isChild = under 5). - // If C > A, only A - 1 children get their own seat and the rest share with an adult. - // If C <= A, no child gets a separate seat — all of them share with an adult. - // Adults always need their own seat. + // Seat eligibility: + // - All adults always need their own seat. + // - For package bookings: first child per adult travels free with no seat; + // additional children (beyond one per adult) pay full fare and need a seat. + // Free children are pre-marked with a child DOB on the package detail page. + // - For regular bookings: same "first child per adult free" rule applies. + // In both cases: children are identified by isChild() (DOB < 5 years). + // Free children = first `adultCount` children (by position); paid children = the rest. const seatEligibility = useMemo(() => { const adultIndices = passengers.map((_, i) => i).filter((i) => !isChild(passengers[i])); const childIndices = passengers.map((_, i) => i).filter((i) => isChild(passengers[i])); const adultCount = adultIndices.length; - const childCount = childIndices.length; - const eligibleChildCount = childCount > adultCount ? Math.max(adultCount - 1, 0) : 0; - const eligibleChildIndices = childIndices.slice(0, eligibleChildCount); - const eligibleSet = new Set([...adultIndices, ...eligibleChildIndices]); + // First `adultCount` children are free (no seat); the rest are paid (need a seat). + const freeChildIndices = new Set(childIndices.slice(0, adultCount)); + const paidChildIndices = childIndices.slice(adultCount); + const eligibleSet = new Set([...adultIndices, ...paidChildIndices]); - // Children who don't get their own seat share with an adult (round-robin, for display). + // Free children share with an adult (round-robin, for display). const sharingWithAdult = new Map(); - childIndices.slice(eligibleChildCount).forEach((childIdx, offset) => { + Array.from(freeChildIndices).forEach((childIdx, offset) => { const adultIdx = adultIndices[offset % Math.max(adultIndices.length, 1)]; if (adultIdx != null) { sharingWithAdult.set(childIdx, passengers[adultIdx]?.name || `Adult ${adultIdx + 1}`); diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index 457425578..3e4dc04df 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -361,8 +361,7 @@ function PriceTiersPanel({ // ─── Passenger count picker ────────────────────────────────────────────────── const PKG_MAX_ADULTS = 5; -const PKG_CHILDREN_PER_ADULT = 2; -const PKG_CHILD_FARE_RATIO = 0.1; +const PKG_CHILDREN_PER_ADULT = 5; function PassengerCountModal({ tier, @@ -386,8 +385,11 @@ function PassengerCountModal({ const [departureStationId, setDepartureStationId] = useState(''); const [showStationError, setShowStationError] = useState(false); const remaining = tier.availableSeats - tier.bookedSeats; - const childFareMinor = Math.round(tier.priceMinor * PKG_CHILD_FARE_RATIO); - const totalMinor = (adultCount * tier.priceMinor + childCount * childFareMinor) * priceMultiplier; + // First child per adult travels free (no seat); additional children pay full adult fare + const freeChildren = Math.min(childCount, adultCount); + const paidChildren = Math.max(0, childCount - adultCount); + // Only paid children need seats; free children share with an adult + const totalMinor = (adultCount * tier.priceMinor + paidChildren * tier.priceMinor) * priceMultiplier; return ( <> @@ -404,13 +406,13 @@ function PassengerCountModal({

Coach type

{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}

-

{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · actual class chosen on seat map

+

{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)

{[ - { label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: setAdultCount }, - { label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 10% of adult fare`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, remaining - adultCount), set: setChildCount }, + { label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: (v: number) => { setAdultCount(v); const newMax = Math.min(v * PKG_CHILDREN_PER_ADULT, v + Math.max(0, remaining - v)); setChildCount(c => Math.min(c, newMax)); } }, + { label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 1st per adult FREE (no seat)`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, adultCount + Math.max(0, remaining - adultCount)), set: setChildCount }, ].map(({ label, sub, value, min, max, set }) => (
@@ -433,6 +435,12 @@ function PassengerCountModal({
))} + {freeChildren > 0 && ( +
+ {freeChildren} child{freeChildren > 1 ? 'ren' : ''} travel free (no seat) + ETB 0.00 +
+ )}
Total{priceMultiplier === 2 ? ' (round-trip)' : ''} {formatPrice(totalMinor, tier.currency)} @@ -572,12 +580,21 @@ export default function PackageDetailPage() { } setPassengers( - Array.from({ length: passengerCount }, (_, i) => ({ - name: "", - dateOfBirth: "", - nationality: "ETHIOPIAN", - isPrimaryPassenger: i === 0, - })), + Array.from({ length: passengerCount }, (_, i) => { + // First `adultCount` entries are adults; remaining are children. + // Among children, the first `adultCount` are free (one per adult, no seat). + const isChildPassenger = i >= adultCount; + const childIndex = i - adultCount; // 0-based index among children + const isFreeChild = isChildPassenger && childIndex < adultCount; + return { + name: "", + // Free children travel without a seat — give them a synthetic DOB that + // makes isChild() return true so seat eligibility logic excludes them. + dateOfBirth: isFreeChild ? new Date(Date.now() - 2 * 365.25 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) : "", + nationality: "ETHIOPIAN", + isPrimaryPassenger: i === 0, + }; + }), ); // Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing