mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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 { 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 { 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 {}
|
||||||
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>
|
<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"
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user