Package passenger numbers and pricing updates

This commit is contained in:
Stephanos A
2026-07-05 18:14:42 +03:00
parent 9e77ca7865
commit afd30c36a0
20 changed files with 537 additions and 105 deletions

View File

@@ -62,6 +62,8 @@ import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.mod
import { HealthModule } from './modules/health/health.module'; import { HealthModule } from './modules/health/health.module';
import { TasksModule } from './modules/tasks/tasks.module'; import { TasksModule } from './modules/tasks/tasks.module';
import { AppReleasesModule } from './modules/app-releases/app-releases.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({ @Module({
imports: [ imports: [
@@ -132,6 +134,7 @@ import { AppReleasesModule } from './modules/app-releases/app-releases.module';
HealthModule, HealthModule,
TasksModule, TasksModule,
AppReleasesModule, AppReleasesModule,
ConfigurableFareModule,
], ],
providers: [ providers: [
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard }, { provide: APP_GUARD, useClass: DynamicThrottlerGuard },
@@ -139,6 +142,7 @@ import { AppReleasesModule } from './modules/app-releases/app-releases.module';
DynamicThrottlerGuard, DynamicThrottlerGuard,
EdrPassengerOrgSeeder, EdrPassengerOrgSeeder,
PassengerStaffUsersSeeder, PassengerStaffUsersSeeder,
SegmentFareSeeder,
], ],
}) })
export class AppModule implements OnApplicationBootstrap { export class AppModule implements OnApplicationBootstrap {
@@ -147,6 +151,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly seeder: DataSeeder, private readonly seeder: DataSeeder,
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder, private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder, private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder,
private readonly segmentFareSeeder: SegmentFareSeeder,
) {} ) {}
async onApplicationBootstrap() { async onApplicationBootstrap() {
@@ -165,5 +170,10 @@ export class AppModule implements OnApplicationBootstrap {
} catch (err) { } catch (err) {
this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message); 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);
}
} }
} }

View File

