mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #467 from Tria-plc/alpha
Package passenger numbers and pricing updates
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
109
apps/edr-passenger-api/src/seed/segment-fare.seeder.ts
Normal file
109
apps/edr-passenger-api/src/seed/segment-fare.seeder.ts
Normal 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 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<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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -289,7 +289,7 @@ export default function ClassesPage() {
|
||||
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
|
||||
|
||||
<div>
|
||||
<label className="label">Base Fare (ETB) *</label>
|
||||
<label className="label">Base Fare *</label>
|
||||
<input
|
||||
type="number"
|
||||
name="baseFareMinor"
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function FareManagementPage() {
|
||||
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>,
|
||||
},
|
||||
{
|
||||
@@ -214,7 +214,7 @@ export default function FareManagementPage() {
|
||||
</select>
|
||||
</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"
|
||||
defaultValue={editingRule ? (editingRule.baseFareMinor / 100).toFixed(2) : ''} required
|
||||
placeholder="e.g. 350.00" />
|
||||
|
||||
@@ -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 <span className="text-muted-foreground">—</span>;
|
||||
@@ -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 <span className="text-muted-foreground">—</span>;
|
||||
@@ -870,7 +870,7 @@ export default function PricingPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Fare (ETB) *</label>
|
||||
<label className="label">Fare *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -1000,7 +1000,7 @@ export default function PricingPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Fare (ETB) *</label>
|
||||
<label className="label">Fare *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
|
||||
@@ -458,6 +458,14 @@ export const excessBaggageApi = {
|
||||
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
|
||||
export const routeCoachTemplatesApi = {
|
||||
get: (routeId: string) => apiClient.get<any>(`/routes/${routeId}/coaches`),
|
||||
|
||||
@@ -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<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 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() {
|
||||
<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>
|
||||
{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 (
|
||||
<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}`}
|
||||
{isChildPassenger && (
|
||||
<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>
|
||||
@@ -288,15 +299,15 @@ export default function PaymentPage() {
|
||||
{formatFare(passengerTotal, displayCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
{isRoundTrip && (
|
||||
{!isPackage && isRoundTrip && (
|
||||
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex justify-between">
|
||||
<span>Outbound {isFreeChild ? '(Free)' : ''}</span>
|
||||
<span>{formatFare(outboundFare, displayCurrency)}</span>
|
||||
<span>{formatFare(calculatePassengerFare(passengers, i, outboundSchedule?.baseFareAdult || 0), displayCurrency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Return {isFreeChild ? '(Free)' : ''}</span>
|
||||
<span>{formatFare(inboundFare, displayCurrency)}</span>
|
||||
<span>{formatFare(calculatePassengerFare(passengers, i, inboundSchedule?.baseFareAdult || 0), displayCurrency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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() {
|
||||
</h2>
|
||||
{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 (
|
||||
<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}`}
|
||||
{isChildPassenger && (
|
||||
<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>
|
||||
|
||||
@@ -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({
|
||||
<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-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 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 }]
|
||||
.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 }) => (
|
||||
<div key={label} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
|
||||
@@ -342,7 +348,7 @@ function PassengerCountModal({
|
||||
</button>
|
||||
<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)}
|
||||
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">
|
||||
+
|
||||
</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">
|
||||
<span className="text-sm text-gray-500">Total</span>
|
||||
<span className="text-base font-extrabold text-primary">{formatPrice(tier.priceMinor * priceMultiplier * total, tier.currency)}</span>
|
||||
<span className="text-sm text-gray-500">Total{priceMultiplier === 2 ? ' (round-trip)' : ''}</span>
|
||||
<span className="text-base font-extrabold text-primary">{formatPrice(totalMinor, tier.currency)}</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -363,7 +369,7 @@ function PassengerCountModal({
|
||||
)}
|
||||
|
||||
<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">
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>}
|
||||
</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");
|
||||
} catch (err: any) {
|
||||
|
||||
Reference in New Issue
Block a user