diff --git a/apps/edr-passenger-api/prisma/migrations/20260706000000_add_departure_station_to_bookings/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260706000000_add_departure_station_to_bookings/migration.sql new file mode 100644 index 000000000..f782c3c64 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260706000000_add_departure_station_to_bookings/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable: add package_departure_station_id to Booking +ALTER TABLE "passenger"."Booking" + ADD COLUMN "packageDepartureStationId" TEXT; + +-- AlterTable: add package_departure_station_id to PackageBooking +ALTER TABLE "passenger"."PackageBooking" + ADD COLUMN "packageDepartureStationId" TEXT; + +-- AddForeignKey: Booking -> Station +ALTER TABLE "passenger"."Booking" + ADD CONSTRAINT "Booking_packageDepartureStationId_fkey" + FOREIGN KEY ("packageDepartureStationId") + REFERENCES "passenger"."Station"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey: PackageBooking -> Station +ALTER TABLE "passenger"."PackageBooking" + ADD CONSTRAINT "PackageBooking_packageDepartureStationId_fkey" + FOREIGN KEY ("packageDepartureStationId") + REFERENCES "passenger"."Station"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index ba3571ab1..83c2b884e 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -330,7 +330,9 @@ model Station { originSchedules TrainSchedule[] @relation("OriginTrips") destinationSchedules TrainSchedule[] @relation("DestinationTrips") stopTimes TripStopTime[] - crowdSignals StationCrowdSignal[] + crowdSignals StationCrowdSignal[] + bookingDepartures Booking[] @relation("BookingPackageDepartureStation") + packageBookingDepartures PackageBooking[] @relation("PackageBookingDepartureStation") @@index([city, countryCode]) @@index([sequence]) @@schema("passenger") @@ -541,6 +543,7 @@ model Booking { promoCode String? paidAt DateTime? paymentReminderSentAt DateTime? + packageDepartureStationId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt passenger Passenger @relation(fields: [passengerId], references: [id]) @@ -548,6 +551,7 @@ model Booking { returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id]) package TravelPackage? @relation(fields: [packageId], references: [id]) priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id]) + departureStation Station? @relation("BookingPackageDepartureStation", fields: [packageDepartureStationId], references: [id]) seats BookingSeat[] paymentIntent PaymentIntent? tickets Ticket[] @@ -1479,10 +1483,12 @@ model PackageBooking { paidAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + packageDepartureStationId String? package TravelPackage @relation(fields: [packageId], references: [id]) priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id]) passenger Passenger? @relation(fields: [passengerId], references: [id]) + departureStation Station? @relation("PackageBookingDepartureStation", fields: [packageDepartureStationId], references: [id]) passengers PackageBookingPassenger[] paymentIntent PackagePaymentIntent? diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 65381f519..1e41076e7 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -62,6 +62,8 @@ import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.mod import { HealthModule } from './modules/health/health.module'; import { TasksModule } from './modules/tasks/tasks.module'; import { AppReleasesModule } from './modules/app-releases/app-releases.module'; +import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module'; +import { SegmentFareSeeder } from './seed/segment-fare.seeder'; @Module({ imports: [ @@ -132,6 +134,7 @@ import { AppReleasesModule } from './modules/app-releases/app-releases.module'; HealthModule, TasksModule, AppReleasesModule, + ConfigurableFareModule, ], providers: [ { provide: APP_GUARD, useClass: DynamicThrottlerGuard }, @@ -139,6 +142,7 @@ import { AppReleasesModule } from './modules/app-releases/app-releases.module'; DynamicThrottlerGuard, EdrPassengerOrgSeeder, PassengerStaffUsersSeeder, + SegmentFareSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -147,6 +151,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly seeder: DataSeeder, private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder, private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder, + private readonly segmentFareSeeder: SegmentFareSeeder, ) {} async onApplicationBootstrap() { @@ -165,5 +170,10 @@ export class AppModule implements OnApplicationBootstrap { } catch (err) { this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message); } + try { + await this.segmentFareSeeder.run(); + } catch (err) { + this.logger.error('[SegmentFareSeeder] Seed failed (non-fatal):', (err as Error).message); + } } } 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 1aba4ea97..9ed00249c 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -341,8 +341,9 @@ export class BookingsService { totalMinor: b.totalMinor, currency: b.currency || 'ETB', displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, contactEmail: b.contactEmail, contactPhone: b.contactPhone, - bookingType: 'PACKAGE', packageId: b.packageId, isPackageBooking: true, - packageName: b.package?.name, packageCode: b.package?.code, + bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId, + isPackageBooking: true, packageName: b.package?.name, packageCode: b.package?.code, + tierLabel: b.priceTier?.label ?? null, returnLegStatus: null, adultCount: b.passengerCount, childCount: 0, createdAt: b.createdAt, passenger: null, passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [], @@ -372,6 +373,8 @@ export class BookingsService { schedule: { include: { originStation: true, destinationStation: true, train: true } }, paymentIntent: true, seats: { include: { seat: true } }, + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true } }, }, }), this.prisma.booking.count({ where }), @@ -415,6 +418,10 @@ export class BookingsService { contactPhone: booking.contactPhone, bookingType: booking.bookingType, packageId: booking.packageId ?? null, + priceTierId: (booking as any).priceTierId ?? null, + packageName: (booking as any).package?.name ?? null, + packageCode: (booking as any).package?.code ?? null, + tierLabel: (booking as any).priceTier?.label ?? null, isPackageBooking: !!booking.packageId, returnLegStatus: (booking as any).returnLegStatus ?? null, adultCount: booking.adultCount, @@ -446,9 +453,11 @@ export class BookingsService { contactPhone: b.contactPhone, bookingType: 'PACKAGE', packageId: b.packageId, + priceTierId: b.priceTierId, isPackageBooking: true, packageName: b.package?.name, packageCode: b.package?.code, + tierLabel: b.priceTier?.label ?? null, returnLegStatus: null, adultCount: b.passengerCount, childCount: 0, @@ -518,20 +527,18 @@ export class BookingsService { displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency); } - // Track which child gets free fare (first child encountered) + // Track per-seat fare. For package bookings children pay 10% of adult fare; + // for regular bookings the first child is free. let freeChildUsed = false; const passengersWithFares = passengersData.map(p => { let fareMinor: number; if (p.category === PassengerCategory.ADULT) { fareMinor = fareCalculation.baseFareMinor; + } else if (dto.packageId) { + fareMinor = Math.round(fareCalculation.baseFareMinor * 0.1); } else { - // Child: first child is free, subsequent children pay full fare - if (!freeChildUsed) { - fareMinor = 0; - freeChildUsed = true; - } else { - fareMinor = fareCalculation.baseFareMinor; - } + if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; } + else fareMinor = fareCalculation.baseFareMinor; } return { ...p, fareMinor }; }); @@ -661,34 +668,27 @@ export class BookingsService { displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); } - // Track which child gets free fare for outbound and return legs + // Track per-seat fare. For package bookings children pay 10% of adult fare; + // for regular bookings the first child is free per leg. let outboundFreeChildUsed = false; let returnFreeChildUsed = false; const passengersWithFares = passengersData.map(p => { let outboundFareMinor: number; let returnFareMinor: number; - + if (p.category === PassengerCategory.ADULT) { outboundFareMinor = outboundFare.baseFareMinor; returnFareMinor = returnFare.baseFareMinor; + } else if (dto.packageId) { + outboundFareMinor = Math.round(outboundFare.baseFareMinor * 0.1); + returnFareMinor = Math.round(returnFare.baseFareMinor * 0.1); } else { - // Child fare for outbound - if (!outboundFreeChildUsed) { - outboundFareMinor = 0; - outboundFreeChildUsed = true; - } else { - outboundFareMinor = outboundFare.baseFareMinor; - } - - // Child fare for return - if (!returnFreeChildUsed) { - returnFareMinor = 0; - returnFreeChildUsed = true; - } else { - returnFareMinor = returnFare.baseFareMinor; - } + if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; } + else outboundFareMinor = outboundFare.baseFareMinor; + if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; } + else returnFareMinor = returnFare.baseFareMinor; } - + return { ...p, outboundFareMinor, returnFareMinor }; }); @@ -1231,16 +1231,20 @@ export class BookingsService { childCount: number, ) { const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } }); - const passengerCount = adultCount + childCount; - const totalBaseFareMinor = tier.priceMinor * passengerCount; + // 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); + const adultFareMinor = tier.priceMinor * adultCount; + const childTotalMinor = childFareMinor * childCount; + const totalBaseFareMinor = adultFareMinor + childTotalMinor; return { baseFareMinor: tier.priceMinor, adultCount, - adultFareMinor: tier.priceMinor * adultCount, + adultFareMinor, childCount, freeChildrenCount: 0, paidChildrenCount: childCount, - childFareMinor: tier.priceMinor * childCount, + childFareMinor: childTotalMinor, totalBaseFareMinor, discountMinor: 0, loyaltyRedemptionMinor: 0, diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index d3d1e206d..c6f27d9c3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -144,6 +144,12 @@ export class CreateGuestBookingDto { @ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' }) @IsOptional() @IsString() deviceId?: string; + + @ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier' }) + @IsOptional() @IsString() packageId?: string; + + @ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' }) + @IsOptional() @IsString() priceTierId?: string; } export class SavedPassengerProfileDto { diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index eb8808b7f..f4139d842 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -144,21 +144,34 @@ export class GuestBookingService { }); } - // Calculate fare - const primaryNationality = passengersData[0]?.nationality; - const baseFareMinor = await this.getBaseFare( - dto.scheduleId, - dto.seatClassId, - segmentRoute, - fullRoute, - primaryNationality, - dto.originStationId, - dto.destinationStationId, - ); + // Calculate fare — package bookings use the fixed tier price, bypassing the fare engine + const isPackageOneway = !!dto.packageId && !!dto.priceTierId; + let baseFareMinor: number; + let paidChildrenCount: number; + let childUnitFare: number; + + if (isPackageOneway) { + const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } }); + baseFareMinor = tier.priceMinor; + paidChildrenCount = childCount; + childUnitFare = Math.round(baseFareMinor * 0.1); + } else { + const primaryNationality = passengersData[0]?.nationality; + baseFareMinor = await this.getBaseFare( + dto.scheduleId, + dto.seatClassId, + segmentRoute, + fullRoute, + primaryNationality, + dto.originStationId, + dto.destinationStationId, + ); + paidChildrenCount = Math.max(0, childCount - 1); + childUnitFare = baseFareMinor; + } const adultFareMinor = baseFareMinor * adultCount; - const paidChildrenCount = Math.max(0, childCount - 1); - const childFareMinor = baseFareMinor * paidChildrenCount; + const childFareMinor = childUnitFare * paidChildrenCount; const totalBaseFareMinor = adultFareMinor + childFareMinor; let discountMinor = 0; @@ -217,6 +230,7 @@ export class GuestBookingService { displayCurrency, displayTotalMinor, bookingType: 'ONE_WAY', + ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), userAgent: dto.deviceId, contactEmail: firstPassenger.email || null, contactPhone: firstPassenger.phone || null, @@ -231,7 +245,7 @@ export class GuestBookingService { passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0), + fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare, displayCurrency, })), }, @@ -258,7 +272,7 @@ export class GuestBookingService { adultCount, adultFareMinor, childCount, - freeChildrenCount: Math.min(childCount, 1), + freeChildrenCount: isPackageOneway ? 0 : Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, @@ -366,18 +380,36 @@ export class GuestBookingService { passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); } - // Calculate fares for both legs + // Calculate fares for both legs — package bookings use the fixed tier price split across legs const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId; - const primaryNationality = passengersData[0]?.nationality; + const isPackageRoundTrip = !!dto.packageId && !!dto.priceTierId; + let outboundBaseFare: number; + let returnBaseFare: number; + let paidChildrenCount: number; + let outboundChildUnitFare: number; + let returnChildUnitFare: number; - const [outboundBaseFare, returnBaseFare] = await Promise.all([ - this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId), - this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId), - ]); - - const paidChildrenCount = Math.max(0, childCount - 1); - const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount; - const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount; + if (isPackageRoundTrip) { + const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } }); + // tier.priceMinor is the full round-trip price per adult; split evenly across legs + const halfMinor = Math.round(tier.priceMinor / 2); + outboundBaseFare = halfMinor; + returnBaseFare = tier.priceMinor - halfMinor; + paidChildrenCount = childCount; + outboundChildUnitFare = Math.round(outboundBaseFare * 0.1); + returnChildUnitFare = Math.round(returnBaseFare * 0.1); + } else { + const primaryNationality = passengersData[0]?.nationality; + [outboundBaseFare, returnBaseFare] = await Promise.all([ + this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId), + this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId), + ]); + paidChildrenCount = Math.max(0, childCount - 1); + outboundChildUnitFare = outboundBaseFare; + returnChildUnitFare = returnBaseFare; + } + const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount; + const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount; const combinedBaseFareMinor = outboundTotalBase + returnTotalBase; let discountMinor = 0; @@ -423,6 +455,7 @@ export class GuestBookingService { returnHoldId: dto.returnHoldId, returnSeatClassId, returnLegStatus: 'NEITHER_USED', + ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), userAgent: dto.deviceId, contactEmail: passengersData[0]?.email || null, contactPhone: passengersData[0]?.phone || null, @@ -440,7 +473,7 @@ export class GuestBookingService { passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0), + fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare, displayCurrency, })), ...passengersData.map((p) => ({ @@ -455,7 +488,7 @@ export class GuestBookingService { passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0), + fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare, displayCurrency, })), ], @@ -484,7 +517,7 @@ export class GuestBookingService { returnBaseFareMinor: returnBaseFare, adultCount, childCount, - freeChildrenCount: Math.min(childCount, 1), + freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1), paidChildrenCount, combinedBaseFareMinor, discountMinor, diff --git a/apps/edr-passenger-api/src/modules/configurable-fare/configurable-fare.service.ts b/apps/edr-passenger-api/src/modules/configurable-fare/configurable-fare.service.ts index 34b2be08f..06107c30e 100644 --- a/apps/edr-passenger-api/src/modules/configurable-fare/configurable-fare.service.ts +++ b/apps/edr-passenger-api/src/modules/configurable-fare/configurable-fare.service.ts @@ -329,7 +329,7 @@ export class ConfigurableFareService { ); if (childRule) { - const freeChildren = Math.min(childCount, childRule.max_free_passengers); + const freeChildren = Math.min(childCount, adultCount); const paidChildren = Math.max(0, childCount - freeChildren); if (freeChildren > 0) { diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index f7f2c4558..70f18564d 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -131,7 +131,7 @@ export class FareEngineService { const adultCount = dto.adultCount ?? 1; const childCount = dto.childCount ?? 0; const freeChildrenCount = Math.min(childCount, adultCount); - const paidChildrenCount = Math.max(0, childCount - 1); + const paidChildrenCount = Math.max(0, childCount - freeChildrenCount); // Subtotal includes: (distance-based fare + premium + insurance) × passengers // First child is free, but pays premium and insurance 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 f814eb3e7..de61f92ca 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -91,6 +91,15 @@ export class PackagesController { return this.service.getBookingByRef(ref); } + @Post('book') + @IsPublic() + @UseGuards(OptionalJwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Book a package (public or authenticated)' }) + book(@Body() dto: BookPackageDto, @Request() req: any) { + return this.service.book(dto, req.user?.passengerId); + } + @Get(':id/booking-context') @IsPublic() @ApiOperation({ summary: 'Get booking context for self-service package booking' }) @@ -177,12 +186,4 @@ export class PackagesController { return this.service.deleteTier(tierId); } - @Post('book') - @IsPublic() - @UseGuards(OptionalJwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Book a package (public or authenticated)' }) - book(@Body() dto: BookPackageDto, @Request() req: any) { - return this.service.book(dto, req.user?.passengerId); - } } diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index 074d7454d..0b7a25bba 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -118,4 +118,10 @@ export class BookPackageDto { @ApiProperty({ type: [BookPackagePassengerDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => BookPackagePassengerDto) passengers: BookPackagePassengerDto[]; + + /** Number of adult passengers (≥5 years). Derived from passengers array if omitted. */ + @ApiPropertyOptional({ example: 2 }) @IsOptional() @IsInt() @Min(1) adultCount?: number; + + /** Number of child passengers (<5 years). Derived from passengers array if omitted. */ + @ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number; } 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 4a9f9382e..3b14af6dd 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -6,6 +6,32 @@ import { Currency } from '@prisma/client'; import { BookingsService } from '../bookings/bookings.service'; import { GuestBookingService } from '../bookings/guest-booking.service'; +/** Package-specific fare rules */ +const PKG_MAX_ADULTS = 5; +const PKG_MAX_CHILDREN = 2; +const PKG_CHILD_FARE_RATIO = 0.1; + +function calculatePackageFareBreakdown( + priceMinor: number, + isRoundTrip: boolean, + adultCount: number, + childCount: number, +) { + const multiplier = isRoundTrip ? 2 : 1; + const adultFareMinor = priceMinor * multiplier; + const childFareMinor = Math.round(adultFareMinor * PKG_CHILD_FARE_RATIO); + const totalMinor = adultCount * adultFareMinor + childCount * childFareMinor; + return { adultFareMinor, childFareMinor, totalMinor, multiplier }; +} + +function deriveAge(dateOfBirth: string | Date): number { + const today = new Date(); + const dob = new Date(dateOfBirth); + let age = today.getFullYear() - dob.getFullYear(); + if (today < new Date(today.getFullYear(), dob.getMonth(), dob.getDate())) age--; + return age; +} + function generateRef(): string { return 'PKG-' + Array.from({ length: 6 }, () => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)], @@ -37,18 +63,22 @@ export class PackagesService { }, }); if (!pkg || pkg.status !== 'ACTIVE') throw new NotFoundException('Package not available'); - - const tier = pkg.priceTiers.find(t => t.id === tierId); + const tier = pkg.priceTiers.find((t) => t.id === tierId); if (!tier) throw new NotFoundException('Price tier not found'); - const passengerCount = adultCount + childCount; - if (passengerCount < 1) throw new BadRequestException('At least one passenger required'); + if (adultCount < 1) throw new BadRequestException('At least one adult passenger required'); + if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`); + if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`); + const passengerCount = adultCount + childCount; const remaining = tier.availableSeats - tier.bookedSeats; if (passengerCount > remaining) throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); - const totalMinor = tier.priceMinor * passengerCount; + const isRoundTrip = !!pkg.returnScheduleId; + const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown( + tier.priceMinor, isRoundTrip, adultCount, childCount, + ); // Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches let seatClassId: string | null = null; @@ -60,7 +90,6 @@ export class PackagesService { ); if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; } } - // Fallback: use the first coach assignment's coachTypeId if no match found if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) { const first = pkg.outboundSchedule.coachAssignments[0]; coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null; @@ -77,14 +106,19 @@ export class PackagesService { adultCount, childCount, passengerCount, - pricePerPassengerMinor: tier.priceMinor, + isRoundTrip, + pricePerAdultMinor: adultFareMinor, + pricePerChildMinor: childFareMinor, + childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, + maxAdults: PKG_MAX_ADULTS, + maxChildren: PKG_MAX_CHILDREN, totalMinor, currency: tier.currency, remainingSeats: remaining, outboundSchedule: { scheduleId: pkg.outboundScheduleId, - originStationId: pkg.originStationId, - destinationStationId: pkg.destinationStationId, + originStationId: pkg.outboundSchedule.originStationId, + destinationStationId: pkg.outboundSchedule.destinationStationId, departureAt: pkg.outboundSchedule.departureAt, arrivalAt: pkg.outboundSchedule.arrivalAt, originStation: pkg.outboundSchedule.originStation, @@ -92,12 +126,12 @@ export class PackagesService { }, returnSchedule: pkg.returnSchedule ? { scheduleId: pkg.returnScheduleId, - originStationId: pkg.destinationStationId, - destinationStationId: pkg.originStationId, + originStationId: pkg.returnSchedule.originStationId, + destinationStationId: pkg.returnSchedule.destinationStationId, departureAt: pkg.returnSchedule.departureAt, arrivalAt: pkg.returnSchedule.arrivalAt, - originStation: pkg.returnSchedule.destinationStation, - destinationStation: pkg.returnSchedule.originStation, + originStation: pkg.returnSchedule.originStation, + destinationStation: pkg.returnSchedule.destinationStation, } : null, includedServices: pkg.includedServices, busTransferIncluded: pkg.busTransferIncluded, @@ -170,7 +204,14 @@ export class PackagesService { where: { id }, include: { priceTiers: true, - outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, + outboundSchedule: { + include: { + originStation: true, + destinationStation: true, + train: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + }, + }, returnSchedule: { include: { originStation: true, destinationStation: true, train: true } }, }, }); @@ -297,13 +338,30 @@ export class PackagesService { const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId); if (!tier) throw new NotFoundException('Price tier not found'); - const passengerCount = dto.passengers.length; + // Derive adult/child counts from the passengers array (dateOfBirth-based) + let adultCount = 0, childCount = 0; + for (const p of dto.passengers) { + if (p.dateOfBirth && deriveAge(p.dateOfBirth) < 5) childCount++; + else adultCount++; + } + // Allow explicit override from mobile app (e.g. when dateOfBirth is not provided per passenger) + if (dto.adultCount !== undefined) adultCount = dto.adultCount; + if (dto.childCount !== undefined) childCount = dto.childCount; + + if (adultCount < 1) throw new BadRequestException('At least one adult passenger required'); + if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`); + if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`); + + const passengerCount = adultCount + childCount; const remaining = tier.availableSeats - tier.bookedSeats; if (passengerCount > remaining) { throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); } - const totalMinor = tier.priceMinor * passengerCount; + const isRoundTrip = !!pkg.returnScheduleId; + const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown( + tier.priceMinor, isRoundTrip, adultCount, childCount, + ); const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB @@ -354,7 +412,21 @@ export class PackagesService { }), ]); - return booking; + return { + ...booking, + fareBreakdown: { + isRoundTrip, + adultCount, + adultFareMinor, + childCount, + childFareMinor, + childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, + }, + }; } getMyBookings(passengerId: string) { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 044768e90..80f3ad13b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -50,8 +50,8 @@ export class CreateScheduleDto { { sequence: 6, plannedArrivalAt: '2026-06-15T20:00:00Z' }, ], }) - @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) - plannedTimes: PlannedStopTimeDto[]; + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) + plannedTimes?: PlannedStopTimeDto[]; } export class UpdateScheduleDto { 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 2d194fd7b..19aa5ed0e 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -153,7 +153,7 @@ export class SchedulesService { }); } - const providedSeqs = new Set(plannedTimes.map(t => t.sequence)); + const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq)); if (missingSeqs.length > 0) { throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`); diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 5c237e6ba..495c394ee 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -61,11 +61,12 @@ export class SeatsService { const resolvedBedPosition = isBedCoach ? this.resolveBedPosition(s.col, s.bedPosition) : s.bedPosition; + const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE'); return { id: s.id, seatNumber: s.seatNumber, label: s.seatNumber, - status: effectiveStatuses.get(s.id) ?? s.status, + status: effectiveStatus, kind: s.kind, row: s.row, col: s.col, @@ -245,11 +246,16 @@ export class SeatsService { holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo); - if (!legsOverlap) continue; - // Check direction conflict const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection); - if (!directionsConflict) continue; + + if (!legsOverlap || !directionsConflict) { + // This hold does not conflict with the requested leg/direction. + // Explicitly mark AVAILABLE so the DB's HELD status (set by the + // opposing-direction hold) does not bleed through via the fallback. + if (!statusMap.has(seatId)) statusMap.set(seatId, 'AVAILABLE'); + continue; + } statusMap.set(seatId, 'HELD'); } @@ -653,7 +659,7 @@ export class SeatsService { const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-')); const totalSeats = seats.length; const unavailable = seats.filter(s => { - const status = effectiveStatuses.get(s.id) ?? s.status; + const status = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE'); return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED'; }).length; @@ -665,8 +671,8 @@ export class SeatsService { seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [], totalSeats, availableSeats: totalSeats - unavailable, - heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'HELD').length, - bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'BOOKED').length, + heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'HELD').length, + bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'BOOKED').length, }; }); } @@ -861,11 +867,21 @@ export class SeatsService { if (expired.length === 0) return; const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]); - // Only reset seats that are still HELD — BOOKED seats have been confirmed and must not be touched. - await this.prisma.seat.updateMany({ - where: { id: { in: expiredSeatIds }, status: 'HELD' }, - data: { status: 'AVAILABLE' }, + + // Only reset seats that have no remaining active holds + const stillHeld = await this.prisma.seatHold.findMany({ + where: { expiresAt: { gte: new Date() }, seatIds: { hasSome: expiredSeatIds } }, + select: { seatIds: true }, }); + const stillHeldIds = new Set(stillHeld.flatMap(h => h.seatIds as string[])); + const toRelease = expiredSeatIds.filter(id => !stillHeldIds.has(id)); + + if (toRelease.length > 0) { + await this.prisma.seat.updateMany({ + where: { id: { in: toRelease }, status: 'HELD' }, + data: { status: 'AVAILABLE' }, + }); + } await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } }); } } diff --git a/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts b/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts deleted file mode 100644 index 7e912a6e2..000000000 --- a/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * SEGMENT-BASED SEAT RESERVATION EXAMPLE - * - * Demonstrates the complete flow for booking Addis Ababa → Dire Dawa - * on the Addis Ababa → Djibouti route with segment-based seat management. - * - * Route: Addis Ababa (seq:1) → Adama (seq:2) → Awash (seq:3) → Dire Dawa (seq:4) → Aysha (seq:5) → Djibouti (seq:6) - * Booking: Addis Ababa → Dire Dawa (segments: 1→2, 2→3, 3→4) - */ - -import { PrismaClient } from '@prisma/client'; - -const prisma = new PrismaClient(); - -async function exampleBookingFlow() { - console.log('=== SEGMENT-BASED BOOKING FLOW ===\n'); - - const scheduleId = 'schedule_add_dji_001'; - const passengerId = 'passenger_kelemu'; - const seatIds = ['seat_coach_a_1a', 'seat_coach_a_1b']; - const originStationId = 'st_ADD'; - const destinationStationId = 'st_DRE'; - - try { - console.log('1. Checking seat availability...'); - const segments = await getJourneySegments(scheduleId, originStationId, destinationStationId); - console.log('Journey segments:', segments.map(s => `${s.fromName} → ${s.toName}`)); - - console.log('\n2. Holding seats...'); - const holdResult = await holdSeatsTransaction(scheduleId, seatIds, passengerId, originStationId, destinationStationId); - console.log('Hold created:', holdResult); - - console.log('\n3. Processing payment...'); - await new Promise(resolve => setTimeout(resolve, 5000)); - - console.log('\n4. Confirming booking...'); - const bookingId = 'booking_' + Date.now(); - const confirmResult = await confirmBookingTransaction(holdResult.holdId, bookingId, segments); - console.log('Booking confirmed:', confirmResult); - - console.log('\n5. Simulating trip progress...'); - await simulateTripProgress(scheduleId, segments); - - } catch (error) { - console.error('Booking flow error:', error); - } -} - -async function getJourneySegments(scheduleId: string, originStationId: string, destinationStationId: string) { - const stopTimes = await prisma.tripStopTime.findMany({ - where: { scheduleId }, - include: { station: true }, - orderBy: { sequence: 'asc' }, - }); - - const originStop = stopTimes.find(st => st.stationId === originStationId); - const destinationStop = stopTimes.find(st => st.stationId === destinationStationId); - - if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) { - throw new Error('Invalid origin/destination'); - } - - const segments = []; - for (let i = originStop.sequence; i < destinationStop.sequence; i++) { - const fromStop = stopTimes.find(st => st.sequence === i); - const toStop = stopTimes.find(st => st.sequence === i + 1); - if (fromStop && toStop) { - segments.push({ - fromStationId: fromStop.stationId, - toStationId: toStop.stationId, - fromSequence: fromStop.sequence, - toSequence: toStop.sequence, - fromName: fromStop.station.name, - toName: toStop.station.name, - }); - } - } - return segments; -} - -async function holdSeatsTransaction(scheduleId: string, seatIds: string[], passengerId: string, originStationId: string, destinationStationId: string) { - return prisma.$transaction(async (tx) => { - console.log(' → Starting seat hold transaction...'); - - const seats = await tx.seat.findMany({ where: { id: { in: seatIds } }, include: { coach: true } }); - if (seats.length !== seatIds.length) throw new Error('Some seats not found'); - - for (const seat of seats) { - if (seat.status !== 'AVAILABLE') { - throw new Error(`Seat ${seat.seatNumber} is not available (status: ${seat.status})`); - } - } - - const expiresAt = new Date(Date.now() + 10 * 60 * 1000); - const seatHold = await tx.seatHold.create({ - data: { scheduleId, seatIds, passengerId, expiresAt }, - }); - - await tx.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } }); - - console.log(' → Seats held successfully'); - return { holdId: seatHold.id, expiresAt, seats: seatIds.length }; - }); -} - -async function confirmBookingTransaction(holdId: string, bookingId: string, segments: any[]) { - return prisma.$transaction(async (tx) => { - console.log(' → Starting booking confirmation transaction...'); - - const hold = await tx.seatHold.findUnique({ where: { id: holdId } }); - if (!hold || hold.expiresAt < new Date()) throw new Error('Hold expired or not found'); - - const booking = await tx.booking.create({ - data: { - id: bookingId, - bookingRef: 'BK' + Date.now().toString().slice(-6), - passengerId: hold.passengerId, - scheduleId: hold.scheduleId, - status: 'CONFIRMED', - totalMinor: 45000, - currency: 'ETB', - }, - }); - - const journey = await tx.journey.create({ - data: { passengerId: hold.passengerId, status: 'CONFIRMED', totalMinor: 45000, currency: 'ETB' }, - }); - - for (const seatId of hold.seatIds) { - for (let i = 0; i < segments.length; i++) { - await tx.journeySegment.create({ - data: { - journeyId: journey.id, - scheduleId: hold.scheduleId, - segmentOrder: i + 1, - seatId, - departureStationId: segments[i].fromStationId, - arrivalStationId: segments[i].toStationId, - }, - }); - } - } - - for (const seatId of hold.seatIds) { - await tx.bookingSeat.create({ data: { bookingId, seatId, passengerName: 'Kelemu Ketsela' } }); - } - - await tx.seat.updateMany({ where: { id: { in: hold.seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); - await tx.seatHold.delete({ where: { id: holdId } }); - - console.log(' → Booking confirmed successfully'); - return { bookingId, bookingRef: booking.bookingRef, confirmedSeats: hold.seatIds.length, segments: segments.length }; - }); -} - -async function simulateTripProgress(scheduleId: string, bookedSegments: any[]) { - console.log(' → Simulating trip progress...'); - - for (const segment of bookedSegments) { - console.log(` → Train approaching ${segment.toName}...`); - - await prisma.tripLiveStatus.upsert({ - where: { scheduleId }, - update: { currentLocationLabel: segment.toName, progressPercent: Math.round((segment.toSequence / 4) * 100) }, - create: { - scheduleId, - state: 'EN_ROUTE', - currentLocationLabel: segment.toName, - progressPercent: Math.round((segment.toSequence / 4) * 100), - delayMinutes: 0, - }, - }); - - if (segment.toName === 'Dire Dawa') { - console.log(' → Passengers reached destination, releasing seats...'); - await releaseSeatsAtStation(scheduleId, segment.toStationId); - } - - await new Promise(resolve => setTimeout(resolve, 2000)); - } -} - -async function releaseSeatsAtStation(scheduleId: string, stationId: string) { - return prisma.$transaction(async (tx) => { - const completedSegments = await tx.journeySegment.findMany({ - where: { scheduleId, arrivalStationId: stationId }, - include: { journey: { include: { journeySegments: { where: { scheduleId } } } } }, - }); - - const seatsToRelease: string[] = []; - - for (const segment of completedSegments) { - const passengerSegments = segment.journey.journeySegments.filter((js: any) => js.seatId === segment.seatId); - const maxOrder = Math.max(...passengerSegments.map((js: any) => js.segmentOrder)); - if (segment.segmentOrder === maxOrder) seatsToRelease.push(segment.seatId!); - } - - if (seatsToRelease.length > 0) { - await tx.seat.updateMany({ where: { id: { in: seatsToRelease } }, data: { status: 'AVAILABLE' } }); - console.log(` → Released ${seatsToRelease.length} seats at station`); - } - - return seatsToRelease; - }); -} - -async function checkOverlappingReservations(tx: any, scheduleId: string, seatId: string, segments: any[]) { - const activeHolds = await tx.seatHold.findMany({ - where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } }, - }); - - const activeBookings = await tx.journeySegment.findMany({ - where: { - scheduleId, - seatId, - journey: { status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } }, - }, - }); - - return [...activeHolds, ...activeBookings]; -} - -if (require.main === module) { - exampleBookingFlow() - .then(() => console.log('\n=== EXAMPLES COMPLETED ===')) - .catch(console.error) - .finally(() => prisma.$disconnect()); -} - -export { - exampleBookingFlow, - getJourneySegments, - holdSeatsTransaction, - confirmBookingTransaction, - simulateTripProgress, - releaseSeatsAtStation, - checkOverlappingReservations, -}; diff --git a/apps/edr-passenger-api/src/modules/segments/segment-fare.controller.ts b/apps/edr-passenger-api/src/modules/segments/segment-fare.controller.ts new file mode 100644 index 000000000..3cd7c5f06 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/segments/segment-fare.controller.ts @@ -0,0 +1,49 @@ +import { + Controller, Get, Post, Put, Delete, + Param, Body, Query, UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { IamGuard } from '../../common/iam-adapter'; +import { Roles } from '../../common/roles.decorator'; +import { SegmentFareService } from './segment-fare.service'; +import { CreateSegmentFareDto, UpdateSegmentFareDto } from './segment-fare.dto'; + +@ApiTags('Admin – Segment Fares') +@Controller('admin/segment-fares') +@ApiBearerAuth('IAM-auth') +@UseGuards(IamGuard) +@Roles('ADMIN', 'SUPERVISOR') +export class SegmentFareController { + constructor(private readonly service: SegmentFareService) {} + + @Get() + @ApiOperation({ summary: 'List all segment fare rules, optionally filtered by route' }) + @ApiQuery({ name: 'routeId', required: false }) + findAll(@Query('routeId') routeId?: string) { + return this.service.findAll(routeId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single segment fare rule' }) + findOne(@Param('id') id: string) { + return this.service.findOne(id); + } + + @Post() + @ApiOperation({ summary: 'Create a segment fare rule' }) + create(@Body() dto: CreateSegmentFareDto) { + return this.service.create(dto); + } + + @Put(':id') + @ApiOperation({ summary: 'Update a segment fare rule' }) + update(@Param('id') id: string, @Body() dto: UpdateSegmentFareDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a segment fare rule' }) + remove(@Param('id') id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-passenger-api/src/modules/segments/segment-fare.dto.ts b/apps/edr-passenger-api/src/modules/segments/segment-fare.dto.ts new file mode 100644 index 000000000..2f56f4fe1 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/segments/segment-fare.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, IsInt, IsOptional, IsDateString, IsIn, Min, +} from 'class-validator'; + +export class CreateSegmentFareDto { + @ApiProperty({ example: 'route-uuid' }) + @IsString() + routeId: string; + + @ApiProperty({ example: 1 }) + @IsInt() @Min(0) + originStopSequence: number; + + @ApiProperty({ example: 5 }) + @IsInt() @Min(1) + destinationStopSequence: number; + + @ApiProperty({ example: 'seat-class-uuid' }) + @IsString() + seatClassId: string; + + @ApiProperty({ example: 35000, description: 'Base fare in minor units (e.g. 350.00 ETB = 35000)' }) + @IsInt() @Min(0) + baseFareMinor: number; + + @ApiPropertyOptional({ example: 'LOCAL', enum: ['LOCAL', 'INTERNATIONAL'] }) + @IsOptional() + @IsIn(['LOCAL', 'INTERNATIONAL']) + nationality?: string; + + @ApiPropertyOptional({ example: 'ETB', default: 'ETB' }) + @IsOptional() + @IsString() + currency?: string; + + @ApiProperty({ example: '2025-01-01T00:00:00.000Z' }) + @IsDateString() + validFrom: string; + + @ApiPropertyOptional({ example: '2026-12-31T23:59:59.000Z' }) + @IsOptional() + @IsDateString() + validUntil?: string; +} + +export class UpdateSegmentFareDto { + @ApiPropertyOptional({ example: 40000 }) + @IsOptional() + @IsInt() @Min(0) + baseFareMinor?: number; + + @ApiPropertyOptional({ example: 'ETB' }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ example: '2025-06-01T00:00:00.000Z' }) + @IsOptional() + @IsDateString() + validFrom?: string; + + @ApiPropertyOptional({ example: '2026-12-31T23:59:59.000Z' }) + @IsOptional() + @IsDateString() + validUntil?: string; +} diff --git a/apps/edr-passenger-api/src/modules/segments/segment-fare.service.ts b/apps/edr-passenger-api/src/modules/segments/segment-fare.service.ts new file mode 100644 index 000000000..982ee7dd6 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/segments/segment-fare.service.ts @@ -0,0 +1,62 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { CreateSegmentFareDto, UpdateSegmentFareDto } from './segment-fare.dto'; + +@Injectable() +export class SegmentFareService { + constructor(private readonly prisma: PrismaService) {} + + findAll(routeId?: string) { + return this.prisma.segmentFareRule.findMany({ + where: routeId ? { routeId } : undefined, + include: { seatClass: true, route: { select: { id: true, code: true, name: true } } }, + orderBy: [{ routeId: 'asc' }, { originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }], + }); + } + + async findOne(id: string) { + const rule = await this.prisma.segmentFareRule.findUnique({ + where: { id }, + include: { seatClass: true, route: { select: { id: true, code: true, name: true } } }, + }); + if (!rule) throw new NotFoundException(`SegmentFareRule ${id} not found`); + return rule; + } + + create(dto: CreateSegmentFareDto) { + return this.prisma.segmentFareRule.create({ + data: { + routeId: dto.routeId, + originStopSequence: dto.originStopSequence, + destinationStopSequence: dto.destinationStopSequence, + seatClassId: dto.seatClassId, + baseFareMinor: dto.baseFareMinor, + nationality: dto.nationality ?? null, + currency: dto.currency ?? 'ETB', + validFrom: new Date(dto.validFrom), + validUntil: dto.validUntil ? new Date(dto.validUntil) : null, + }, + include: { seatClass: true }, + }); + } + + async update(id: string, dto: UpdateSegmentFareDto) { + await this.findOne(id); + return this.prisma.segmentFareRule.update({ + where: { id }, + data: { + ...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }), + ...(dto.currency !== undefined && { currency: dto.currency }), + ...(dto.validFrom !== undefined && { validFrom: new Date(dto.validFrom) }), + ...(dto.validUntil !== undefined && { validUntil: new Date(dto.validUntil) }), + }, + include: { seatClass: true }, + }); + } + + async remove(id: string) { + await this.findOne(id); + await this.prisma.segmentFareRule.delete({ where: { id } }); + return { deleted: true }; + } +} diff --git a/apps/edr-passenger-api/src/modules/segments/segments.module.ts b/apps/edr-passenger-api/src/modules/segments/segments.module.ts index f82da7fa9..053d1d33f 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.module.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.module.ts @@ -3,20 +3,24 @@ import { SegmentsService } from './segments.service'; import { EnhancedSeatsService } from './enhanced-seats.service'; import { TripProgressService } from './trip-progress.service'; import { SegmentSeatsController } from './segments.controller'; +import { SegmentFareController } from './segment-fare.controller'; +import { SegmentFareService } from './segment-fare.service'; import { PrismaService } from '../../common/prisma.service'; @Module({ - controllers: [SegmentSeatsController], + controllers: [SegmentSeatsController, SegmentFareController], providers: [ SegmentsService, EnhancedSeatsService, TripProgressService, - PrismaService + SegmentFareService, + PrismaService, ], exports: [ SegmentsService, EnhancedSeatsService, - TripProgressService - ] + TripProgressService, + SegmentFareService, + ], }) export class SegmentsModule {} \ No newline at end of file diff --git a/apps/edr-passenger-api/src/seed/segment-fare.seeder.ts b/apps/edr-passenger-api/src/seed/segment-fare.seeder.ts new file mode 100644 index 000000000..4ae06b0a1 --- /dev/null +++ b/apps/edr-passenger-api/src/seed/segment-fare.seeder.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../common/prisma.service'; + +const SEED_FLAG = 'SEED_SEGMENT_FARES'; + +/** + * Seeds SegmentFareRule rows for every origin→destination pair on the + * Addis Ababa–Djibouti route across all active seat classes. + * + * Skip-if-loaded: uses Prisma upsert on the unique constraint + * (routeId, originStopSequence, destinationStopSequence, seatClassId, nationality). + * Re-running is safe — existing rows are updated in-place. + */ +@Injectable() +export class SegmentFareSeeder { + private readonly logger = new Logger(SegmentFareSeeder.name); + + constructor(private readonly prisma: PrismaService) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping segment fare seed — set ${SEED_FLAG}=true to enable`); + return; + } + + const route = await this.prisma.route.findFirst({ + where: { active: true }, + include: { stops: { orderBy: { sequence: 'asc' } } }, + }); + + if (!route) { + this.logger.warn('No active route found — skipping segment fare seed'); + return; + } + + const seatClasses = await this.prisma.seatClass.findMany({ + where: { isActive: true }, + }); + + if (!seatClasses.length) { + this.logger.warn('No active seat classes found — skipping segment fare seed'); + return; + } + + const stops = route.stops; + const validFrom = new Date('2025-01-01T00:00:00.000Z'); + + // Rate table: ETB minor units per km, keyed by (nationalityType, bedPosition) + // null bedPosition = regular seat + const rateTable: Record> = { + LOCAL: { null: 3000, UPPER: 4000, MIDDLE: 5500, LOWER: 6000 }, + INTERNATIONAL: { null: 6000, UPPER: 8000, MIDDLE: 11000, LOWER: 12000 }, + }; + + let upserted = 0; + + for (const seatClass of seatClasses) { + const natType = seatClass.nationalityType ?? 'LOCAL'; + const bedPos = seatClass.bedPosition ?? 'null'; + const ratePerKm = rateTable[natType]?.[bedPos] ?? rateTable['LOCAL']['null']; + + for (let i = 0; i < stops.length - 1; i++) { + for (let j = i + 1; j < stops.length; j++) { + const origin = stops[i]; + const dest = stops[j]; + + // Approximate distance: sum of per-stop distanceKm if available, + // otherwise fall back to sequence-gap × 50 km. + let distanceKm = 0; + for (let k = i; k < j; k++) { + distanceKm += stops[k + 1].distanceKm ?? 50; + } + + const baseFareMinor = Math.round(distanceKm * ratePerKm); + + await this.prisma.segmentFareRule.upsert({ + where: { + routeId_originStopSequence_destinationStopSequence_seatClassId_nationality: { + routeId: route.id, + originStopSequence: origin.sequence, + destinationStopSequence: dest.sequence, + seatClassId: seatClass.id, + nationality: natType, + }, + }, + update: { baseFareMinor, validFrom }, + create: { + routeId: route.id, + originStopSequence: origin.sequence, + destinationStopSequence: dest.sequence, + seatClassId: seatClass.id, + nationality: natType, + baseFareMinor, + currency: 'ETB', + validFrom, + }, + }); + + upserted++; + } + } + } + + this.logger.log( + `Segment fare seed complete — ${upserted} rules upserted ` + + `(${stops.length} stops × ${seatClasses.length} seat classes)`, + ); + } +} diff --git a/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx index 2e036f31e..c5d5d7fa9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx @@ -165,7 +165,7 @@ export default function AppReleasesPage() {
setFormOpen(false)}>Cancel - + {editing ? 'Save Changes' : 'Create Release'}
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 61a38ddae..974c25470 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -13,7 +13,7 @@ import { usePermission } from '@/lib/use-permission'; import { PERMS } from '@/lib/permissions'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { bookingsApi, apiClient } from '@/lib/api'; -import { formatCurrency, formatDateTime } from '@/lib/utils'; +import { formatCurrency, formatDateTime, formatDateTimeShort } from '@/lib/utils'; import { BookingFilters } from '@/types'; const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( @@ -150,10 +150,39 @@ function BookingsPageContent() { )} -
{booking.bookingType || 'ONE_WAY'}
+ {booking.isPackageBooking + ?
Boarding at: {booking.departureStationName || booking.schedule?.originStation?.name || '—'}
+ :
{booking.bookingType || 'ONE_WAY'}
+ } ), }, + { + key: 'trip', + label: 'Trip', + render: (booking: any) => { + const isRoundTrip = booking?.bookingType === 'ROUND_TRIP' || booking?.bookingType === 'ROUND_TRIP_TRANSIT'; + const returnDeparture = booking?.returnSchedule?.departureAt; + console.log(JSON.stringify(booking.packageId)); + return ( +
+
+ {booking.schedule?.originStation?.name || 'N/A'} → {booking.schedule?.destinationStation?.name || 'N/A'} +
+
+ {!isRoundTrip ? ( + {booking.schedule?.departureAt ? formatDateTimeShort(booking.schedule.departureAt) : 'N/A'} + ) : ( + + {booking.schedule?.departureAt ? formatDateTimeShort(booking.schedule.departureAt) : 'N/A'} · + {returnDeparture ? formatDateTimeShort(returnDeparture) : ''} + + )} +
+
+ ); + }, + }, { key: 'passengerNames', label: 'Names', render: (booking: any) => { @@ -200,14 +229,6 @@ function BookingsPageContent() { ), }, - { - key: 'passengerCount', label: 'Passengers', - render: (booking: any) => { - const adults = booking.adultCount || 0, children = booking.childCount || 0; - if (!adults && !children) return '—'; - return <>
Adult: {adults}
Child: {children}
; - }, - }, { key: 'paymentStatus', label: 'Payment', render: (booking: any) => ( 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 243dc49c0..65671959b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -289,7 +289,7 @@ export default function ClassesPage() {

Pricing Configuration

- + {r.nationality || 'All'}, }, { - key: 'baseFareMinor', label: 'Base Fare (ETB)', + key: 'baseFareMinor', label: 'Base Fare', render: (r: any) => {(r.baseFareMinor / 100).toFixed(2)}, }, { @@ -214,7 +214,7 @@ export default function FareManagementPage() {
- + diff --git a/apps/edr-passenger-web/backoffice/src/app/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/layout.tsx index 23838f62c..b302ee74d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/layout.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { headers } from 'next/headers'; import '@/styles/globals.css'; import Providers from './providers'; @@ -12,10 +13,13 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { + // Nonce set per-request by middleware; required for this inline script under the CSP. + const nonce = headers().get('x-nonce') ?? undefined; return ( - - diff --git a/apps/edr-passenger-web/backoffice/test-stations-crud.js b/apps/edr-passenger-web/backoffice/test-stations-crud.js deleted file mode 100644 index 7964cc813..000000000 --- a/apps/edr-passenger-web/backoffice/test-stations-crud.js +++ /dev/null @@ -1,132 +0,0 @@ -// Test script for Stations CRUD operations -// Run this in the browser console on the backoffice app - -async function testStationsCRUD() { - const API_URL = 'http://localhost:4000'; - const token = localStorage.getItem('auth_token'); - - const headers = { - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '' - }; - - console.log('🧪 Testing Stations CRUD Operations...\n'); - - try { - // 1. CREATE - Add a new station - console.log('1️⃣ Testing CREATE Station...'); - const newStation = { - code: 'TEST', - name: 'Test Station', - city: 'Test City', - countryCode: 'ET', - lat: '9.0320', - lng: '38.7469', - timezone: 'Africa/Addis_Ababa', - isOperational: true - }; - - const createResponse = await fetch(`${API_URL}/stations`, { - method: 'POST', - headers, - body: JSON.stringify(newStation) - }); - - if (!createResponse.ok) { - throw new Error(`CREATE failed: ${createResponse.status} ${await createResponse.text()}`); - } - - const createdStation = await createResponse.json(); - console.log('✅ Station created:', createdStation); - const stationId = createdStation.id || createdStation.data?.id; - - if (!stationId) { - throw new Error('No station ID returned from create'); - } - - // 2. READ - Get the created station - console.log('\n2️⃣ Testing READ Station...'); - const readResponse = await fetch(`${API_URL}/stations/${stationId}`, { - method: 'GET', - headers - }); - - if (!readResponse.ok) { - throw new Error(`READ failed: ${readResponse.status}`); - } - - const readStation = await readResponse.json(); - console.log('✅ Station retrieved:', readStation); - - // 3. UPDATE - Modify the station - console.log('\n3️⃣ Testing UPDATE Station...'); - const updateData = { - name: 'Test Station Updated', - city: 'Test City Updated', - isOperational: false - }; - - const updateResponse = await fetch(`${API_URL}/stations/${stationId}`, { - method: 'PATCH', - headers, - body: JSON.stringify(updateData) - }); - - if (!updateResponse.ok) { - throw new Error(`UPDATE failed: ${updateResponse.status} ${await updateResponse.text()}`); - } - - const updatedStation = await updateResponse.json(); - console.log('✅ Station updated:', updatedStation); - - // 4. LIST - Get all stations - console.log('\n4️⃣ Testing LIST Stations...'); - const listResponse = await fetch(`${API_URL}/stations`, { - method: 'GET', - headers - }); - - if (!listResponse.ok) { - throw new Error(`LIST failed: ${listResponse.status}`); - } - - const stations = await listResponse.json(); - console.log('✅ Stations list retrieved:', stations); - - // 5. DELETE - Remove the test station - console.log('\n5️⃣ Testing DELETE Station...'); - const deleteResponse = await fetch(`${API_URL}/stations/${stationId}`, { - method: 'DELETE', - headers - }); - - if (!deleteResponse.ok) { - throw new Error(`DELETE failed: ${deleteResponse.status} ${await deleteResponse.text()}`); - } - - console.log('✅ Station deleted successfully'); - - // 6. Verify deletion - console.log('\n6️⃣ Verifying deletion...'); - const verifyResponse = await fetch(`${API_URL}/stations/${stationId}`, { - method: 'GET', - headers - }); - - if (verifyResponse.status === 404) { - console.log('✅ Station deletion verified (404 Not Found)'); - } else { - console.warn('⚠️ Station might still exist'); - } - - console.log('\n🎉 All tests passed!'); - return { success: true, message: 'All CRUD operations working correctly' }; - - } catch (error) { - console.error('❌ Test failed:', error); - return { success: false, error: error.message }; - } -} - -// Run the test -testStationsCRUD(); 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 fca170a2c..e84837af1 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 @@ -37,7 +37,6 @@ export default function ConfirmationPage() { try { return await apiClient.get(`/bookings/${bookingId}`); } catch (error) { - console.log('Booking API not available, using local data'); return { id: bookingId || '', pnr: pnr || undefined, @@ -57,13 +56,9 @@ export default function ConfirmationPage() { // For other payment methods, ticket is generated by the payment webhook after payment completes apiClient.get(`/bookings/${bookingId}`).then((data: any) => { if (data?.status === 'CONFIRMED') { - apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => { - console.error('Failed to generate ticket:', err); - }); + apiClient.post(`/tickets/generate/${bookingId}`).catch(() => {}); } - }).catch((err) => { - console.error('Failed to fetch booking status:', err); - }); + }).catch(() => {}); } }, [bookingId]); @@ -142,7 +137,6 @@ export default function ConfirmationPage() { if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400)); } } catch (error) { - console.error('❌ Failed to generate voucher:', error); alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsGeneratingVoucher(false); @@ -159,12 +153,12 @@ export default function ConfirmationPage() { const handleNewBooking = () => { clearBooking(); - router.push('/booking/search'); + window.location.href = '/'; }; useEffect(() => { if (!bookingId || !pnr) { - router.push('/booking/search'); + window.location.href = '/'; } }, [bookingId, pnr, router]); @@ -376,19 +370,19 @@ export default function ConfirmationPage() {

{passenger.nationality}

-

Seat

+

Seat(s)

{isRoundTrip ? (

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

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

) : (

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

)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index f01ab89c6..5087b7e4d 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -34,9 +34,7 @@ function BookingDetailContent() { queryKey: ['booking-detail', bookingRef], queryFn: async () => { if (!bookingRef) throw new Error('No booking reference provided'); - console.log('🔍 Fetching Booking:', bookingRef); const response = await apiClient.get(`/bookings/${bookingRef}`); - console.log('✅ Booking Response:', response); // Handle wrapped response return (response as any)?.data || response; }, @@ -56,7 +54,6 @@ function BookingDetailContent() { return response; }, onSuccess: async (data: any) => { - console.log('Payment intent created:', data); await apiClient.patch(`/bookings/${booking?.id}/confirm`, { paymentIntentId: data.id, paymentMethod: selectedPaymentMethod, @@ -64,7 +61,6 @@ function BookingDetailContent() { refetch(); }, onError: (error: any) => { - console.error('Payment failed:', error); alert(error?.response?.data?.message || 'Payment failed. Please try again.'); }, }); @@ -99,11 +95,9 @@ function BookingDetailContent() { setIsGeneratingVoucher(true); try { - console.log('📄 Generating voucher for booking:', booking); const { generateVoucherPDF } = await import('@/lib/generate-voucher'); await generateVoucherPDF(booking as any); } catch (error) { - console.error('Failed to generate voucher:', error); alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsGeneratingVoucher(false); @@ -148,11 +142,6 @@ function BookingDetailContent() { const isExpired = booking.status === 'EXPIRED'; const isCancelled = booking.status === 'CANCELLED'; - console.log('📊 Booking Status:', booking.status); - console.log('📊 isPendingPayment:', isPendingPayment); - console.log('📊 isConfirmed:', isConfirmed); - console.log('📊 isExpired:', isExpired); - console.log('📊 isCancelled:', isCancelled); const StatusBadge = () => { const statusConfig = { diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index e2ca159b5..c6122dda4 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -761,7 +761,6 @@ export default function PassengersPage() { // Remove code/state from the URL so a refresh doesn't re-trigger router.replace('/booking/passengers'); } catch (error) { - console.error('Failed to complete Fayda verification:', error); setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' })); setFaydaErrors((prev) => ({ ...prev, [targetIndex]: 'Fayda verification failed. Please try again or enter details manually.' })); } finally { @@ -793,7 +792,6 @@ export default function PassengersPage() { try { // Fetch passenger profile from backend const passengerData: any = await apiClient.get(`/passengers/me`); - console.log('Fetched passenger data:', passengerData); if (!passengerData) { setFormInitialized(true); @@ -801,7 +799,6 @@ export default function PassengersPage() { } // Only populate first passenger - console.log('Setting passenger 0 values'); setValue('passengers.0.name', passengerData?.fullName || user.fullName || ''); setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || ''); if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any); @@ -818,7 +815,6 @@ export default function PassengersPage() { setFormInitialized(true); } catch (error) { - console.error('Failed to fetch passenger data:', error); setFormInitialized(true); } }; @@ -904,7 +900,6 @@ export default function PassengersPage() { setFaydaErrors((prev) => ({ ...prev, [index]: 'Fayda verification was not completed. Please try again or enter details manually.' })); } } catch (error) { - console.error('Failed to get verification status:', error); setVerificationStatus((prev) => ({ ...prev, [index]: 'error' })); setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to confirm verification status. Please try again or enter details manually.' })); } finally { @@ -914,7 +909,6 @@ export default function PassengersPage() { } }, 1000); } catch (error) { - console.error('Failed to start Fayda verification:', error); setVerificationStatus((prev) => ({ ...prev, [index]: 'error' })); setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to start verification. Please try again.' })); setVerifyingIndex(null); @@ -943,9 +937,7 @@ export default function PassengersPage() { try { const passengerProfile: any = await apiClient.get('/passengers/me'); passengerId = passengerProfile?.id || ''; - console.log('Fetched passengerId:', passengerId); } catch (error) { - console.error('Failed to fetch passenger profile:', error); } } @@ -983,12 +975,10 @@ export default function PassengersPage() { if (isAuthenticated && passengerId) { const { setPassengerId } = useBookingStore.getState(); setPassengerId(passengerId); - console.log('Saved passengerId to booking store:', passengerId); } router.push('/booking/seats'); } catch (error) { - console.error('Failed to save passenger details:', error); alert('Failed to save passenger details. Please try again.'); } finally { setSaving(false); @@ -997,7 +987,7 @@ export default function PassengersPage() { useEffect(() => { if (!searchCriteria) { - router.push('/booking/search'); + window.location.href = '/'; } }, [searchCriteria, router]); 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 ecba48945..c2c32a8cf 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 @@ -28,7 +28,7 @@ const getIconForMethod = (methodId: string) => { export default function PaymentPage() { const router = useRouter(); - const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName } = useBookingStore(); + const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, packageTierPriceMinor } = useBookingStore(); const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null); @@ -36,6 +36,10 @@ export default function PaymentPage() { const [paymentError, setPaymentError] = useState(null); 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; @@ -59,29 +63,34 @@ export default function PaymentPage() { queryKey: ['bookingAmount', bookingId, amountCurrency, selectedMethod], queryFn: async () => { const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`; - console.log('[BookingAmount] Request:', { url, bookingId, currency: amountCurrency, selectedMethod }); const response: any = await apiClient.get(url); - console.log('[BookingAmount] Response:', response); return response; }, enabled: !!selectedMethod && !!bookingId, }); - // Fallback: estimate from local store while API hasn't responded yet. - // Uses the same first-child-free calculation as the review page so the - // breakdown shown here matches what the passenger already saw there. - const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => { - return sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0); - }, 0) : 0; + // 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). + const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; + const childCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; + const pkgPerLegAdultFare = isPackage ? packageTierPriceMinor! : 0; + const pkgPerLegChildFare = isPackage ? Math.round(pkgPerLegAdultFare * 0.1) : 0; + const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + childCount * pkgPerLegChildFare : 0; - const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => { - return sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0); - }, 0) : 0; + const outboundBaseFare = isPackage + ? pkgPerLegTotal + : (isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0), 0) : 0); - const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => { - const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0; - return sum + calculatePassengerFare(passengers, i, farePerPassenger); - }, 0); + const inboundBaseFare = isPackage + ? pkgPerLegTotal + : (isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0), 0) : 0); + + const baseFare = isPackage + ? adultCount * pkgAdultFare + childCount * pkgChildFare + : isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => { + const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0; + return sum + calculatePassengerFare(passengers, i, farePerPassenger); + }, 0); // API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency const totalAmount = bookingAmountData != null @@ -115,7 +124,6 @@ export default function PaymentPage() { router.push("/booking/confirmation"); }, onError: (error: any) => { - console.error("Payment failed:", error); updateStatus("FAILED"); setPaymentError( error?.response?.data?.message || @@ -160,10 +168,6 @@ export default function PaymentPage() { // Add a small delay to allow state to be set from previous page const timer = setTimeout(() => { if (!bookingId || !pnr) { - console.log( - "Payment page: Missing booking data, redirecting to search", - ); - console.log("bookingId:", bookingId, "pnr:", pnr); router.push("/booking/search"); } }, 500); @@ -266,17 +270,22 @@ export default function PaymentPage() {

Fare breakdown

{passengers.map((p, i) => { - const outFare = outboundSchedule?.baseFareAdult || 0; - const inFare = inboundSchedule?.baseFareAdult || 0; - const onewayFare = selectedSchedule?.baseFareAdult || 0; - - const outboundFare = calculatePassengerFare(passengers, i, outFare); - const inboundFare = calculatePassengerFare(passengers, i, inFare); - const oneWayFare = calculatePassengerFare(passengers, i, onewayFare); - - const passengerTotal = isRoundTrip ? outboundFare + inboundFare : oneWayFare; const isChildPassenger = isChild(p); - const isFreeChild = isChildPassenger && isFirstChild(passengers, i); + + let passengerTotal: number; + let isFreeChild = false; + if (isPackage) { + passengerTotal = isChildPassenger ? pkgChildFare : pkgAdultFare; + } else { + const outFare = outboundSchedule?.baseFareAdult || 0; + const inFare = inboundSchedule?.baseFareAdult || 0; + const onewayFare = selectedSchedule?.baseFareAdult || 0; + const outboundFare = calculatePassengerFare(passengers, i, outFare); + const inboundFare = calculatePassengerFare(passengers, i, inFare); + const oneWayFare = calculatePassengerFare(passengers, i, onewayFare); + passengerTotal = isRoundTrip ? outboundFare + inboundFare : oneWayFare; + isFreeChild = isChildPassenger && isFirstChild(passengers, i); + } return (
@@ -285,9 +294,9 @@ export default function PaymentPage() { {p.name || `Passenger ${i + 1}`} {isChildPassenger && ( - ({isFreeChild ? 'CHILD - FREE' : 'CHILD'}) + ({isPackage ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'}) )} @@ -298,12 +307,22 @@ export default function PaymentPage() { {isRoundTrip && (
- Outbound {isFreeChild ? '(Free)' : ''} - {formatFare(outboundFare, displayCurrency)} + Outbound {!isPackage && isFreeChild ? '(Free)' : ''} + {formatFare( + isPackage + ? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare) + : calculatePassengerFare(passengers, i, outboundSchedule?.baseFareAdult || 0), + displayCurrency + )}
- Return {isFreeChild ? '(Free)' : ''} - {formatFare(inboundFare, displayCurrency)} + Return {!isPackage && isFreeChild ? '(Free)' : ''} + {formatFare( + isPackage + ? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare) + : calculatePassengerFare(passengers, i, inboundSchedule?.baseFareAdult || 0), + displayCurrency + )}
)} diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 70ada8f7c..6a54fe010 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -107,11 +107,9 @@ export default function ResultsPage() { payload.returnDate = searchData.returnDate; } - console.log('🚂 Search Request:', JSON.stringify(payload, null, 2)); const response = await apiClient.post('/search', payload) as any; - console.log('✅ Search Response:', JSON.stringify(response, null, 2)); return response; }, 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 241e6fea9..862a0ba8f 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 @@ -15,13 +15,11 @@ import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils'; function getPassengerIdFromToken(token: string): string | null { try { if (!token) { - console.warn('No token provided'); return null; } const parts = token.split('.'); if (parts.length !== 3) { - console.warn('Invalid token format - expected 3 parts, got', parts.length); return null; } @@ -33,21 +31,16 @@ function getPassengerIdFromToken(token: string): string | null { try { decoded = JSON.parse(atob(padded)); } catch (e) { - console.error('Failed to parse base64:', e); return null; } - console.log('Decoded JWT payload keys:', Object.keys(decoded)); - console.log('passengerId from JWT:', decoded.passengerId); if (!decoded.passengerId) { - console.warn('No passengerId in JWT payload, available keys:', Object.keys(decoded)); return null; } return decoded.passengerId; } catch (error) { - console.error('Error in getPassengerIdFromToken:', error); return null; } } @@ -149,7 +142,6 @@ export default function ReviewPage() { setSeatDetails(details); } catch (error) { - console.error('Failed to fetch seat details:', error); } }; @@ -159,9 +151,6 @@ export default function ReviewPage() { const createBookingMutation = useMutation({ mutationFn: (data: any) => { const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest'; - console.log('=== API REQUEST ==='); - console.log('Endpoint:', endpoint); - console.log('Request Data:', JSON.stringify(data, null, 2)); return apiClient.post(endpoint, data); }, onSuccess: (data: any) => { @@ -201,9 +190,8 @@ export default function ReviewPage() { } if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) { - console.error('Missing search criteria'); alert('Missing search criteria. Please start over.'); - router.push('/booking/search'); + window.location.href = '/'; return; } @@ -225,10 +213,8 @@ export default function ReviewPage() { seatClassId = outboundClassName ? findByName(outboundClassName) : seatClasses[0].id; returnSeatClassId = returnClassName ? findByName(returnClassName) : seatClasses[0].id; - console.log('Seat class lookup:', { outboundClassName, returnClassName, seatClassId, returnSeatClassId }); } } catch (err) { - console.error('Failed to fetch seat classes:', err); } if (!seatClassId) { @@ -242,11 +228,9 @@ export default function ReviewPage() { const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; if (!token) { - console.error('No token in localStorage'); throw new Error('Authentication token not found. Please log in again.'); } - console.log('Token found, length:', token.length); let passengerId = getPassengerIdFromToken(token); @@ -274,7 +258,6 @@ export default function ReviewPage() { const me: any = await apiClient.get('/passengers/me'); passengerId = me?.id || me?.passengerId || ''; } catch (err) { - console.error('Failed to resolve passengerId from /passengers/me:', err); } } @@ -379,11 +362,8 @@ export default function ReviewPage() { localStorage.setItem('deviceId', bookingData.deviceId); } - console.log('Creating booking with payload:', JSON.stringify(bookingData, null, 2)); - console.log('API endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest'); await createBookingMutation.mutateAsync(bookingData); } catch (error) { - console.error('Error in handleConfirm:', error); alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.'); } }; @@ -392,30 +372,22 @@ export default function ReviewPage() { if (isRoundTrip) { if (!outboundSchedule || !inboundSchedule || !passengers.length) { if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { - console.log('Redirecting to search - missing round trip data'); - router.push('/booking/search'); + window.location.href = '/'; } } } else { if (!selectedSchedule || !passengers.length) { if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { - console.log('Redirecting to search - missing data'); - router.push('/booking/search'); + window.location.href = '/'; } } } }, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]); - if (isRoundTrip && (!outboundSchedule || !inboundSchedule || !passengers.length)) { - return null; - } - - if (!isRoundTrip && (!selectedSchedule || !passengers.length)) { - return null; - } - - const fetchFareBreakdown = useCallback(async (scheduleId: string, originStationId: string, destinationStationId: string) => { + // Package bookings use the stored tier price — no fare calculation needed + if (packageTierPriceMinor !== null) return; + try { const seatClasses: any[] = await apiClient.get('/seat-classes'); const scheduleSeatClassName = isRoundTrip @@ -445,9 +417,8 @@ export default function ReviewPage() { const result: any = await apiClient.get(`/search/fare-breakdown?${params}`); setFareBreakdown(result); } catch (err) { - console.error('Failed to fetch fare breakdown:', err); } - }, [passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]); + }, [packageTierPriceMinor, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]); useEffect(() => { if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return; @@ -456,7 +427,26 @@ export default function ReviewPage() { fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId); }, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]); - const total = packageTierPriceMinor ?? fareBreakdown?.totalMinor ?? 0; + if (isRoundTrip && (!outboundSchedule || !inboundSchedule || !passengers.length)) { + return null; + } + + if (!isRoundTrip && (!selectedSchedule || !passengers.length)) { + return null; + } + + 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; + const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; + const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; + + const total = isPackageBooking + ? adultPassengerCount * pkgAdultFare + childPassengerCount * pkgChildFare + : (fareBreakdown?.totalMinor ?? 0); // Shared fare sidebar — rendered in right column (desktop) and inline (mobile) const FareSidebar = () => ( @@ -466,9 +456,11 @@ export default function ReviewPage() { {passengers.map((p, i) => { const line = fareBreakdown?.passengers?.[i]; - const passengerTotal = line?.fareMinor ?? 0; const isChildPassenger = isChild(p); - const isFreeChild = line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i)); + const passengerTotal = isPackageBooking + ? (isChildPassenger ? pkgChildFare : pkgAdultFare) + : (line?.fareMinor ?? 0); + const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i))); return (
@@ -477,9 +469,9 @@ export default function ReviewPage() { {p.name || `Passenger ${i + 1}`} {isChildPassenger && ( - ({isFreeChild ? 'CHILD - FREE' : 'CHILD'}) + ({isPackageBooking ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'}) )} 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 5c717bd39..f0e765f33 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 @@ -141,6 +141,9 @@ export default function SeatsPage() { // Maps passenger index -> assigned seat id. A passenger can only get a seat while // they are the "active" passenger, which prevents bulk/batch selection across passengers. const [passengerSeatMap, setPassengerSeatMap] = useState>({}); + // Outbound seat IDs locked in after the outbound hold — used to prevent the + // same physical seat being picked again on the inbound leg. + const [activePassengerIndex, setActivePassengerIndex] = useState(0); const [selectedCoach, setSelectedCoach] = useState(null); const [currentJourneyType, setCurrentJourneyType] = useState< @@ -170,24 +173,17 @@ export default function SeatsPage() { isLoading, error, } = useQuery({ - queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType], + queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType, (currentSchedule as any)?.originStationId, (currentSchedule as any)?.destinationStationId], queryFn: async () => { - const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeId}&journeyDirection=${journeyDirection}`; - console.log("🪑 Seatmap Request:", { - endpoint, - }); + const scheduleForMap = isRoundTrip && currentJourneyType === "inbound" ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule); + const originId = (scheduleForMap as any)?.originStationId || searchCriteria?.originStationId; + const destinationId = (scheduleForMap as any)?.destinationStationId || searchCriteria?.destinationStationId; + const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeId}&journeyDirection=${journeyDirection}${originId ? `&originStationId=${originId}` : ''}${destinationId ? `&destinationStationId=${destinationId}` : ''}`; const response = await apiClient.get(endpoint); - console.log("✅ Seatmap Response:", { - endpoint, - fullResponse: response, - dataCoaches: (response as any)?.data?.coaches?.length || 0, - rootCoaches: (response as any)?.coaches?.length || 0, - }); const finalData = (response as any)?.data || response; - console.log("🎯 Final data structure:", finalData); return finalData; }, enabled: !!currentSchedule?.id && !!coachTypeId, @@ -202,15 +198,14 @@ export default function SeatsPage() { seatId: seatIds[i], })); - const isInbound = isRoundTrip && currentJourneyType === "inbound"; + const isInbound = currentJourneyType === "inbound"; // Always use the schedule's own station IDs (set from schedule.origin.id / schedule.destination.id) // so they are guaranteed to exist in the trip's TripStopTime records. - const scheduleForHold = isInbound ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule); + const scheduleForHold = isInbound && inboundSchedule ? inboundSchedule : (outboundSchedule || selectedSchedule); const originId = (scheduleForHold as any)?.originStationId || searchCriteria?.originStationId; const destinationId = (scheduleForHold as any)?.destinationStationId || searchCriteria?.destinationStationId; - console.log('🎫 Hold request:', { scheduleId: currentSchedule?.id, originId, destinationId, isInbound }); return apiClient.post(`/seats/hold`, { scheduleId: currentSchedule?.id, originStationId: originId, @@ -220,7 +215,7 @@ export default function SeatsPage() { }); }, onSuccess: (data: any) => { - const isInbound = isRoundTrip && currentJourneyType === "inbound"; + const isInbound = currentJourneyType === "inbound"; if (isInbound) { // Merge the return hold into the existing outbound hold const current = useBookingStore.getState().seatHold; @@ -258,29 +253,10 @@ export default function SeatsPage() { (seatMapData as any)?.coaches || (seatMapData as any)?.data?.coaches || []; - console.log("📦 Raw coaches data:", { - fromRoot: (seatMapData as any)?.coaches?.length || 0, - fromData: (seatMapData as any)?.data?.coaches?.length || 0, - using: rawCoaches.length, - hasRooms: rawCoaches.some((c: any) => c.rooms?.length > 0), - sampleRooms: rawCoaches[0]?.rooms?.length || 0, - }); return rawCoaches; }, [seatMapData]); const filteredCoaches = useMemo(() => { - console.log("🔍 Filtering coaches:", { - totalCoaches: coaches.length, - selectedSeatClass: currentSchedule?.selectedSeatClass, - coachesData: coaches.map((c: any) => ({ - id: c.id, - name: c.name, - label: c.label, - seatClass: c.seatClass, - seatClasses: c.seatClasses, - seatsCount: c.seats?.length || 0, - })), - }); const coachesWithSeats = coaches.filter((c: any) => { // Bed coaches store occupants in rooms.beds, not seats @@ -288,12 +264,8 @@ export default function SeatsPage() { return c.seats && c.seats.length > 0; }); - console.log( - "✅ Returning all coaches with seats/beds:", - coachesWithSeats.length, - ); return coachesWithSeats; - }, [coaches, currentSchedule?.selectedSeatClass]); + }, [coaches]); const selectedCoachData = useMemo( @@ -367,7 +339,7 @@ export default function SeatsPage() { return seats; }, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]); - // Seats already claimed by any passenger in this journey leg + // Seats already claimed by any passenger in this journey leg, plus outbound const assignedSeatIds = useMemo( () => new Set(Object.values(passengerSeatMap)), [passengerSeatMap], diff --git a/apps/edr-passenger-web/portal/src/app/layout.tsx b/apps/edr-passenger-web/portal/src/app/layout.tsx index da774c15f..d38f63ca2 100644 --- a/apps/edr-passenger-web/portal/src/app/layout.tsx +++ b/apps/edr-passenger-web/portal/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { headers } from 'next/headers'; import './globals.css'; import { Providers } from './providers'; import AppHeader from '@/components/AppHeader'; @@ -15,10 +16,13 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { + // Nonce set per-request by middleware; required for this inline script under the CSP. + const nonce = headers().get('x-nonce') ?? undefined; return (