@@ -341,8 +341,9 @@ export class BookingsService {
totalMinor: b.totalMinor, currency: b.currency || 'ETB', totalMinor: b.totalMinor, currency: b.currency || 'ETB',
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail, contactPhone: b.contactPhone, contactEmail: b.contactEmail, contactPhone: b.contactPhone,
bookingType: 'PACKAGE', packageId: b.packageId, isPackageBooking: true, bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
packageName: b.package?.name, packageCode: b.package?.code, isPackageBooking: true, packageName: b.package?.name, packageCode: b.package?.code,
tierLabel: b.priceTier?.label ?? null,
returnLegStatus: null, adultCount: b.passengerCount, childCount: 0, returnLegStatus: null, adultCount: b.passengerCount, childCount: 0,
createdAt: b.createdAt, passenger: null, createdAt: b.createdAt, passenger: null,
passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [], passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [],
@@ -372,6 +373,8 @@ export class BookingsService {
schedule: { include: { originStation: true, destinationStation: true, train: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true, paymentIntent: true,
seats: { include: { seat: true } }, seats: { include: { seat: true } },
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true } },
}, },
}), }),
this.prisma.booking.count({ where }), this.prisma.booking.count({ where }),
@@ -415,6 +418,10 @@ export class BookingsService {
contactPhone: booking.contactPhone, contactPhone: booking.contactPhone,
bookingType: booking.bookingType, bookingType: booking.bookingType,
packageId: booking.packageId ?? null, 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, isPackageBooking: !!booking.packageId,
returnLegStatus: (booking as any).returnLegStatus ?? null, returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount, adultCount: booking.adultCount,
@@ -446,9 +453,11 @@ export class BookingsService {
contactPhone: b.contactPhone, contactPhone: b.contactPhone,
bookingType: 'PACKAGE', bookingType: 'PACKAGE',
packageId: b.packageId, packageId: b.packageId,
priceTierId: b.priceTierId,
isPackageBooking: true, isPackageBooking: true,
packageName: b.package?.name, packageName: b.package?.name,
packageCode: b.package?.code, packageCode: b.package?.code,
tierLabel: b.priceTier?.label ?? null,
returnLegStatus: null, returnLegStatus: null,
adultCount: b.passengerCount, adultCount: b.passengerCount,
childCount: 0, childCount: 0,
@@ -518,20 +527,18 @@ export class BookingsService {
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency); 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; let freeChildUsed = false;
const passengersWithFares = passengersData.map(p => { const passengersWithFares = passengersData.map(p => {
let fareMinor: number; let fareMinor: number;
if (p.category === PassengerCategory.ADULT) { if (p.category === PassengerCategory.ADULT) {
fareMinor = fareCalculation.baseFareMinor; fareMinor = fareCalculation.baseFareMinor;
} else if (dto.packageId) {
fareMinor = Math.round(fareCalculation.baseFareMinor * 0.1);
} else { } else {
// Child: first child is free, subsequent children pay full fare if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
if (!freeChildUsed) { else fareMinor = fareCalculation.baseFareMinor;
fareMinor = 0;
freeChildUsed = true;
} else {
fareMinor = fareCalculation.baseFareMinor;
}
} }
return { ...p, fareMinor }; return { ...p, fareMinor };
}); });
@@ -661,34 +668,27 @@ export class BookingsService {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); 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 outboundFreeChildUsed = false;
let returnFreeChildUsed = false; let returnFreeChildUsed = false;
const passengersWithFares = passengersData.map(p => { const passengersWithFares = passengersData.map(p => {
let outboundFareMinor: number; let outboundFareMinor: number;
let returnFareMinor: number; let returnFareMinor: number;
if (p.category === PassengerCategory.ADULT) { if (p.category === PassengerCategory.ADULT) {
outboundFareMinor = outboundFare.baseFareMinor; outboundFareMinor = outboundFare.baseFareMinor;
returnFareMinor = returnFare.baseFareMinor; returnFareMinor = returnFare.baseFareMinor;
} else if (dto.packageId) {
outboundFareMinor = Math.round(outboundFare.baseFareMinor * 0.1);
returnFareMinor = Math.round(returnFare.baseFareMinor * 0.1);
} else { } else {
// Child fare for outbound if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
if (!outboundFreeChildUsed) { else outboundFareMinor = outboundFare.baseFareMinor;
outboundFareMinor = 0; if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
outboundFreeChildUsed = true; else returnFareMinor = returnFare.baseFareMinor;
} else {
outboundFareMinor = outboundFare.baseFareMinor;
}
// Child fare for return
if (!returnFreeChildUsed) {
returnFareMinor = 0;
returnFreeChildUsed = true;
} else {
returnFareMinor = returnFare.baseFareMinor;
}
} }
return { ...p, outboundFareMinor, returnFareMinor }; return { ...p, outboundFareMinor, returnFareMinor };
}); });
@@ -1231,16 +1231,20 @@ export class BookingsService {
childCount: number, childCount: number,
) { ) {
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } }); const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } });
const passengerCount = adultCount + childCount; // For round-trip packages the caller splits the tier price across legs, so
const totalBaseFareMinor = tier.priceMinor * passengerCount; // 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 { return {
baseFareMinor: tier.priceMinor, baseFareMinor: tier.priceMinor,
adultCount, adultCount,
adultFareMinor: tier.priceMinor * adultCount, adultFareMinor,
childCount, childCount,
freeChildrenCount: 0, freeChildrenCount: 0,
paidChildrenCount: childCount, paidChildrenCount: childCount,
childFareMinor: tier.priceMinor * childCount, childFareMinor: childTotalMinor,
totalBaseFareMinor, totalBaseFareMinor,
discountMinor: 0, discountMinor: 0,
loyaltyRedemptionMinor: 0, loyaltyRedemptionMinor: 0,

View File

