From afd30c36a0ff13a67e58e3ab9629d5b9aefc1e7a Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sun, 5 Jul 2026 18:14:42 +0300 Subject: [PATCH] Package passenger numbers and pricing updates --- apps/edr-passenger-api/src/app.module.ts | 10 ++ .../src/modules/bookings/bookings.service.ts | 68 ++++++----- .../src/modules/bookings/guest-booking.dto.ts | 6 + .../modules/bookings/guest-booking.service.ts | 27 +++-- .../configurable-fare.service.ts | 2 +- .../fare-engine/fare-engine.service.ts | 2 +- .../src/modules/packages/packages.dto.ts | 6 + .../src/modules/packages/packages.service.ts | 82 +++++++++++-- .../segments/segment-fare.controller.ts | 49 ++++++++ .../src/modules/segments/segment-fare.dto.ts | 67 +++++++++++ .../modules/segments/segment-fare.service.ts | 62 ++++++++++ .../src/modules/segments/segments.module.ts | 12 +- .../src/seed/segment-fare.seeder.ts | 109 ++++++++++++++++++ .../backoffice/src/app/classes/page.tsx | 2 +- .../src/app/fare-management/page.tsx | 4 +- .../backoffice/src/app/pricing/page.tsx | 16 +-- .../backoffice/src/lib/api/index.ts | 8 ++ .../portal/src/app/booking/payment/page.tsx | 55 +++++---- .../portal/src/app/booking/review/page.tsx | 28 ++++- .../portal/src/app/packages/[id]/page.tsx | 27 +++-- 20 files changed, 537 insertions(+), 105 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/segments/segment-fare.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/segments/segment-fare.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/segments/segment-fare.service.ts create mode 100644 apps/edr-passenger-api/src/seed/segment-fare.seeder.ts 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..3aaa85fc2 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 @@ -157,8 +157,10 @@ export class GuestBookingService { ); const adultFareMinor = baseFareMinor * adultCount; - const paidChildrenCount = Math.max(0, childCount - 1); - const childFareMinor = baseFareMinor * paidChildrenCount; + const isPackageOneway = !!dto.packageId; + const paidChildrenCount = isPackageOneway ? childCount : Math.max(0, childCount - 1); + const childUnitFare = isPackageOneway ? Math.round(baseFareMinor * 0.1) : baseFareMinor; + const childFareMinor = childUnitFare * paidChildrenCount; const totalBaseFareMinor = adultFareMinor + childFareMinor; let discountMinor = 0; @@ -217,6 +219,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 +234,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 +261,7 @@ export class GuestBookingService { adultCount, adultFareMinor, childCount, - freeChildrenCount: Math.min(childCount, 1), + freeChildrenCount: isPackageOneway ? 0 : Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, @@ -375,9 +378,12 @@ export class GuestBookingService { 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; + const isPackageRoundTrip = !!dto.packageId; + const paidChildrenCount = isPackageRoundTrip ? childCount : Math.max(0, childCount - 1); + const outboundChildUnitFare = isPackageRoundTrip ? Math.round(outboundBaseFare * 0.1) : outboundBaseFare; + const returnChildUnitFare = isPackageRoundTrip ? Math.round(returnBaseFare * 0.1) : returnBaseFare; + const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount; + const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount; const combinedBaseFareMinor = outboundTotalBase + returnTotalBase; let discountMinor = 0; @@ -423,6 +429,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 +447,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 +462,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 +491,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.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..746c8922d 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)], @@ -41,14 +67,19 @@ export class PackagesService { 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 +91,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,7 +107,12 @@ 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, @@ -297,13 +332,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 +406,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/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/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/pricing/page.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx index 0efeab47d..3e221839b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx @@ -116,7 +116,7 @@ export default function PricingPage() { const { data: segmentFares = [], isLoading: segmentFaresLoading, refetch: refetchSegmentFares } = useQuery({ queryKey: ['segment-fares', selectedRoute], - queryFn: () => (selectedRoute ? apiClient.get(`/schedules/routes/${selectedRoute}/segment-fares`) : Promise.resolve([])), + queryFn: () => (selectedRoute ? apiClient.get(`/admin/segment-fares?routeId=${selectedRoute}`) : Promise.resolve([])), enabled: !!selectedRoute && tab === 'segment', }); @@ -199,7 +199,7 @@ export default function PricingPage() { }); const createSegmentFareMutation = useMutation({ - mutationFn: (data: any) => apiClient.post(`/schedules/segment-fares`, data), + mutationFn: (data: any) => apiClient.post(`/admin/segment-fares`, data), onSuccess: () => { refetchSegmentFares(); resetSegmentForm(); @@ -211,7 +211,7 @@ export default function PricingPage() { }); const updateSegmentFareMutation = useMutation({ - mutationFn: (data: any) => apiClient.patch(`/schedules/segment-fares/${data.id}`, data), + mutationFn: ({ id, ...data }: any) => apiClient.put(`/admin/segment-fares/${id}`, data), onSuccess: () => { refetchSegmentFares(); setEditingFare(null); @@ -224,7 +224,7 @@ export default function PricingPage() { }); const deleteSegmentFareMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/schedules/segment-fares/${id}`), + mutationFn: (id: string) => apiClient.delete(`/admin/segment-fares/${id}`), onSuccess: () => { refetchSegmentFares(); setDeleteConfirm({ isOpen: false, id: null }); @@ -422,7 +422,7 @@ export default function PricingPage() { }, { key: 'baseFare', - label: 'Fare (ETB)', + label: 'Fare', render: (fare: any) => { const minor = fare.baseFareMinor ?? fare.baseFare; if (minor == null) return ; @@ -495,7 +495,7 @@ export default function PricingPage() { }, { key: 'baseFare', - label: 'Fare (ETB)', + label: 'Fare', render: (fare: any) => { const fareValue = fare.baseFare ?? fare.baseFareMinor; if (fareValue == null) return ; @@ -870,7 +870,7 @@ export default function PricingPage() {
- +
- + apiClient.delete(`/agents/excess-baggage/${id}`), }; +// Segment Fares API +export const segmentFaresApi = { + getAll: (routeId?: string) => apiClient.get(`/admin/segment-fares${routeId ? `?routeId=${routeId}` : ''}`), + create: (data: any) => apiClient.post('/admin/segment-fares', data), + update: (id: string, data: any) => apiClient.put(`/admin/segment-fares/${id}`, data), + remove: (id: string) => apiClient.delete(`/admin/segment-fares/${id}`), +}; + // Route Coach Templates API export const routeCoachTemplatesApi = { get: (routeId: string) => apiClient.get(`/routes/${routeId}/coaches`), 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 5413af78c..dc20d76d8 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; @@ -68,18 +72,20 @@ export default function PaymentPage() { // 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) => { + const outboundBaseFare = !isPackage && isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => { return sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0); }, 0) : 0; - const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => { + const inboundBaseFare = !isPackage && isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => { return sum + calculatePassengerFare(passengers, i, inboundSchedule.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 baseFare = isPackage + ? (searchCriteria?.adultCount ?? 0) * pkgAdultFare + (searchCriteria?.childCount ?? 0) * 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 @@ -259,17 +265,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 (
@@ -278,9 +289,9 @@ export default function PaymentPage() { {p.name || `Passenger ${i + 1}`} {isChildPassenger && ( - ({isFreeChild ? 'CHILD - FREE' : 'CHILD'}) + ({isPackage ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'}) )} @@ -288,15 +299,15 @@ export default function PaymentPage() { {formatFare(passengerTotal, displayCurrency)}
- {isRoundTrip && ( + {!isPackage && isRoundTrip && (
Outbound {isFreeChild ? '(Free)' : ''} - {formatFare(outboundFare, displayCurrency)} + {formatFare(calculatePassengerFare(passengers, i, outboundSchedule?.baseFareAdult || 0), displayCurrency)}
Return {isFreeChild ? '(Free)' : ''} - {formatFare(inboundFare, displayCurrency)} + {formatFare(calculatePassengerFare(passengers, i, inboundSchedule?.baseFareAdult || 0), displayCurrency)}
)} diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 4dcf82bd9..7b449dc8b 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 @@ -385,6 +385,9 @@ export default function ReviewPage() { }, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]); 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 @@ -415,7 +418,7 @@ export default function ReviewPage() { setFareBreakdown(result); } catch (err) { } - }, [passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]); + }, [packageTierPriceMinor, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]); useEffect(() => { if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return; @@ -432,7 +435,18 @@ export default function ReviewPage() { return null; } - const total = packageTierPriceMinor ?? fareBreakdown?.totalMinor ?? 0; + 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.length; + const childPassengerCount = searchCriteria?.childCount ?? 0; + + const total = isPackageBooking + ? adultPassengerCount * pkgAdultFare + childPassengerCount * pkgChildFare + : (fareBreakdown?.totalMinor ?? 0); // Shared fare sidebar — rendered in right column (desktop) and inline (mobile) const FareSidebar = () => ( @@ -442,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 (
@@ -453,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/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index a735cc790..4b1bd5a98 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -287,6 +287,10 @@ function PriceTiersPanel({ // ─── Passenger count picker ────────────────────────────────────────────────── +const PKG_MAX_ADULTS = 5; +const PKG_MAX_CHILDREN = 2; +const PKG_CHILD_FARE_RATIO = 0.1; + function PassengerCountModal({ tier, onClose, @@ -304,8 +308,9 @@ function PassengerCountModal({ }) { const [adultCount, setAdultCount] = useState(1); const [childCount, setChildCount] = useState(0); - const total = adultCount + childCount; const remaining = tier.availableSeats - tier.bookedSeats; + const childFareMinor = Math.round(tier.priceMinor * PKG_CHILD_FARE_RATIO); + const totalMinor = (adultCount * tier.priceMinor + childCount * childFareMinor) * priceMultiplier; return ( <> @@ -322,13 +327,14 @@ function PassengerCountModal({

Selected tier

{tier.label.trim()}

-

{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per person

+

{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult

- {[{ label: "Adults", sub: "Age 5+", value: adultCount, min: 1, set: setAdultCount }, - { label: "Children", sub: "Under 5", value: childCount, min: 0, set: setChildCount }] - .map(({ label, sub, value, min, set }) => ( + {[ + { label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: setAdultCount }, + { label: "Children", sub: `Under 5 · max ${PKG_MAX_CHILDREN} · 10% of adult fare`, value: childCount, min: 0, max: Math.min(PKG_MAX_CHILDREN, remaining - adultCount), set: setChildCount }, + ].map(({ label, sub, value, min, max, set }) => (

{label}

@@ -342,7 +348,7 @@ function PassengerCountModal({ {value} @@ -351,8 +357,8 @@ function PassengerCountModal({ ))}
- Total - {formatPrice(tier.priceMinor * priceMultiplier * total, tier.currency)} + Total{priceMultiplier === 2 ? ' (round-trip)' : ''} + {formatPrice(totalMinor, tier.currency)}
{error && ( @@ -363,7 +369,7 @@ function PassengerCountModal({ )} @@ -460,7 +466,8 @@ export default function PackageDetailPage() { })), ); - setPackageContext(id, selectedTier.id, ctx.totalMinor, pkg.name); + // Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing + setPackageContext(id, selectedTier.id, selectedTier.priceMinor, pkg.name); router.push("/booking/passengers"); } catch (err: any) {