@@ -144,6 +144,12 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' }) @ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' })
@IsOptional() @IsString() deviceId?: string; @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 { export class SavedPassengerProfileDto {

View File

@@ -157,8 +157,10 @@ export class GuestBookingService {
); );
const adultFareMinor = baseFareMinor * adultCount; const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1); const isPackageOneway = !!dto.packageId;
const childFareMinor = baseFareMinor * paidChildrenCount; 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; const totalBaseFareMinor = adultFareMinor + childFareMinor;
let discountMinor = 0; let discountMinor = 0;
@@ -217,6 +219,7 @@ export class GuestBookingService {
displayCurrency, displayCurrency,
displayTotalMinor, displayTotalMinor,
bookingType: 'ONE_WAY', bookingType: 'ONE_WAY',
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
userAgent: dto.deviceId, userAgent: dto.deviceId,
contactEmail: firstPassenger.email || null, contactEmail: firstPassenger.email || null,
contactPhone: firstPassenger.phone || null, contactPhone: firstPassenger.phone || null,
@@ -231,7 +234,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry, passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified, verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined, verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0), fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
displayCurrency, displayCurrency,
})), })),
}, },
@@ -258,7 +261,7 @@ export class GuestBookingService {
adultCount, adultCount,
adultFareMinor, adultFareMinor,
childCount, childCount,
freeChildrenCount: Math.min(childCount, 1), freeChildrenCount: isPackageOneway ? 0 : Math.min(childCount, 1),
paidChildrenCount, paidChildrenCount,
childFareMinor, childFareMinor,
totalBaseFareMinor, totalBaseFareMinor,
@@ -375,9 +378,12 @@ export class GuestBookingService {
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId), this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
]); ]);
const paidChildrenCount = Math.max(0, childCount - 1); const isPackageRoundTrip = !!dto.packageId;
const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount; const paidChildrenCount = isPackageRoundTrip ? childCount : Math.max(0, childCount - 1);
const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount; 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; const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
let discountMinor = 0; let discountMinor = 0;
@@ -423,6 +429,7 @@ export class GuestBookingService {
returnHoldId: dto.returnHoldId, returnHoldId: dto.returnHoldId,
returnSeatClassId, returnSeatClassId,
returnLegStatus: 'NEITHER_USED', returnLegStatus: 'NEITHER_USED',
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
userAgent: dto.deviceId, userAgent: dto.deviceId,
contactEmail: passengersData[0]?.email || null, contactEmail: passengersData[0]?.email || null,
contactPhone: passengersData[0]?.phone || null, contactPhone: passengersData[0]?.phone || null,
@@ -440,7 +447,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry, passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified, verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined, verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0), fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
displayCurrency, displayCurrency,
})), })),
...passengersData.map((p) => ({ ...passengersData.map((p) => ({
@@ -455,7 +462,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry, passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified, verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined, verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0), fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
displayCurrency, displayCurrency,
})), })),
], ],
@@ -484,7 +491,7 @@ export class GuestBookingService {
returnBaseFareMinor: returnBaseFare, returnBaseFareMinor: returnBaseFare,
adultCount, adultCount,
childCount, childCount,
freeChildrenCount: Math.min(childCount, 1), freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1),
paidChildrenCount, paidChildrenCount,
combinedBaseFareMinor, combinedBaseFareMinor,
discountMinor, discountMinor,

View File

@@ -329,7 +329,7 @@ export class ConfigurableFareService {
); );
if (childRule) { if (childRule) {
const freeChildren = Math.min(childCount, childRule.max_free_passengers); const freeChildren = Math.min(childCount, adultCount);
const paidChildren = Math.max(0, childCount - freeChildren); const paidChildren = Math.max(0, childCount - freeChildren);
if (freeChildren > 0) { if (freeChildren > 0) {

View File

@@ -131,7 +131,7 @@ export class FareEngineService {
const adultCount = dto.adultCount ?? 1; const adultCount = dto.adultCount ?? 1;
const childCount = dto.childCount ?? 0; const childCount = dto.childCount ?? 0;
const freeChildrenCount = Math.min(childCount, adultCount); 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 // Subtotal includes: (distance-based fare + premium + insurance) × passengers
// First child is free, but pays premium and insurance // First child is free, but pays premium and insurance

View File

@@ -118,4 +118,10 @@ export class BookPackageDto {
@ApiProperty({ type: [BookPackagePassengerDto] }) @ApiProperty({ type: [BookPackagePassengerDto] })
@IsArray() @ValidateNested({ each: true }) @Type(() => BookPackagePassengerDto) @IsArray() @ValidateNested({ each: true }) @Type(() => BookPackagePassengerDto)
passengers: 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;
} }

View File

@@ -6,6 +6,32 @@ import { Currency } from '@prisma/client';
import { BookingsService } from '../bookings/bookings.service'; import { BookingsService } from '../bookings/bookings.service';
import { GuestBookingService } from '../bookings/guest-booking.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 { function generateRef(): string {
return 'PKG-' + Array.from({ length: 6 }, () => return 'PKG-' + Array.from({ length: 6 }, () =>
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)],
@@ -41,14 +67,19 @@ export class PackagesService {
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'); if (!tier) throw new NotFoundException('Price tier not found');
const passengerCount = adultCount + childCount; if (adultCount < 1) throw new BadRequestException('At least one adult passenger required');
if (passengerCount < 1) throw new BadRequestException('At least one 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; const remaining = tier.availableSeats - tier.bookedSeats;
if (passengerCount > remaining) if (passengerCount > remaining)
throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); 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 // Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches
let seatClassId: string | null = null; 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; } 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) { if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) {
const first = pkg.outboundSchedule.coachAssignments[0]; const first = pkg.outboundSchedule.coachAssignments[0];
coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null; coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null;
@@ -77,7 +107,12 @@ export class PackagesService {
adultCount, adultCount,
childCount, childCount,
passengerCount, 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, totalMinor,
currency: tier.currency, currency: tier.currency,
remainingSeats: remaining, remainingSeats: remaining,
@@ -297,13 +332,30 @@ export class PackagesService {
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId); const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
if (!tier) throw new NotFoundException('Price tier not found'); 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; const remaining = tier.availableSeats - tier.bookedSeats;
if (passengerCount > remaining) { if (passengerCount > remaining) {
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); 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 displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB;
const displayTotalMinor = const displayTotalMinor =
displayCurrency !== Currency.ETB 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) { getMyBookings(passengerId: string) {

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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 };
}
}

View File

@@ -3,20 +3,24 @@ import { SegmentsService } from './segments.service';
import { EnhancedSeatsService } from './enhanced-seats.service'; import { EnhancedSeatsService } from './enhanced-seats.service';
import { TripProgressService } from './trip-progress.service'; import { TripProgressService } from './trip-progress.service';
import { SegmentSeatsController } from './segments.controller'; import { SegmentSeatsController } from './segments.controller';
import { SegmentFareController } from './segment-fare.controller';
import { SegmentFareService } from './segment-fare.service';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
@Module({ @Module({
controllers: [SegmentSeatsController], controllers: [SegmentSeatsController, SegmentFareController],
providers: [ providers: [
SegmentsService, SegmentsService,
EnhancedSeatsService, EnhancedSeatsService,
TripProgressService, TripProgressService,
PrismaService SegmentFareService,
PrismaService,
], ],
exports: [ exports: [
SegmentsService, SegmentsService,
EnhancedSeatsService, EnhancedSeatsService,
TripProgressService TripProgressService,
] SegmentFareService,
],
}) })
export class SegmentsModule {} export class SegmentsModule {}

View File

@@ -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 AbabaDjibouti 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<string, Record<string | 'null', number>> = {
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)`,
);
}
}

View File

@@ -289,7 +289,7 @@ export default function ClassesPage() {
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3> <h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
<div> <div>
<label className="label">Base Fare (ETB) *</label> <label className="label">Base Fare *</label>
<input <input
type="number" type="number"
name="baseFareMinor" name="baseFareMinor"

View File

@@ -107,7 +107,7 @@ export default function FareManagementPage() {
render: (r: any) => <span className="text-sm">{r.nationality || 'All'}</span>, render: (r: any) => <span className="text-sm">{r.nationality || 'All'}</span>,
}, },
{ {
key: 'baseFareMinor', label: 'Base Fare (ETB)', key: 'baseFareMinor', label: 'Base Fare',
render: (r: any) => <span className="font-mono">{(r.baseFareMinor / 100).toFixed(2)}</span>, render: (r: any) => <span className="font-mono">{(r.baseFareMinor / 100).toFixed(2)}</span>,
}, },
{ {
@@ -214,7 +214,7 @@ export default function FareManagementPage() {
</select> </select>
</div> </div>
<div> <div>
<label className="label">Base Fare (ETB) *</label> <label className="label">Base Fare *</label>
<input type="number" name="baseFareMinor" className="input" min="0" step="0.01" <input type="number" name="baseFareMinor" className="input" min="0" step="0.01"
defaultValue={editingRule ? (editingRule.baseFareMinor / 100).toFixed(2) : ''} required defaultValue={editingRule ? (editingRule.baseFareMinor / 100).toFixed(2) : ''} required
placeholder="e.g. 350.00" /> placeholder="e.g. 350.00" />

View File

@@ -116,7 +116,7 @@ export default function PricingPage() {
const { data: segmentFares = [], isLoading: segmentFaresLoading, refetch: refetchSegmentFares } = useQuery({ const { data: segmentFares = [], isLoading: segmentFaresLoading, refetch: refetchSegmentFares } = useQuery({
queryKey: ['segment-fares', selectedRoute], 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', enabled: !!selectedRoute && tab === 'segment',
}); });
@@ -199,7 +199,7 @@ export default function PricingPage() {
}); });
const createSegmentFareMutation = useMutation({ const createSegmentFareMutation = useMutation({
mutationFn: (data: any) => apiClient.post(`/schedules/segment-fares`, data), mutationFn: (data: any) => apiClient.post(`/admin/segment-fares`, data),
onSuccess: () => { onSuccess: () => {
refetchSegmentFares(); refetchSegmentFares();
resetSegmentForm(); resetSegmentForm();
@@ -211,7 +211,7 @@ export default function PricingPage() {
}); });
const updateSegmentFareMutation = useMutation({ 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: () => { onSuccess: () => {
refetchSegmentFares(); refetchSegmentFares();
setEditingFare(null); setEditingFare(null);
@@ -224,7 +224,7 @@ export default function PricingPage() {
}); });
const deleteSegmentFareMutation = useMutation({ const deleteSegmentFareMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/schedules/segment-fares/${id}`), mutationFn: (id: string) => apiClient.delete(`/admin/segment-fares/${id}`),
onSuccess: () => { onSuccess: () => {
refetchSegmentFares(); refetchSegmentFares();
setDeleteConfirm({ isOpen: false, id: null }); setDeleteConfirm({ isOpen: false, id: null });
@@ -422,7 +422,7 @@ export default function PricingPage() {
}, },
{ {
key: 'baseFare', key: 'baseFare',
label: 'Fare (ETB)', label: 'Fare',
render: (fare: any) => { render: (fare: any) => {
const minor = fare.baseFareMinor ?? fare.baseFare; const minor = fare.baseFareMinor ?? fare.baseFare;
if (minor == null) return <span className="text-muted-foreground"></span>; if (minor == null) return <span className="text-muted-foreground"></span>;
@@ -495,7 +495,7 @@ export default function PricingPage() {
}, },
{ {
key: 'baseFare', key: 'baseFare',
label: 'Fare (ETB)', label: 'Fare',
render: (fare: any) => { render: (fare: any) => {
const fareValue = fare.baseFare ?? fare.baseFareMinor; const fareValue = fare.baseFare ?? fare.baseFareMinor;
if (fareValue == null) return <span className="text-muted-foreground"></span>; if (fareValue == null) return <span className="text-muted-foreground"></span>;
@@ -870,7 +870,7 @@ export default function PricingPage() {
</div> </div>
<div> <div>
<label className="label">Fare (ETB) *</label> <label className="label">Fare *</label>
<input <input
type="number" type="number"
min="0" min="0"
@@ -1000,7 +1000,7 @@ export default function PricingPage() {
</div> </div>
<div> <div>
<label className="label">Fare (ETB) *</label> <label className="label">Fare *</label>
<input <input
type="number" type="number"
min="0" min="0"

View File

@@ -458,6 +458,14 @@ export const excessBaggageApi = {
delete: (id: string) => apiClient.delete(`/agents/excess-baggage/${id}`), delete: (id: string) => apiClient.delete(`/agents/excess-baggage/${id}`),
}; };
// Segment Fares API
export const segmentFaresApi = {
getAll: (routeId?: string) => apiClient.get<any>(`/admin/segment-fares${routeId ? `?routeId=${routeId}` : ''}`),
create: (data: any) => apiClient.post<any>('/admin/segment-fares', data),
update: (id: string, data: any) => apiClient.put<any>(`/admin/segment-fares/${id}`, data),
remove: (id: string) => apiClient.delete(`/admin/segment-fares/${id}`),
};
// Route Coach Templates API // Route Coach Templates API
export const routeCoachTemplatesApi = { export const routeCoachTemplatesApi = {
get: (routeId: string) => apiClient.get<any>(`/routes/${routeId}/coaches`), get: (routeId: string) => apiClient.get<any>(`/routes/${routeId}/coaches`),

View File

@@ -28,7 +28,7 @@ const getIconForMethod = (methodId: string) => {
export default function PaymentPage() { export default function PaymentPage() {
const router = useRouter(); 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 { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null); const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null); const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
@@ -36,6 +36,10 @@ export default function PaymentPage() {
const [paymentError, setPaymentError] = useState<string | null>(null); const [paymentError, setPaymentError] = useState<string | null>(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; 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; const displayCurrency = 'ETB' as const;
@@ -68,18 +72,20 @@ export default function PaymentPage() {
// Fallback: estimate from local store while API hasn't responded yet. // Fallback: estimate from local store while API hasn't responded yet.
// Uses the same first-child-free calculation as the review page so the // Uses the same first-child-free calculation as the review page so the
// breakdown shown here matches what the passenger already saw there. // 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); return sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0);
}, 0) : 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); return sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0);
}, 0) : 0; }, 0) : 0;
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => { const baseFare = isPackage
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0; ? (searchCriteria?.adultCount ?? 0) * pkgAdultFare + (searchCriteria?.childCount ?? 0) * pkgChildFare
return sum + calculatePassengerFare(passengers, i, farePerPassenger); : isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
}, 0); 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 // API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency
const totalAmount = bookingAmountData != null const totalAmount = bookingAmountData != null
@@ -259,17 +265,22 @@ export default function PaymentPage() {
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700 space-y-2"> <div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700 space-y-2">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3> <h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
{passengers.map((p, i) => { {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 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 ( return (
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0"> <div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
@@ -278,9 +289,9 @@ export default function PaymentPage() {
{p.name || `Passenger ${i + 1}`} {p.name || `Passenger ${i + 1}`}
{isChildPassenger && ( {isChildPassenger && (
<span className={`text-xs font-semibold ml-1 ${ <span className={`text-xs font-semibold ml-1 ${
isFreeChild ? 'text-green-600' : 'text-blue-600' isPackage ? 'text-blue-600' : isFreeChild ? 'text-green-600' : 'text-blue-600'
}`}> }`}>
({isFreeChild ? 'CHILD - FREE' : 'CHILD'}) ({isPackage ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'})
</span> </span>
)} )}
</span> </span>
@@ -288,15 +299,15 @@ export default function PaymentPage() {
{formatFare(passengerTotal, displayCurrency)} {formatFare(passengerTotal, displayCurrency)}
</span> </span>
</div> </div>
{isRoundTrip && ( {!isPackage && isRoundTrip && (
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400"> <div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between"> <div className="flex justify-between">
<span>Outbound {isFreeChild ? '(Free)' : ''}</span> <span>Outbound {isFreeChild ? '(Free)' : ''}</span>
<span>{formatFare(outboundFare, displayCurrency)}</span> <span>{formatFare(calculatePassengerFare(passengers, i, outboundSchedule?.baseFareAdult || 0), displayCurrency)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>Return {isFreeChild ? '(Free)' : ''}</span> <span>Return {isFreeChild ? '(Free)' : ''}</span>
<span>{formatFare(inboundFare, displayCurrency)}</span> <span>{formatFare(calculatePassengerFare(passengers, i, inboundSchedule?.baseFareAdult || 0), displayCurrency)}</span>
</div> </div>
</div> </div>
)} )}

View File

@@ -385,6 +385,9 @@ export default function ReviewPage() {
}, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]); }, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]);
const fetchFareBreakdown = useCallback(async (scheduleId: string, originStationId: string, destinationStationId: string) => { 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 { try {
const seatClasses: any[] = await apiClient.get('/seat-classes'); const seatClasses: any[] = await apiClient.get('/seat-classes');
const scheduleSeatClassName = isRoundTrip const scheduleSeatClassName = isRoundTrip
@@ -415,7 +418,7 @@ export default function ReviewPage() {
setFareBreakdown(result); setFareBreakdown(result);
} catch (err) { } catch (err) {
} }
}, [passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]); }, [packageTierPriceMinor, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
useEffect(() => { useEffect(() => {
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return; if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
@@ -432,7 +435,18 @@ export default function ReviewPage() {
return null; 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) // Shared fare sidebar — rendered in right column (desktop) and inline (mobile)
const FareSidebar = () => ( const FareSidebar = () => (
@@ -442,9 +456,11 @@ export default function ReviewPage() {
</h2> </h2>
{passengers.map((p, i) => { {passengers.map((p, i) => {
const line = fareBreakdown?.passengers?.[i]; const line = fareBreakdown?.passengers?.[i];
const passengerTotal = line?.fareMinor ?? 0;
const isChildPassenger = isChild(p); 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 ( return (
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0"> <div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
@@ -453,9 +469,9 @@ export default function ReviewPage() {
{p.name || `Passenger ${i + 1}`} {p.name || `Passenger ${i + 1}`}
{isChildPassenger && ( {isChildPassenger && (
<span className={`text-xs font-semibold ml-1 ${ <span className={`text-xs font-semibold ml-1 ${
isFreeChild ? 'text-green-600' : 'text-blue-600' isPackageBooking ? 'text-blue-600' : isFreeChild ? 'text-green-600' : 'text-blue-600'
}`}> }`}>
({isFreeChild ? 'CHILD - FREE' : 'CHILD'}) ({isPackageBooking ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'})
</span> </span>
)} )}
</span> </span>

View File

@@ -287,6 +287,10 @@ function PriceTiersPanel({
// ─── Passenger count picker ────────────────────────────────────────────────── // ─── Passenger count picker ──────────────────────────────────────────────────
const PKG_MAX_ADULTS = 5;
const PKG_MAX_CHILDREN = 2;
const PKG_CHILD_FARE_RATIO = 0.1;
function PassengerCountModal({ function PassengerCountModal({
tier, tier,
onClose, onClose,
@@ -304,8 +308,9 @@ function PassengerCountModal({
}) { }) {
const [adultCount, setAdultCount] = useState(1); const [adultCount, setAdultCount] = useState(1);
const [childCount, setChildCount] = useState(0); const [childCount, setChildCount] = useState(0);
const total = adultCount + childCount;
const remaining = tier.availableSeats - tier.bookedSeats; 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 ( return (
<> <>
@@ -322,13 +327,14 @@ function PassengerCountModal({
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10"> <div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Selected tier</p> <p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Selected tier</p>
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.label.trim()}</p> <p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.label.trim()}</p>
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per person</p> <p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult</p>
</div> </div>
<div className="px-6 py-5 space-y-4"> <div className="px-6 py-5 space-y-4">
{[{ label: "Adults", sub: "Age 5+", value: adultCount, min: 1, set: setAdultCount }, {[
{ label: "Children", sub: "Under 5", value: childCount, min: 0, set: setChildCount }] { label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: setAdultCount },
.map(({ label, sub, value, min, set }) => ( { 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 }) => (
<div key={label} className="flex items-center justify-between"> <div key={label} className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p> <p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
@@ -342,7 +348,7 @@ function PassengerCountModal({
</button> </button>
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">{value}</span> <span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">{value}</span>
<button type="button" onClick={() => set(value + 1)} <button type="button" onClick={() => set(value + 1)}
disabled={total >= remaining} disabled={value >= max}
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors"> className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
+ +
</button> </button>
@@ -351,8 +357,8 @@ function PassengerCountModal({
))} ))}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800"> <div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
<span className="text-sm text-gray-500">Total</span> <span className="text-sm text-gray-500">Total{priceMultiplier === 2 ? ' (round-trip)' : ''}</span>
<span className="text-base font-extrabold text-primary">{formatPrice(tier.priceMinor * priceMultiplier * total, tier.currency)}</span> <span className="text-base font-extrabold text-primary">{formatPrice(totalMinor, tier.currency)}</span>
</div> </div>
{error && ( {error && (
@@ -363,7 +369,7 @@ function PassengerCountModal({
)} )}
<button type="button" onClick={() => onConfirm(adultCount, childCount)} <button type="button" onClick={() => onConfirm(adultCount, childCount)}
disabled={loading || total < 1} disabled={loading || adultCount + childCount < 1}
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2"> className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2">
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>} {loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>}
</button> </button>
@@ -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"); router.push("/booking/passengers");
} catch (err: any) { } catch (err: any) {