This commit is contained in:
Roba Boru
2026-07-06 03:15:57 +03:00
46 changed files with 1032 additions and 788 deletions

View File

@@ -0,0 +1,21 @@
-- AlterTable: add package_departure_station_id to Booking
ALTER TABLE "passenger"."Booking"
ADD COLUMN "packageDepartureStationId" TEXT;
-- AlterTable: add package_departure_station_id to PackageBooking
ALTER TABLE "passenger"."PackageBooking"
ADD COLUMN "packageDepartureStationId" TEXT;
-- AddForeignKey: Booking -> Station
ALTER TABLE "passenger"."Booking"
ADD CONSTRAINT "Booking_packageDepartureStationId_fkey"
FOREIGN KEY ("packageDepartureStationId")
REFERENCES "passenger"."Station"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey: PackageBooking -> Station
ALTER TABLE "passenger"."PackageBooking"
ADD CONSTRAINT "PackageBooking_packageDepartureStationId_fkey"
FOREIGN KEY ("packageDepartureStationId")
REFERENCES "passenger"."Station"("id")
ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -330,7 +330,9 @@ model Station {
originSchedules TrainSchedule[] @relation("OriginTrips")
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
stopTimes TripStopTime[]
crowdSignals StationCrowdSignal[]
crowdSignals StationCrowdSignal[]
bookingDepartures Booking[] @relation("BookingPackageDepartureStation")
packageBookingDepartures PackageBooking[] @relation("PackageBookingDepartureStation")
@@index([city, countryCode])
@@index([sequence])
@@schema("passenger")
@@ -541,6 +543,7 @@ model Booking {
promoCode String?
paidAt DateTime?
paymentReminderSentAt DateTime?
packageDepartureStationId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id])
@@ -548,6 +551,7 @@ model Booking {
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
package TravelPackage? @relation(fields: [packageId], references: [id])
priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id])
departureStation Station? @relation("BookingPackageDepartureStation", fields: [packageDepartureStationId], references: [id])
seats BookingSeat[]
paymentIntent PaymentIntent?
tickets Ticket[]
@@ -1479,10 +1483,12 @@ model PackageBooking {
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
packageDepartureStationId String?
package TravelPackage @relation(fields: [packageId], references: [id])
priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id])
passenger Passenger? @relation(fields: [passengerId], references: [id])
departureStation Station? @relation("PackageBookingDepartureStation", fields: [packageDepartureStationId], references: [id])
passengers PackageBookingPassenger[]
paymentIntent PackagePaymentIntent?

View File

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

View File

@@ -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,

View File

@@ -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 {

View File

@@ -144,21 +144,34 @@ export class GuestBookingService {
});
}
// Calculate fare
const primaryNationality = passengersData[0]?.nationality;
const baseFareMinor = await this.getBaseFare(
dto.scheduleId,
dto.seatClassId,
segmentRoute,
fullRoute,
primaryNationality,
dto.originStationId,
dto.destinationStationId,
);
// Calculate fare — package bookings use the fixed tier price, bypassing the fare engine
const isPackageOneway = !!dto.packageId && !!dto.priceTierId;
let baseFareMinor: number;
let paidChildrenCount: number;
let childUnitFare: number;
if (isPackageOneway) {
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } });
baseFareMinor = tier.priceMinor;
paidChildrenCount = childCount;
childUnitFare = Math.round(baseFareMinor * 0.1);
} else {
const primaryNationality = passengersData[0]?.nationality;
baseFareMinor = await this.getBaseFare(
dto.scheduleId,
dto.seatClassId,
segmentRoute,
fullRoute,
primaryNationality,
dto.originStationId,
dto.destinationStationId,
);
paidChildrenCount = Math.max(0, childCount - 1);
childUnitFare = baseFareMinor;
}
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
const childFareMinor = baseFareMinor * paidChildrenCount;
const childFareMinor = childUnitFare * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childFareMinor;
let discountMinor = 0;
@@ -217,6 +230,7 @@ export class GuestBookingService {
displayCurrency,
displayTotalMinor,
bookingType: 'ONE_WAY',
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
userAgent: dto.deviceId,
contactEmail: firstPassenger.email || null,
contactPhone: firstPassenger.phone || null,
@@ -231,7 +245,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0),
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
displayCurrency,
})),
},
@@ -258,7 +272,7 @@ export class GuestBookingService {
adultCount,
adultFareMinor,
childCount,
freeChildrenCount: Math.min(childCount, 1),
freeChildrenCount: isPackageOneway ? 0 : Math.min(childCount, 1),
paidChildrenCount,
childFareMinor,
totalBaseFareMinor,
@@ -366,18 +380,36 @@ export class GuestBookingService {
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
// Calculate fares for both legs
// Calculate fares for both legs — package bookings use the fixed tier price split across legs
const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
const primaryNationality = passengersData[0]?.nationality;
const isPackageRoundTrip = !!dto.packageId && !!dto.priceTierId;
let outboundBaseFare: number;
let returnBaseFare: number;
let paidChildrenCount: number;
let outboundChildUnitFare: number;
let returnChildUnitFare: number;
const [outboundBaseFare, returnBaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
]);
const paidChildrenCount = Math.max(0, childCount - 1);
const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount;
const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount;
if (isPackageRoundTrip) {
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } });
// tier.priceMinor is the full round-trip price per adult; split evenly across legs
const halfMinor = Math.round(tier.priceMinor / 2);
outboundBaseFare = halfMinor;
returnBaseFare = tier.priceMinor - halfMinor;
paidChildrenCount = childCount;
outboundChildUnitFare = Math.round(outboundBaseFare * 0.1);
returnChildUnitFare = Math.round(returnBaseFare * 0.1);
} else {
const primaryNationality = passengersData[0]?.nationality;
[outboundBaseFare, returnBaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
]);
paidChildrenCount = Math.max(0, childCount - 1);
outboundChildUnitFare = outboundBaseFare;
returnChildUnitFare = returnBaseFare;
}
const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount;
const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount;
const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
let discountMinor = 0;
@@ -423,6 +455,7 @@ export class GuestBookingService {
returnHoldId: dto.returnHoldId,
returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
userAgent: dto.deviceId,
contactEmail: passengersData[0]?.email || null,
contactPhone: passengersData[0]?.phone || null,
@@ -440,7 +473,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0),
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
displayCurrency,
})),
...passengersData.map((p) => ({
@@ -455,7 +488,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0),
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
displayCurrency,
})),
],
@@ -484,7 +517,7 @@ export class GuestBookingService {
returnBaseFareMinor: returnBaseFare,
adultCount,
childCount,
freeChildrenCount: Math.min(childCount, 1),
freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1),
paidChildrenCount,
combinedBaseFareMinor,
discountMinor,

View File

@@ -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) {

View File

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

View File

@@ -91,6 +91,15 @@ export class PackagesController {
return this.service.getBookingByRef(ref);
}
@Post('book')
@IsPublic()
@UseGuards(OptionalJwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Book a package (public or authenticated)' })
book(@Body() dto: BookPackageDto, @Request() req: any) {
return this.service.book(dto, req.user?.passengerId);
}
@Get(':id/booking-context')
@IsPublic()
@ApiOperation({ summary: 'Get booking context for self-service package booking' })
@@ -177,12 +186,4 @@ export class PackagesController {
return this.service.deleteTier(tierId);
}
@Post('book')
@IsPublic()
@UseGuards(OptionalJwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Book a package (public or authenticated)' })
book(@Body() dto: BookPackageDto, @Request() req: any) {
return this.service.book(dto, req.user?.passengerId);
}
}

View File

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

View File

@@ -6,6 +6,32 @@ import { Currency } from '@prisma/client';
import { BookingsService } from '../bookings/bookings.service';
import { GuestBookingService } from '../bookings/guest-booking.service';
/** Package-specific fare rules */
const PKG_MAX_ADULTS = 5;
const PKG_MAX_CHILDREN = 2;
const PKG_CHILD_FARE_RATIO = 0.1;
function calculatePackageFareBreakdown(
priceMinor: number,
isRoundTrip: boolean,
adultCount: number,
childCount: number,
) {
const multiplier = isRoundTrip ? 2 : 1;
const adultFareMinor = priceMinor * multiplier;
const childFareMinor = Math.round(adultFareMinor * PKG_CHILD_FARE_RATIO);
const totalMinor = adultCount * adultFareMinor + childCount * childFareMinor;
return { adultFareMinor, childFareMinor, totalMinor, multiplier };
}
function deriveAge(dateOfBirth: string | Date): number {
const today = new Date();
const dob = new Date(dateOfBirth);
let age = today.getFullYear() - dob.getFullYear();
if (today < new Date(today.getFullYear(), dob.getMonth(), dob.getDate())) age--;
return age;
}
function generateRef(): string {
return 'PKG-' + Array.from({ length: 6 }, () =>
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)],
@@ -37,18 +63,22 @@ export class PackagesService {
},
});
if (!pkg || pkg.status !== 'ACTIVE') throw new NotFoundException('Package not available');
const tier = pkg.priceTiers.find(t => t.id === tierId);
const tier = pkg.priceTiers.find((t) => t.id === tierId);
if (!tier) throw new NotFoundException('Price tier not found');
const passengerCount = adultCount + childCount;
if (passengerCount < 1) throw new BadRequestException('At least one passenger required');
if (adultCount < 1) throw new BadRequestException('At least one adult passenger required');
if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`);
if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`);
const passengerCount = adultCount + childCount;
const remaining = tier.availableSeats - tier.bookedSeats;
if (passengerCount > remaining)
throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`);
const totalMinor = tier.priceMinor * passengerCount;
const isRoundTrip = !!pkg.returnScheduleId;
const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown(
tier.priceMinor, isRoundTrip, adultCount, childCount,
);
// Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches
let seatClassId: string | null = null;
@@ -60,7 +90,6 @@ export class PackagesService {
);
if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; }
}
// Fallback: use the first coach assignment's coachTypeId if no match found
if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) {
const first = pkg.outboundSchedule.coachAssignments[0];
coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null;
@@ -77,14 +106,19 @@ export class PackagesService {
adultCount,
childCount,
passengerCount,
pricePerPassengerMinor: tier.priceMinor,
isRoundTrip,
pricePerAdultMinor: adultFareMinor,
pricePerChildMinor: childFareMinor,
childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`,
maxAdults: PKG_MAX_ADULTS,
maxChildren: PKG_MAX_CHILDREN,
totalMinor,
currency: tier.currency,
remainingSeats: remaining,
outboundSchedule: {
scheduleId: pkg.outboundScheduleId,
originStationId: pkg.originStationId,
destinationStationId: pkg.destinationStationId,
originStationId: pkg.outboundSchedule.originStationId,
destinationStationId: pkg.outboundSchedule.destinationStationId,
departureAt: pkg.outboundSchedule.departureAt,
arrivalAt: pkg.outboundSchedule.arrivalAt,
originStation: pkg.outboundSchedule.originStation,
@@ -92,12 +126,12 @@ export class PackagesService {
},
returnSchedule: pkg.returnSchedule ? {
scheduleId: pkg.returnScheduleId,
originStationId: pkg.destinationStationId,
destinationStationId: pkg.originStationId,
originStationId: pkg.returnSchedule.originStationId,
destinationStationId: pkg.returnSchedule.destinationStationId,
departureAt: pkg.returnSchedule.departureAt,
arrivalAt: pkg.returnSchedule.arrivalAt,
originStation: pkg.returnSchedule.destinationStation,
destinationStation: pkg.returnSchedule.originStation,
originStation: pkg.returnSchedule.originStation,
destinationStation: pkg.returnSchedule.destinationStation,
} : null,
includedServices: pkg.includedServices,
busTransferIncluded: pkg.busTransferIncluded,
@@ -170,7 +204,14 @@ export class PackagesService {
where: { id },
include: {
priceTiers: true,
outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } },
outboundSchedule: {
include: {
originStation: true,
destinationStation: true,
train: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
},
},
returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
@@ -297,13 +338,30 @@ export class PackagesService {
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
if (!tier) throw new NotFoundException('Price tier not found');
const passengerCount = dto.passengers.length;
// Derive adult/child counts from the passengers array (dateOfBirth-based)
let adultCount = 0, childCount = 0;
for (const p of dto.passengers) {
if (p.dateOfBirth && deriveAge(p.dateOfBirth) < 5) childCount++;
else adultCount++;
}
// Allow explicit override from mobile app (e.g. when dateOfBirth is not provided per passenger)
if (dto.adultCount !== undefined) adultCount = dto.adultCount;
if (dto.childCount !== undefined) childCount = dto.childCount;
if (adultCount < 1) throw new BadRequestException('At least one adult passenger required');
if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`);
if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`);
const passengerCount = adultCount + childCount;
const remaining = tier.availableSeats - tier.bookedSeats;
if (passengerCount > remaining) {
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`);
}
const totalMinor = tier.priceMinor * passengerCount;
const isRoundTrip = !!pkg.returnScheduleId;
const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown(
tier.priceMinor, isRoundTrip, adultCount, childCount,
);
const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB;
const displayTotalMinor =
displayCurrency !== Currency.ETB
@@ -354,7 +412,21 @@ export class PackagesService {
}),
]);
return booking;
return {
...booking,
fareBreakdown: {
isRoundTrip,
adultCount,
adultFareMinor,
childCount,
childFareMinor,
childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
},
};
}
getMyBookings(passengerId: string) {

View File

@@ -50,8 +50,8 @@ export class CreateScheduleDto {
{ sequence: 6, plannedArrivalAt: '2026-06-15T20:00:00Z' },
],
})
@IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes: PlannedStopTimeDto[];
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
}
export class UpdateScheduleDto {

View File

@@ -153,7 +153,7 @@ export class SchedulesService {
});
}
const providedSeqs = new Set(plannedTimes.map(t => t.sequence));
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq));
if (missingSeqs.length > 0) {
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);

View File

@@ -61,11 +61,12 @@ export class SeatsService {
const resolvedBedPosition = isBedCoach
? this.resolveBedPosition(s.col, s.bedPosition)
: s.bedPosition;
const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
return {
id: s.id,
seatNumber: s.seatNumber,
label: s.seatNumber,
status: effectiveStatuses.get(s.id) ?? s.status,
status: effectiveStatus,
kind: s.kind,
row: s.row,
col: s.col,
@@ -245,11 +246,16 @@ export class SeatsService {
holdFrom === undefined || holdTo === undefined ||
(holdFrom < reqTo && reqFrom < holdTo);
if (!legsOverlap) continue;
// Check direction conflict
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
if (!directionsConflict) continue;
if (!legsOverlap || !directionsConflict) {
// This hold does not conflict with the requested leg/direction.
// Explicitly mark AVAILABLE so the DB's HELD status (set by the
// opposing-direction hold) does not bleed through via the fallback.
if (!statusMap.has(seatId)) statusMap.set(seatId, 'AVAILABLE');
continue;
}
statusMap.set(seatId, 'HELD');
}
@@ -653,7 +659,7 @@ export class SeatsService {
const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-'));
const totalSeats = seats.length;
const unavailable = seats.filter(s => {
const status = effectiveStatuses.get(s.id) ?? s.status;
const status = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED';
}).length;
@@ -665,8 +671,8 @@ export class SeatsService {
seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [],
totalSeats,
availableSeats: totalSeats - unavailable,
heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'HELD').length,
bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'BOOKED').length,
heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'HELD').length,
bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'BOOKED').length,
};
});
}
@@ -861,11 +867,21 @@ export class SeatsService {
if (expired.length === 0) return;
const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]);
// Only reset seats that are still HELD — BOOKED seats have been confirmed and must not be touched.
await this.prisma.seat.updateMany({
where: { id: { in: expiredSeatIds }, status: 'HELD' },
data: { status: 'AVAILABLE' },
// Only reset seats that have no remaining active holds
const stillHeld = await this.prisma.seatHold.findMany({
where: { expiresAt: { gte: new Date() }, seatIds: { hasSome: expiredSeatIds } },
select: { seatIds: true },
});
const stillHeldIds = new Set(stillHeld.flatMap(h => h.seatIds as string[]));
const toRelease = expiredSeatIds.filter(id => !stillHeldIds.has(id));
if (toRelease.length > 0) {
await this.prisma.seat.updateMany({
where: { id: { in: toRelease }, status: 'HELD' },
data: { status: 'AVAILABLE' },
});
}
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
}
}

View File

@@ -1,238 +0,0 @@
/**
* SEGMENT-BASED SEAT RESERVATION EXAMPLE
*
* Demonstrates the complete flow for booking Addis Ababa → Dire Dawa
* on the Addis Ababa → Djibouti route with segment-based seat management.
*
* Route: Addis Ababa (seq:1) → Adama (seq:2) → Awash (seq:3) → Dire Dawa (seq:4) → Aysha (seq:5) → Djibouti (seq:6)
* Booking: Addis Ababa → Dire Dawa (segments: 1→2, 2→3, 3→4)
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function exampleBookingFlow() {
console.log('=== SEGMENT-BASED BOOKING FLOW ===\n');
const scheduleId = 'schedule_add_dji_001';
const passengerId = 'passenger_kelemu';
const seatIds = ['seat_coach_a_1a', 'seat_coach_a_1b'];
const originStationId = 'st_ADD';
const destinationStationId = 'st_DRE';
try {
console.log('1. Checking seat availability...');
const segments = await getJourneySegments(scheduleId, originStationId, destinationStationId);
console.log('Journey segments:', segments.map(s => `${s.fromName}${s.toName}`));
console.log('\n2. Holding seats...');
const holdResult = await holdSeatsTransaction(scheduleId, seatIds, passengerId, originStationId, destinationStationId);
console.log('Hold created:', holdResult);
console.log('\n3. Processing payment...');
await new Promise(resolve => setTimeout(resolve, 5000));
console.log('\n4. Confirming booking...');
const bookingId = 'booking_' + Date.now();
const confirmResult = await confirmBookingTransaction(holdResult.holdId, bookingId, segments);
console.log('Booking confirmed:', confirmResult);
console.log('\n5. Simulating trip progress...');
await simulateTripProgress(scheduleId, segments);
} catch (error) {
console.error('Booking flow error:', error);
}
}
async function getJourneySegments(scheduleId: string, originStationId: string, destinationStationId: string) {
const stopTimes = await prisma.tripStopTime.findMany({
where: { scheduleId },
include: { station: true },
orderBy: { sequence: 'asc' },
});
const originStop = stopTimes.find(st => st.stationId === originStationId);
const destinationStop = stopTimes.find(st => st.stationId === destinationStationId);
if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) {
throw new Error('Invalid origin/destination');
}
const segments = [];
for (let i = originStop.sequence; i < destinationStop.sequence; i++) {
const fromStop = stopTimes.find(st => st.sequence === i);
const toStop = stopTimes.find(st => st.sequence === i + 1);
if (fromStop && toStop) {
segments.push({
fromStationId: fromStop.stationId,
toStationId: toStop.stationId,
fromSequence: fromStop.sequence,
toSequence: toStop.sequence,
fromName: fromStop.station.name,
toName: toStop.station.name,
});
}
}
return segments;
}
async function holdSeatsTransaction(scheduleId: string, seatIds: string[], passengerId: string, originStationId: string, destinationStationId: string) {
return prisma.$transaction(async (tx) => {
console.log(' → Starting seat hold transaction...');
const seats = await tx.seat.findMany({ where: { id: { in: seatIds } }, include: { coach: true } });
if (seats.length !== seatIds.length) throw new Error('Some seats not found');
for (const seat of seats) {
if (seat.status !== 'AVAILABLE') {
throw new Error(`Seat ${seat.seatNumber} is not available (status: ${seat.status})`);
}
}
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
const seatHold = await tx.seatHold.create({
data: { scheduleId, seatIds, passengerId, expiresAt },
});
await tx.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
console.log(' → Seats held successfully');
return { holdId: seatHold.id, expiresAt, seats: seatIds.length };
});
}
async function confirmBookingTransaction(holdId: string, bookingId: string, segments: any[]) {
return prisma.$transaction(async (tx) => {
console.log(' → Starting booking confirmation transaction...');
const hold = await tx.seatHold.findUnique({ where: { id: holdId } });
if (!hold || hold.expiresAt < new Date()) throw new Error('Hold expired or not found');
const booking = await tx.booking.create({
data: {
id: bookingId,
bookingRef: 'BK' + Date.now().toString().slice(-6),
passengerId: hold.passengerId,
scheduleId: hold.scheduleId,
status: 'CONFIRMED',
totalMinor: 45000,
currency: 'ETB',
},
});
const journey = await tx.journey.create({
data: { passengerId: hold.passengerId, status: 'CONFIRMED', totalMinor: 45000, currency: 'ETB' },
});
for (const seatId of hold.seatIds) {
for (let i = 0; i < segments.length; i++) {
await tx.journeySegment.create({
data: {
journeyId: journey.id,
scheduleId: hold.scheduleId,
segmentOrder: i + 1,
seatId,
departureStationId: segments[i].fromStationId,
arrivalStationId: segments[i].toStationId,
},
});
}
}
for (const seatId of hold.seatIds) {
await tx.bookingSeat.create({ data: { bookingId, seatId, passengerName: 'Kelemu Ketsela' } });
}
await tx.seat.updateMany({ where: { id: { in: hold.seatIds } }, data: { status: 'BOOKED', heldUntil: null } });
await tx.seatHold.delete({ where: { id: holdId } });
console.log(' → Booking confirmed successfully');
return { bookingId, bookingRef: booking.bookingRef, confirmedSeats: hold.seatIds.length, segments: segments.length };
});
}
async function simulateTripProgress(scheduleId: string, bookedSegments: any[]) {
console.log(' → Simulating trip progress...');
for (const segment of bookedSegments) {
console.log(` → Train approaching ${segment.toName}...`);
await prisma.tripLiveStatus.upsert({
where: { scheduleId },
update: { currentLocationLabel: segment.toName, progressPercent: Math.round((segment.toSequence / 4) * 100) },
create: {
scheduleId,
state: 'EN_ROUTE',
currentLocationLabel: segment.toName,
progressPercent: Math.round((segment.toSequence / 4) * 100),
delayMinutes: 0,
},
});
if (segment.toName === 'Dire Dawa') {
console.log(' → Passengers reached destination, releasing seats...');
await releaseSeatsAtStation(scheduleId, segment.toStationId);
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
async function releaseSeatsAtStation(scheduleId: string, stationId: string) {
return prisma.$transaction(async (tx) => {
const completedSegments = await tx.journeySegment.findMany({
where: { scheduleId, arrivalStationId: stationId },
include: { journey: { include: { journeySegments: { where: { scheduleId } } } } },
});
const seatsToRelease: string[] = [];
for (const segment of completedSegments) {
const passengerSegments = segment.journey.journeySegments.filter((js: any) => js.seatId === segment.seatId);
const maxOrder = Math.max(...passengerSegments.map((js: any) => js.segmentOrder));
if (segment.segmentOrder === maxOrder) seatsToRelease.push(segment.seatId!);
}
if (seatsToRelease.length > 0) {
await tx.seat.updateMany({ where: { id: { in: seatsToRelease } }, data: { status: 'AVAILABLE' } });
console.log(` → Released ${seatsToRelease.length} seats at station`);
}
return seatsToRelease;
});
}
async function checkOverlappingReservations(tx: any, scheduleId: string, seatId: string, segments: any[]) {
const activeHolds = await tx.seatHold.findMany({
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
});
const activeBookings = await tx.journeySegment.findMany({
where: {
scheduleId,
seatId,
journey: { status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } },
},
});
return [...activeHolds, ...activeBookings];
}
if (require.main === module) {
exampleBookingFlow()
.then(() => console.log('\n=== EXAMPLES COMPLETED ==='))
.catch(console.error)
.finally(() => prisma.$disconnect());
}
export {
exampleBookingFlow,
getJourneySegments,
holdSeatsTransaction,
confirmBookingTransaction,
simulateTripProgress,
releaseSeatsAtStation,
checkOverlappingReservations,
};

View File

@@ -0,0 +1,49 @@
import {
Controller, Get, Post, Put, Delete,
Param, Body, Query, UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { IamGuard } from '../../common/iam-adapter';
import { Roles } from '../../common/roles.decorator';
import { SegmentFareService } from './segment-fare.service';
import { CreateSegmentFareDto, UpdateSegmentFareDto } from './segment-fare.dto';
@ApiTags('Admin Segment Fares')
@Controller('admin/segment-fares')
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN', 'SUPERVISOR')
export class SegmentFareController {
constructor(private readonly service: SegmentFareService) {}
@Get()
@ApiOperation({ summary: 'List all segment fare rules, optionally filtered by route' })
@ApiQuery({ name: 'routeId', required: false })
findAll(@Query('routeId') routeId?: string) {
return this.service.findAll(routeId);
}
@Get(':id')
@ApiOperation({ summary: 'Get a single segment fare rule' })
findOne(@Param('id') id: string) {
return this.service.findOne(id);
}
@Post()
@ApiOperation({ summary: 'Create a segment fare rule' })
create(@Body() dto: CreateSegmentFareDto) {
return this.service.create(dto);
}
@Put(':id')
@ApiOperation({ summary: 'Update a segment fare rule' })
update(@Param('id') id: string, @Body() dto: UpdateSegmentFareDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a segment fare rule' })
remove(@Param('id') id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,67 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString, IsInt, IsOptional, IsDateString, IsIn, Min,
} from 'class-validator';
export class CreateSegmentFareDto {
@ApiProperty({ example: 'route-uuid' })
@IsString()
routeId: string;
@ApiProperty({ example: 1 })
@IsInt() @Min(0)
originStopSequence: number;
@ApiProperty({ example: 5 })
@IsInt() @Min(1)
destinationStopSequence: number;
@ApiProperty({ example: 'seat-class-uuid' })
@IsString()
seatClassId: string;
@ApiProperty({ example: 35000, description: 'Base fare in minor units (e.g. 350.00 ETB = 35000)' })
@IsInt() @Min(0)
baseFareMinor: number;
@ApiPropertyOptional({ example: 'LOCAL', enum: ['LOCAL', 'INTERNATIONAL'] })
@IsOptional()
@IsIn(['LOCAL', 'INTERNATIONAL'])
nationality?: string;
@ApiPropertyOptional({ example: 'ETB', default: 'ETB' })
@IsOptional()
@IsString()
currency?: string;
@ApiProperty({ example: '2025-01-01T00:00:00.000Z' })
@IsDateString()
validFrom: string;
@ApiPropertyOptional({ example: '2026-12-31T23:59:59.000Z' })
@IsOptional()
@IsDateString()
validUntil?: string;
}
export class UpdateSegmentFareDto {
@ApiPropertyOptional({ example: 40000 })
@IsOptional()
@IsInt() @Min(0)
baseFareMinor?: number;
@ApiPropertyOptional({ example: 'ETB' })
@IsOptional()
@IsString()
currency?: string;
@ApiPropertyOptional({ example: '2025-06-01T00:00:00.000Z' })
@IsOptional()
@IsDateString()
validFrom?: string;
@ApiPropertyOptional({ example: '2026-12-31T23:59:59.000Z' })
@IsOptional()
@IsDateString()
validUntil?: string;
}

View File

@@ -0,0 +1,62 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateSegmentFareDto, UpdateSegmentFareDto } from './segment-fare.dto';
@Injectable()
export class SegmentFareService {
constructor(private readonly prisma: PrismaService) {}
findAll(routeId?: string) {
return this.prisma.segmentFareRule.findMany({
where: routeId ? { routeId } : undefined,
include: { seatClass: true, route: { select: { id: true, code: true, name: true } } },
orderBy: [{ routeId: 'asc' }, { originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }],
});
}
async findOne(id: string) {
const rule = await this.prisma.segmentFareRule.findUnique({
where: { id },
include: { seatClass: true, route: { select: { id: true, code: true, name: true } } },
});
if (!rule) throw new NotFoundException(`SegmentFareRule ${id} not found`);
return rule;
}
create(dto: CreateSegmentFareDto) {
return this.prisma.segmentFareRule.create({
data: {
routeId: dto.routeId,
originStopSequence: dto.originStopSequence,
destinationStopSequence: dto.destinationStopSequence,
seatClassId: dto.seatClassId,
baseFareMinor: dto.baseFareMinor,
nationality: dto.nationality ?? null,
currency: dto.currency ?? 'ETB',
validFrom: new Date(dto.validFrom),
validUntil: dto.validUntil ? new Date(dto.validUntil) : null,
},
include: { seatClass: true },
});
}
async update(id: string, dto: UpdateSegmentFareDto) {
await this.findOne(id);
return this.prisma.segmentFareRule.update({
where: { id },
data: {
...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }),
...(dto.currency !== undefined && { currency: dto.currency }),
...(dto.validFrom !== undefined && { validFrom: new Date(dto.validFrom) }),
...(dto.validUntil !== undefined && { validUntil: new Date(dto.validUntil) }),
},
include: { seatClass: true },
});
}
async remove(id: string) {
await this.findOne(id);
await this.prisma.segmentFareRule.delete({ where: { id } });
return { deleted: true };
}
}

View File

@@ -3,20 +3,24 @@ import { SegmentsService } from './segments.service';
import { EnhancedSeatsService } from './enhanced-seats.service';
import { 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 {}

View File

@@ -0,0 +1,109 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
const SEED_FLAG = 'SEED_SEGMENT_FARES';
/**
* Seeds SegmentFareRule rows for every origin→destination pair on the
* Addis AbabaDjibouti route across all active seat classes.
*
* Skip-if-loaded: uses Prisma upsert on the unique constraint
* (routeId, originStopSequence, destinationStopSequence, seatClassId, nationality).
* Re-running is safe — existing rows are updated in-place.
*/
@Injectable()
export class SegmentFareSeeder {
private readonly logger = new Logger(SegmentFareSeeder.name);
constructor(private readonly prisma: PrismaService) {}
async run() {
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
this.logger.log(`Skipping segment fare seed — set ${SEED_FLAG}=true to enable`);
return;
}
const route = await this.prisma.route.findFirst({
where: { active: true },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
if (!route) {
this.logger.warn('No active route found — skipping segment fare seed');
return;
}
const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true },
});
if (!seatClasses.length) {
this.logger.warn('No active seat classes found — skipping segment fare seed');
return;
}
const stops = route.stops;
const validFrom = new Date('2025-01-01T00:00:00.000Z');
// Rate table: ETB minor units per km, keyed by (nationalityType, bedPosition)
// null bedPosition = regular seat
const rateTable: Record<string, Record<string | 'null', number>> = {
LOCAL: { null: 3000, UPPER: 4000, MIDDLE: 5500, LOWER: 6000 },
INTERNATIONAL: { null: 6000, UPPER: 8000, MIDDLE: 11000, LOWER: 12000 },
};
let upserted = 0;
for (const seatClass of seatClasses) {
const natType = seatClass.nationalityType ?? 'LOCAL';
const bedPos = seatClass.bedPosition ?? 'null';
const ratePerKm = rateTable[natType]?.[bedPos] ?? rateTable['LOCAL']['null'];
for (let i = 0; i < stops.length - 1; i++) {
for (let j = i + 1; j < stops.length; j++) {
const origin = stops[i];
const dest = stops[j];
// Approximate distance: sum of per-stop distanceKm if available,
// otherwise fall back to sequence-gap × 50 km.
let distanceKm = 0;
for (let k = i; k < j; k++) {
distanceKm += stops[k + 1].distanceKm ?? 50;
}
const baseFareMinor = Math.round(distanceKm * ratePerKm);
await this.prisma.segmentFareRule.upsert({
where: {
routeId_originStopSequence_destinationStopSequence_seatClassId_nationality: {
routeId: route.id,
originStopSequence: origin.sequence,
destinationStopSequence: dest.sequence,
seatClassId: seatClass.id,
nationality: natType,
},
},
update: { baseFareMinor, validFrom },
create: {
routeId: route.id,
originStopSequence: origin.sequence,
destinationStopSequence: dest.sequence,
seatClassId: seatClass.id,
nationality: natType,
baseFareMinor,
currency: 'ETB',
validFrom,
},
});
upserted++;
}
}
}
this.logger.log(
`Segment fare seed complete — ${upserted} rules upserted ` +
`(${stops.length} stops × ${seatClasses.length} seat classes)`,
);
}
}

View File

@@ -165,7 +165,7 @@ export default function AppReleasesPage() {
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setFormOpen(false)}>Cancel</ActionButton>
<ActionButton type="submit" isLoading={saveMutation.isPending}>
<ActionButton type="submit" loading={saveMutation.isPending}>
{editing ? 'Save Changes' : 'Create Release'}
</ActionButton>
</div>

View File

@@ -13,7 +13,7 @@ import { usePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { bookingsApi, apiClient } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { formatCurrency, formatDateTime, formatDateTimeShort } from '@/lib/utils';
import { BookingFilters } from '@/types';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
@@ -150,10 +150,39 @@ function BookingsPageContent() {
</span>
)}
</div>
<div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
{booking.isPackageBooking
? <div className="text-xs text-muted-foreground">Boarding at: {booking.departureStationName || booking.schedule?.originStation?.name || '—'}</div>
: <div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
}
</div>
),
},
{
key: 'trip',
label: 'Trip',
render: (booking: any) => {
const isRoundTrip = booking?.bookingType === 'ROUND_TRIP' || booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const returnDeparture = booking?.returnSchedule?.departureAt;
console.log(JSON.stringify(booking.packageId));
return (
<div>
<div className="font-medium">
{booking.schedule?.originStation?.name || 'N/A'} {booking.schedule?.destinationStation?.name || 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
{!isRoundTrip ? (
<span>{booking.schedule?.departureAt ? formatDateTimeShort(booking.schedule.departureAt) : 'N/A'}</span>
) : (
<span>
{booking.schedule?.departureAt ? formatDateTimeShort(booking.schedule.departureAt) : 'N/A'} ·
{returnDeparture ? formatDateTimeShort(returnDeparture) : ''}
</span>
)}
</div>
</div>
);
},
},
{
key: 'passengerNames', label: 'Names',
render: (booking: any) => {
@@ -200,14 +229,6 @@ function BookingsPageContent() {
</div>
),
},
{
key: 'passengerCount', label: 'Passengers',
render: (booking: any) => {
const adults = booking.adultCount || 0, children = booking.childCount || 0;
if (!adults && !children) return '—';
return <><div>Adult: {adults}</div><div className="text-sm text-muted-foreground">Child: {children}</div></>;
},
},
{
key: 'paymentStatus', label: 'Payment',
render: (booking: any) => (

View File

@@ -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"

View File

@@ -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" />

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import '@/styles/globals.css';
import Providers from './providers';
@@ -12,10 +13,13 @@ export default function RootLayout({
}: {
children: React.ReactNode;
}) {
// Nonce set per-request by middleware; required for this inline script under the CSP.
const nonce = headers().get('x-nonce') ?? undefined;
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `
(function() {

View File

@@ -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"

View File

@@ -9,6 +9,7 @@ import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
import Image from 'next/image';
import { ticketsApi, apiClient, stationsApi, excessBaggageApi } from '@/lib/api';
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
@@ -241,10 +242,14 @@ export default function TicketsPage() {
'</div>' +
'</div>' +
'</div>' +
'<script>window.onload=function(){window.print();window.onafterprint=function(){window.close()};}<\/script>' +
'</body></html>'
);
w.document.close();
// Drive printing from the opener rather than an inline <script> in the popup — the
// about:blank window inherits this page's CSP, which blocks non-nonced inline scripts.
w.focus();
w.onafterprint = () => w.close();
w.print();
};
const handleDeleteClick = (ticket: any) => {
@@ -844,10 +849,13 @@ export default function TicketsPage() {
<SectionHeader title="QR Code" />
<div className="flex justify-center">
<div className="bg-white p-4 rounded-xl border border-muted inline-block">
<img
<Image
src={t.qrCode.startsWith('data:') ? t.qrCode : `data:image/png;base64,${t.qrCode}`}
alt={`QR Code for ${t.ticketNumber}`}
className="w-48 h-48 object-contain"
width={192}
height={192}
className="object-contain"
unoptimized
/>
<p className="text-center text-xs text-muted-foreground mt-2 font-mono">{t.ticketNumber}</p>
</div>

View File

@@ -71,7 +71,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'Tourism',
items: [
{ name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin },
// { name: 'Pkg Bookings', href: '/package-bookings', icon: Ticket, permission: PERMS.admin },
// { name: 'Bookings', href: '/package-bookings', icon: Ticket, permission: PERMS.admin },
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin },
]
},

View File

@@ -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`),

View File

@@ -2,21 +2,93 @@ import { NextRequest, NextResponse } from 'next/server';
const PUBLIC_PATHS = ['/login', '/reset-password'];
/**
* Build the Content-Security-Policy for a single request.
*
* Production uses a strict, nonce-based policy with `strict-dynamic`: only scripts
* carrying this request's nonce (and scripts they load) may execute, which neutralises
* reflected/stored XSS regardless of any host allowlist. Next.js applies the nonce to
* its own bootstrap/chunk scripts automatically because middleware forwards it on the
* request `Content-Security-Policy` header (see below); our own inline scripts read it
* from the `x-nonce` request header in the root layout.
*
* `strict-dynamic` also covers the jsQR script the boarding scanner injects at runtime
* (a trusted script's dynamically-created <script> is allowed), so no CDN host needs
* allowlisting.
*
* Development relaxes `script-src` (Next.js HMR/react-refresh needs `unsafe-eval` and
* inline) and allows the HMR websocket, and drops `upgrade-insecure-requests` so
* plain-HTTP localhost keeps working.
*/
function buildCsp(nonce: string): string {
const isProd = process.env.NODE_ENV === 'production';
// Origin the browser calls for API/XHR/fetch — must be allowed in connect-src.
let apiOrigin = '';
try {
apiOrigin = new URL(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000').origin;
} catch {
apiOrigin = '';
}
const scriptSrc = isProd
? `'self' 'nonce-${nonce}' 'strict-dynamic'`
: `'self' 'unsafe-inline' 'unsafe-eval'`;
const connectSrc = isProd
? `'self' ${apiOrigin}`.trim()
: `'self' ${apiOrigin} ws: wss:`.trim();
const directives = [
`default-src 'self'`,
`base-uri 'self'`,
`script-src ${scriptSrc}`,
// Inline styles (Tailwind runtime + React `style=` attributes) can't execute JS;
// nonce-ing them reliably breaks Next/React, so 'unsafe-inline' is the accepted stance.
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: blob: ${apiOrigin}`.trim(),
`font-src 'self' data:`,
`connect-src ${connectSrc}`,
`worker-src 'self' blob:`,
`frame-src 'self'`,
`object-src 'none'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
...(isProd ? ['upgrade-insecure-requests'] : []),
];
return directives.join('; ');
}
export function middleware(request: NextRequest) {
const nonce = btoa(crypto.randomUUID());
const csp = buildCsp(nonce);
// Forward the nonce + CSP on the *request* so Next.js nonces its own scripts and our
// layout can read `x-nonce`. The browser-enforced copy is set on the response below.
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('Content-Security-Policy', csp);
const render = () => NextResponse.next({ request: { headers: requestHeaders } });
const { pathname } = request.nextUrl;
let response: NextResponse;
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
response = render();
} else {
// Token is stored in localStorage (client-side only), so middleware can't
// read it directly. We use a cookie set on login as the server-side signal.
const token = request.cookies.get('auth_token')?.value;
response = token ? render() : NextResponse.redirect(new URL('/login', request.url));
}
// Token is stored in localStorage (client-side only), so middleware can't
// read it directly. We use a cookie set on login as the server-side signal.
const token = request.cookies.get('auth_token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
response.headers.set('Content-Security-Policy', csp);
// Anti-clickjacking. `frame-ancestors 'none'` (in the CSP above) is the modern control;
// X-Frame-Options: DENY is the legacy equivalent for older browsers and scanners.
response.headers.set('X-Frame-Options', 'DENY');
return response;
}
export const config = {

View File

@@ -1,100 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Routes API</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
button { padding: 10px 20px; margin: 10px 0; cursor: pointer; }
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
.route { border: 1px solid #ddd; padding: 10px; margin: 10px 0; border-radius: 5px; }
</style>
</head>
<body>
<h1>Routes API Test</h1>
<button onclick="fetchRoutes()">Fetch All Routes</button>
<button onclick="deleteRoute()">Delete ADD-DDW Route</button>
<div id="output"></div>
<script>
const API_URL = 'http://localhost:4000';
const TOKEN = localStorage.getItem('token') || 'YOUR_JWT_TOKEN_HERE';
async function fetchRoutes() {
try {
const response = await fetch(`${API_URL}/routes`, {
headers: {
'Authorization': `Bearer ${TOKEN}`
}
});
const data = await response.json();
console.log('Routes response:', data);
const output = document.getElementById('output');
output.innerHTML = '<h2>Routes Found:</h2>';
if (Array.isArray(data)) {
output.innerHTML += `<p>Total routes: ${data.length}</p>`;
data.forEach(route => {
output.innerHTML += `
<div class="route">
<strong>${route.code}</strong> - ${route.name}<br>
<small>ID: ${route.id}</small><br>
<small>Active: ${route.active}</small><br>
<small>Stops: ${route._count?.stops || route.stops?.length || 0}</small>
</div>
`;
});
} else {
output.innerHTML += '<pre>' + JSON.stringify(data, null, 2) + '</pre>';
}
} catch (error) {
document.getElementById('output').innerHTML =
'<p style="color: red;">Error: ' + error.message + '</p>';
console.error('Error:', error);
}
}
async function deleteRoute() {
const code = 'ADD-DDW';
try {
// First fetch to get the route ID
const listResponse = await fetch(`${API_URL}/routes`, {
headers: { 'Authorization': `Bearer ${TOKEN}` }
});
const routes = await listResponse.json();
const route = routes.find(r => r.code === code);
if (!route) {
alert('Route ADD-DDW not found');
return;
}
if (confirm(`Delete route ${route.code} - ${route.name}?`)) {
const response = await fetch(`${API_URL}/routes/${route.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${TOKEN}`
}
});
if (response.ok) {
alert('Route deleted successfully');
fetchRoutes();
} else {
const error = await response.json();
alert('Error: ' + JSON.stringify(error));
}
}
} catch (error) {
alert('Error: ' + error.message);
console.error('Error:', error);
}
}
// Auto-fetch on load
fetchRoutes();
</script>
</body>
</html>

View File

@@ -1,132 +0,0 @@
// Test script for Stations CRUD operations
// Run this in the browser console on the backoffice app
async function testStationsCRUD() {
const API_URL = 'http://localhost:4000';
const token = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
};
console.log('🧪 Testing Stations CRUD Operations...\n');
try {
// 1. CREATE - Add a new station
console.log('1⃣ Testing CREATE Station...');
const newStation = {
code: 'TEST',
name: 'Test Station',
city: 'Test City',
countryCode: 'ET',
lat: '9.0320',
lng: '38.7469',
timezone: 'Africa/Addis_Ababa',
isOperational: true
};
const createResponse = await fetch(`${API_URL}/stations`, {
method: 'POST',
headers,
body: JSON.stringify(newStation)
});
if (!createResponse.ok) {
throw new Error(`CREATE failed: ${createResponse.status} ${await createResponse.text()}`);
}
const createdStation = await createResponse.json();
console.log('✅ Station created:', createdStation);
const stationId = createdStation.id || createdStation.data?.id;
if (!stationId) {
throw new Error('No station ID returned from create');
}
// 2. READ - Get the created station
console.log('\n2⃣ Testing READ Station...');
const readResponse = await fetch(`${API_URL}/stations/${stationId}`, {
method: 'GET',
headers
});
if (!readResponse.ok) {
throw new Error(`READ failed: ${readResponse.status}`);
}
const readStation = await readResponse.json();
console.log('✅ Station retrieved:', readStation);
// 3. UPDATE - Modify the station
console.log('\n3⃣ Testing UPDATE Station...');
const updateData = {
name: 'Test Station Updated',
city: 'Test City Updated',
isOperational: false
};
const updateResponse = await fetch(`${API_URL}/stations/${stationId}`, {
method: 'PATCH',
headers,
body: JSON.stringify(updateData)
});
if (!updateResponse.ok) {
throw new Error(`UPDATE failed: ${updateResponse.status} ${await updateResponse.text()}`);
}
const updatedStation = await updateResponse.json();
console.log('✅ Station updated:', updatedStation);
// 4. LIST - Get all stations
console.log('\n4⃣ Testing LIST Stations...');
const listResponse = await fetch(`${API_URL}/stations`, {
method: 'GET',
headers
});
if (!listResponse.ok) {
throw new Error(`LIST failed: ${listResponse.status}`);
}
const stations = await listResponse.json();
console.log('✅ Stations list retrieved:', stations);
// 5. DELETE - Remove the test station
console.log('\n5⃣ Testing DELETE Station...');
const deleteResponse = await fetch(`${API_URL}/stations/${stationId}`, {
method: 'DELETE',
headers
});
if (!deleteResponse.ok) {
throw new Error(`DELETE failed: ${deleteResponse.status} ${await deleteResponse.text()}`);
}
console.log('✅ Station deleted successfully');
// 6. Verify deletion
console.log('\n6⃣ Verifying deletion...');
const verifyResponse = await fetch(`${API_URL}/stations/${stationId}`, {
method: 'GET',
headers
});
if (verifyResponse.status === 404) {
console.log('✅ Station deletion verified (404 Not Found)');
} else {
console.warn('⚠️ Station might still exist');
}
console.log('\n🎉 All tests passed!');
return { success: true, message: 'All CRUD operations working correctly' };
} catch (error) {
console.error('❌ Test failed:', error);
return { success: false, error: error.message };
}
}
// Run the test
testStationsCRUD();

View File

@@ -37,7 +37,6 @@ export default function ConfirmationPage() {
try {
return await apiClient.get(`/bookings/${bookingId}`);
} catch (error) {
console.log('Booking API not available, using local data');
return {
id: bookingId || '',
pnr: pnr || undefined,
@@ -57,13 +56,9 @@ export default function ConfirmationPage() {
// For other payment methods, ticket is generated by the payment webhook after payment completes
apiClient.get(`/bookings/${bookingId}`).then((data: any) => {
if (data?.status === 'CONFIRMED') {
apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => {
console.error('Failed to generate ticket:', err);
});
apiClient.post(`/tickets/generate/${bookingId}`).catch(() => {});
}
}).catch((err) => {
console.error('Failed to fetch booking status:', err);
});
}).catch(() => {});
}
}, [bookingId]);
@@ -142,7 +137,6 @@ export default function ConfirmationPage() {
if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400));
}
} catch (error) {
console.error('❌ Failed to generate voucher:', error);
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsGeneratingVoucher(false);
@@ -159,12 +153,12 @@ export default function ConfirmationPage() {
const handleNewBooking = () => {
clearBooking();
router.push('/booking/search');
window.location.href = '/';
};
useEffect(() => {
if (!bookingId || !pnr) {
router.push('/booking/search');
window.location.href = '/';
}
}, [bookingId, pnr, router]);
@@ -376,19 +370,19 @@ export default function ConfirmationPage() {
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.nationality}</p>
</div>
<div>
<p className="text-gray-600 dark:text-gray-400">Seat</p>
<p className="text-gray-600 dark:text-gray-400">Seat(s)</p>
{isRoundTrip ? (
<div className="space-y-0.5">
<p className="font-semibold text-gray-900 dark:text-gray-100">
Outbound: {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'}{(passenger as any).outboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {(passenger as any).outboundCoachNumber})</span>}
Outbound: {(passenger as any).outboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).outboundCoachNumber}</span>} {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'}
</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
Return: {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'}{(passenger as any).inboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {(passenger as any).inboundCoachNumber})</span>}
Return: {(passenger as any).inboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).inboundCoachNumber}</span>} {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'}
</p>
</div>
) : (
<p className="font-semibold text-gray-900 dark:text-gray-100">
{passenger.seatNumber || 'Auto-assigned at boarding'}{passenger.coachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {passenger.coachNumber})</span>}
{passenger.coachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {passenger.coachNumber})</span>} {passenger.seatNumber || 'Auto-assigned at boarding'}
</p>
)}
</div>

View File

@@ -34,9 +34,7 @@ function BookingDetailContent() {
queryKey: ['booking-detail', bookingRef],
queryFn: async () => {
if (!bookingRef) throw new Error('No booking reference provided');
console.log('🔍 Fetching Booking:', bookingRef);
const response = await apiClient.get(`/bookings/${bookingRef}`);
console.log('✅ Booking Response:', response);
// Handle wrapped response
return (response as any)?.data || response;
},
@@ -56,7 +54,6 @@ function BookingDetailContent() {
return response;
},
onSuccess: async (data: any) => {
console.log('Payment intent created:', data);
await apiClient.patch(`/bookings/${booking?.id}/confirm`, {
paymentIntentId: data.id,
paymentMethod: selectedPaymentMethod,
@@ -64,7 +61,6 @@ function BookingDetailContent() {
refetch();
},
onError: (error: any) => {
console.error('Payment failed:', error);
alert(error?.response?.data?.message || 'Payment failed. Please try again.');
},
});
@@ -99,11 +95,9 @@ function BookingDetailContent() {
setIsGeneratingVoucher(true);
try {
console.log('📄 Generating voucher for booking:', booking);
const { generateVoucherPDF } = await import('@/lib/generate-voucher');
await generateVoucherPDF(booking as any);
} catch (error) {
console.error('Failed to generate voucher:', error);
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsGeneratingVoucher(false);
@@ -148,11 +142,6 @@ function BookingDetailContent() {
const isExpired = booking.status === 'EXPIRED';
const isCancelled = booking.status === 'CANCELLED';
console.log('📊 Booking Status:', booking.status);
console.log('📊 isPendingPayment:', isPendingPayment);
console.log('📊 isConfirmed:', isConfirmed);
console.log('📊 isExpired:', isExpired);
console.log('📊 isCancelled:', isCancelled);
const StatusBadge = () => {
const statusConfig = {

View File

@@ -761,7 +761,6 @@ export default function PassengersPage() {
// Remove code/state from the URL so a refresh doesn't re-trigger
router.replace('/booking/passengers');
} catch (error) {
console.error('Failed to complete Fayda verification:', error);
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
setFaydaErrors((prev) => ({ ...prev, [targetIndex]: 'Fayda verification failed. Please try again or enter details manually.' }));
} finally {
@@ -793,7 +792,6 @@ export default function PassengersPage() {
try {
// Fetch passenger profile from backend
const passengerData: any = await apiClient.get(`/passengers/me`);
console.log('Fetched passenger data:', passengerData);
if (!passengerData) {
setFormInitialized(true);
@@ -801,7 +799,6 @@ export default function PassengersPage() {
}
// Only populate first passenger
console.log('Setting passenger 0 values');
setValue('passengers.0.name', passengerData?.fullName || user.fullName || '');
setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || '');
if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any);
@@ -818,7 +815,6 @@ export default function PassengersPage() {
setFormInitialized(true);
} catch (error) {
console.error('Failed to fetch passenger data:', error);
setFormInitialized(true);
}
};
@@ -904,7 +900,6 @@ export default function PassengersPage() {
setFaydaErrors((prev) => ({ ...prev, [index]: 'Fayda verification was not completed. Please try again or enter details manually.' }));
}
} catch (error) {
console.error('Failed to get verification status:', error);
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to confirm verification status. Please try again or enter details manually.' }));
} finally {
@@ -914,7 +909,6 @@ export default function PassengersPage() {
}
}, 1000);
} catch (error) {
console.error('Failed to start Fayda verification:', error);
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to start verification. Please try again.' }));
setVerifyingIndex(null);
@@ -943,9 +937,7 @@ export default function PassengersPage() {
try {
const passengerProfile: any = await apiClient.get('/passengers/me');
passengerId = passengerProfile?.id || '';
console.log('Fetched passengerId:', passengerId);
} catch (error) {
console.error('Failed to fetch passenger profile:', error);
}
}
@@ -983,12 +975,10 @@ export default function PassengersPage() {
if (isAuthenticated && passengerId) {
const { setPassengerId } = useBookingStore.getState();
setPassengerId(passengerId);
console.log('Saved passengerId to booking store:', passengerId);
}
router.push('/booking/seats');
} catch (error) {
console.error('Failed to save passenger details:', error);
alert('Failed to save passenger details. Please try again.');
} finally {
setSaving(false);
@@ -997,7 +987,7 @@ export default function PassengersPage() {
useEffect(() => {
if (!searchCriteria) {
router.push('/booking/search');
window.location.href = '/';
}
}, [searchCriteria, router]);

View File

@@ -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;
@@ -59,29 +63,34 @@ export default function PaymentPage() {
queryKey: ['bookingAmount', bookingId, amountCurrency, selectedMethod],
queryFn: async () => {
const url = `/payments/booking-amount?bookingId=${bookingId}&currency=${amountCurrency}`;
console.log('[BookingAmount] Request:', { url, bookingId, currency: amountCurrency, selectedMethod });
const response: any = await apiClient.get(url);
console.log('[BookingAmount] Response:', response);
return response;
},
enabled: !!selectedMethod && !!bookingId,
});
// Fallback: estimate from local store while API hasn't responded yet.
// Uses the same first-child-free calculation as the review page so the
// breakdown shown here matches what the passenger already saw there.
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => {
return sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0);
}, 0) : 0;
// Per-leg totals across all passengers.
// Package: one leg = pkgAdultFare/pkgChildFare (already ×1 per leg; pkgAdultFare already has ×2 for round-trip baked in via pkgRoundTripMultiplier — so per-leg is packageTierPriceMinor).
const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
const childCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length;
const pkgPerLegAdultFare = isPackage ? packageTierPriceMinor! : 0;
const pkgPerLegChildFare = isPackage ? Math.round(pkgPerLegAdultFare * 0.1) : 0;
const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + childCount * pkgPerLegChildFare : 0;
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => {
return sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0);
}, 0) : 0;
const outboundBaseFare = isPackage
? pkgPerLegTotal
: (isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0), 0) : 0);
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
return sum + calculatePassengerFare(passengers, i, farePerPassenger);
}, 0);
const inboundBaseFare = isPackage
? pkgPerLegTotal
: (isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0), 0) : 0);
const baseFare = isPackage
? adultCount * pkgAdultFare + childCount * pkgChildFare
: isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
return sum + calculatePassengerFare(passengers, i, farePerPassenger);
}, 0);
// API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency
const totalAmount = bookingAmountData != null
@@ -115,7 +124,6 @@ export default function PaymentPage() {
router.push("/booking/confirmation");
},
onError: (error: any) => {
console.error("Payment failed:", error);
updateStatus("FAILED");
setPaymentError(
error?.response?.data?.message ||
@@ -160,10 +168,6 @@ export default function PaymentPage() {
// Add a small delay to allow state to be set from previous page
const timer = setTimeout(() => {
if (!bookingId || !pnr) {
console.log(
"Payment page: Missing booking data, redirecting to search",
);
console.log("bookingId:", bookingId, "pnr:", pnr);
router.push("/booking/search");
}
}, 500);
@@ -266,17 +270,22 @@ export default function PaymentPage() {
<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">
@@ -285,9 +294,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>
@@ -298,12 +307,22 @@ export default function PaymentPage() {
{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>Outbound {!isPackage && isFreeChild ? '(Free)' : ''}</span>
<span>{formatFare(
isPackage
? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)
: 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>Return {!isPackage && isFreeChild ? '(Free)' : ''}</span>
<span>{formatFare(
isPackage
? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)
: calculatePassengerFare(passengers, i, inboundSchedule?.baseFareAdult || 0),
displayCurrency
)}</span>
</div>
</div>
)}

View File

@@ -107,11 +107,9 @@ export default function ResultsPage() {
payload.returnDate = searchData.returnDate;
}
console.log('🚂 Search Request:', JSON.stringify(payload, null, 2));
const response = await apiClient.post('/search', payload) as any;
console.log('✅ Search Response:', JSON.stringify(response, null, 2));
return response;
},

View File

@@ -15,13 +15,11 @@ import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
function getPassengerIdFromToken(token: string): string | null {
try {
if (!token) {
console.warn('No token provided');
return null;
}
const parts = token.split('.');
if (parts.length !== 3) {
console.warn('Invalid token format - expected 3 parts, got', parts.length);
return null;
}
@@ -33,21 +31,16 @@ function getPassengerIdFromToken(token: string): string | null {
try {
decoded = JSON.parse(atob(padded));
} catch (e) {
console.error('Failed to parse base64:', e);
return null;
}
console.log('Decoded JWT payload keys:', Object.keys(decoded));
console.log('passengerId from JWT:', decoded.passengerId);
if (!decoded.passengerId) {
console.warn('No passengerId in JWT payload, available keys:', Object.keys(decoded));
return null;
}
return decoded.passengerId;
} catch (error) {
console.error('Error in getPassengerIdFromToken:', error);
return null;
}
}
@@ -149,7 +142,6 @@ export default function ReviewPage() {
setSeatDetails(details);
} catch (error) {
console.error('Failed to fetch seat details:', error);
}
};
@@ -159,9 +151,6 @@ export default function ReviewPage() {
const createBookingMutation = useMutation({
mutationFn: (data: any) => {
const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest';
console.log('=== API REQUEST ===');
console.log('Endpoint:', endpoint);
console.log('Request Data:', JSON.stringify(data, null, 2));
return apiClient.post(endpoint, data);
},
onSuccess: (data: any) => {
@@ -201,9 +190,8 @@ export default function ReviewPage() {
}
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
console.error('Missing search criteria');
alert('Missing search criteria. Please start over.');
router.push('/booking/search');
window.location.href = '/';
return;
}
@@ -225,10 +213,8 @@ export default function ReviewPage() {
seatClassId = outboundClassName ? findByName(outboundClassName) : seatClasses[0].id;
returnSeatClassId = returnClassName ? findByName(returnClassName) : seatClasses[0].id;
console.log('Seat class lookup:', { outboundClassName, returnClassName, seatClassId, returnSeatClassId });
}
} catch (err) {
console.error('Failed to fetch seat classes:', err);
}
if (!seatClassId) {
@@ -242,11 +228,9 @@ export default function ReviewPage() {
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
if (!token) {
console.error('No token in localStorage');
throw new Error('Authentication token not found. Please log in again.');
}
console.log('Token found, length:', token.length);
let passengerId = getPassengerIdFromToken(token);
@@ -274,7 +258,6 @@ export default function ReviewPage() {
const me: any = await apiClient.get('/passengers/me');
passengerId = me?.id || me?.passengerId || '';
} catch (err) {
console.error('Failed to resolve passengerId from /passengers/me:', err);
}
}
@@ -379,11 +362,8 @@ export default function ReviewPage() {
localStorage.setItem('deviceId', bookingData.deviceId);
}
console.log('Creating booking with payload:', JSON.stringify(bookingData, null, 2));
console.log('API endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest');
await createBookingMutation.mutateAsync(bookingData);
} catch (error) {
console.error('Error in handleConfirm:', error);
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
}
};
@@ -392,30 +372,22 @@ export default function ReviewPage() {
if (isRoundTrip) {
if (!outboundSchedule || !inboundSchedule || !passengers.length) {
if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
console.log('Redirecting to search - missing round trip data');
router.push('/booking/search');
window.location.href = '/';
}
}
} else {
if (!selectedSchedule || !passengers.length) {
if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
console.log('Redirecting to search - missing data');
router.push('/booking/search');
window.location.href = '/';
}
}
}
}, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]);
if (isRoundTrip && (!outboundSchedule || !inboundSchedule || !passengers.length)) {
return null;
}
if (!isRoundTrip && (!selectedSchedule || !passengers.length)) {
return null;
}
const fetchFareBreakdown = useCallback(async (scheduleId: string, originStationId: string, destinationStationId: string) => {
// Package bookings use the stored tier price — no fare calculation needed
if (packageTierPriceMinor !== null) return;
try {
const seatClasses: any[] = await apiClient.get('/seat-classes');
const scheduleSeatClassName = isRoundTrip
@@ -445,9 +417,8 @@ export default function ReviewPage() {
const result: any = await apiClient.get(`/search/fare-breakdown?${params}`);
setFareBreakdown(result);
} catch (err) {
console.error('Failed to fetch fare breakdown:', err);
}
}, [passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
}, [packageTierPriceMinor, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
useEffect(() => {
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
@@ -456,7 +427,26 @@ export default function ReviewPage() {
fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId);
}, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]);
const total = packageTierPriceMinor ?? fareBreakdown?.totalMinor ?? 0;
if (isRoundTrip && (!outboundSchedule || !inboundSchedule || !passengers.length)) {
return null;
}
if (!isRoundTrip && (!selectedSchedule || !passengers.length)) {
return null;
}
const isPackageBooking = packageTierPriceMinor !== null;
// packageTierPriceMinor is the per-adult fare for ONE leg.
// Round-trip packages multiply by 2; children pay 10% of the adult fare.
const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
const pkgChildFare = isPackageBooking ? Math.round(pkgAdultFare * 0.1) : 0;
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length;
const total = isPackageBooking
? adultPassengerCount * pkgAdultFare + childPassengerCount * pkgChildFare
: (fareBreakdown?.totalMinor ?? 0);
// Shared fare sidebar — rendered in right column (desktop) and inline (mobile)
const FareSidebar = () => (
@@ -466,9 +456,11 @@ export default function ReviewPage() {
</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">
@@ -477,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>

View File

@@ -141,6 +141,9 @@ export default function SeatsPage() {
// Maps passenger index -> assigned seat id. A passenger can only get a seat while
// they are the "active" passenger, which prevents bulk/batch selection across passengers.
const [passengerSeatMap, setPassengerSeatMap] = useState<Record<number, string>>({});
// Outbound seat IDs locked in after the outbound hold — used to prevent the
// same physical seat being picked again on the inbound leg.
const [activePassengerIndex, setActivePassengerIndex] = useState(0);
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
const [currentJourneyType, setCurrentJourneyType] = useState<
@@ -170,24 +173,17 @@ export default function SeatsPage() {
isLoading,
error,
} = useQuery({
queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType],
queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType, (currentSchedule as any)?.originStationId, (currentSchedule as any)?.destinationStationId],
queryFn: async () => {
const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeId}&journeyDirection=${journeyDirection}`;
console.log("🪑 Seatmap Request:", {
endpoint,
});
const scheduleForMap = isRoundTrip && currentJourneyType === "inbound" ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule);
const originId = (scheduleForMap as any)?.originStationId || searchCriteria?.originStationId;
const destinationId = (scheduleForMap as any)?.destinationStationId || searchCriteria?.destinationStationId;
const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeId}&journeyDirection=${journeyDirection}${originId ? `&originStationId=${originId}` : ''}${destinationId ? `&destinationStationId=${destinationId}` : ''}`;
const response = await apiClient.get(endpoint);
console.log("✅ Seatmap Response:", {
endpoint,
fullResponse: response,
dataCoaches: (response as any)?.data?.coaches?.length || 0,
rootCoaches: (response as any)?.coaches?.length || 0,
});
const finalData = (response as any)?.data || response;
console.log("🎯 Final data structure:", finalData);
return finalData;
},
enabled: !!currentSchedule?.id && !!coachTypeId,
@@ -202,15 +198,14 @@ export default function SeatsPage() {
seatId: seatIds[i],
}));
const isInbound = isRoundTrip && currentJourneyType === "inbound";
const isInbound = currentJourneyType === "inbound";
// Always use the schedule's own station IDs (set from schedule.origin.id / schedule.destination.id)
// so they are guaranteed to exist in the trip's TripStopTime records.
const scheduleForHold = isInbound ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule);
const scheduleForHold = isInbound && inboundSchedule ? inboundSchedule : (outboundSchedule || selectedSchedule);
const originId = (scheduleForHold as any)?.originStationId || searchCriteria?.originStationId;
const destinationId = (scheduleForHold as any)?.destinationStationId || searchCriteria?.destinationStationId;
console.log('🎫 Hold request:', { scheduleId: currentSchedule?.id, originId, destinationId, isInbound });
return apiClient.post(`/seats/hold`, {
scheduleId: currentSchedule?.id,
originStationId: originId,
@@ -220,7 +215,7 @@ export default function SeatsPage() {
});
},
onSuccess: (data: any) => {
const isInbound = isRoundTrip && currentJourneyType === "inbound";
const isInbound = currentJourneyType === "inbound";
if (isInbound) {
// Merge the return hold into the existing outbound hold
const current = useBookingStore.getState().seatHold;
@@ -258,29 +253,10 @@ export default function SeatsPage() {
(seatMapData as any)?.coaches ||
(seatMapData as any)?.data?.coaches ||
[];
console.log("📦 Raw coaches data:", {
fromRoot: (seatMapData as any)?.coaches?.length || 0,
fromData: (seatMapData as any)?.data?.coaches?.length || 0,
using: rawCoaches.length,
hasRooms: rawCoaches.some((c: any) => c.rooms?.length > 0),
sampleRooms: rawCoaches[0]?.rooms?.length || 0,
});
return rawCoaches;
}, [seatMapData]);
const filteredCoaches = useMemo(() => {
console.log("🔍 Filtering coaches:", {
totalCoaches: coaches.length,
selectedSeatClass: currentSchedule?.selectedSeatClass,
coachesData: coaches.map((c: any) => ({
id: c.id,
name: c.name,
label: c.label,
seatClass: c.seatClass,
seatClasses: c.seatClasses,
seatsCount: c.seats?.length || 0,
})),
});
const coachesWithSeats = coaches.filter((c: any) => {
// Bed coaches store occupants in rooms.beds, not seats
@@ -288,12 +264,8 @@ export default function SeatsPage() {
return c.seats && c.seats.length > 0;
});
console.log(
"✅ Returning all coaches with seats/beds:",
coachesWithSeats.length,
);
return coachesWithSeats;
}, [coaches, currentSchedule?.selectedSeatClass]);
}, [coaches]);
const selectedCoachData = useMemo(
@@ -367,7 +339,7 @@ export default function SeatsPage() {
return seats;
}, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]);
// Seats already claimed by any passenger in this journey leg
// Seats already claimed by any passenger in this journey leg, plus outbound
const assignedSeatIds = useMemo(
() => new Set(Object.values(passengerSeatMap)),
[passengerSeatMap],

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import './globals.css';
import { Providers } from './providers';
import AppHeader from '@/components/AppHeader';
@@ -15,10 +16,13 @@ export default function RootLayout({
}: {
children: React.ReactNode;
}) {
// Nonce set per-request by middleware; required for this inline script under the CSP.
const nonce = headers().get('x-nonce') ?? undefined;
return (
<html lang="en" suppressHydrationWarning>
<body className="font-sans antialiased flex flex-col min-h-screen">
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `
(function() {

View File

@@ -40,6 +40,11 @@ interface TrainInfo {
operatorName: string;
}
interface StopTime {
sequence: number;
station: Station;
}
interface Schedule {
id: string;
departureAt: string;
@@ -50,6 +55,7 @@ interface Schedule {
originStation: Station;
destinationStation: Station;
train: TrainInfo;
stopTimes?: StopTime[];
}
interface PriceTier {
@@ -287,6 +293,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,
@@ -294,18 +304,23 @@ function PassengerCountModal({
loading,
error,
priceMultiplier,
stations,
}: {
tier: PriceTier;
onClose: () => void;
onConfirm: (adultCount: number, childCount: number) => void;
onConfirm: (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => void;
loading: boolean;
error: string | null;
priceMultiplier: number;
stations: Station[];
}) {
const [adultCount, setAdultCount] = useState(1);
const [childCount, setChildCount] = useState(0);
const total = adultCount + childCount;
const [departureStationId, setDepartureStationId] = useState('');
const [showStationError, setShowStationError] = useState(false);
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 (
<>
@@ -313,7 +328,7 @@ function PassengerCountModal({
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4">
<div className="w-full sm:max-w-sm bg-white dark:bg-gray-900 rounded-t-3xl sm:rounded-2xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h2 className="text-base font-bold text-gray-900 dark:text-white">Number of passengers</h2>
<h2 className="text-base font-bold text-gray-900 dark:text-white">Number of passengers & boarding station selection</h2>
<button type="button" onClick={onClose} className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800">
<X className="w-4 h-4 text-gray-500" />
</button>
@@ -322,13 +337,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 +358,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 +367,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 && (
@@ -362,8 +378,36 @@ function PassengerCountModal({
</div>
)}
<button type="button" onClick={() => onConfirm(adultCount, childCount)}
disabled={loading || total < 1}
{/* Departure Station */}
<div>
<label className="flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5 border-t pt-3">
Departure Station
</label>
<select
value={departureStationId}
onChange={(e) => { setDepartureStationId(e.target.value); setShowStationError(false); }}
className={`w-full rounded-xl border bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/40 ${
showStationError && !departureStationId ? 'border-red-400 dark:border-red-500' : 'border-gray-200 dark:border-gray-700'
}`}
>
<option value="">Select your boarding station</option>
{stations.map((s) => (
<option key={s.id} value={s.id}>{s.name.trim()} ({s.code})</option>
))}
</select>
<p className="text-[10px] text-gray-400 mt-1">For informational purposes pricing remains fixed regardless of boarding point.</p>
</div>
{showStationError && !departureStationId && (
<p className="text-xs text-red-500 -mt-2">Please select your boarding station to continue.</p>
)}
<button type="button" onClick={() => {
if (!departureStationId) { setShowStationError(true); return; }
const station = stations.find(s => s.id === departureStationId);
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
}}
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>
@@ -394,11 +438,16 @@ export default function PackageDetailPage() {
enabled: !!id,
});
// Build departure station list from the outbound schedule's route stops (ordered by sequence)
const routeStopStations: Station[] = pkg?.outboundSchedule?.stopTimes?.length
? pkg.outboundSchedule.stopTimes.map((st) => st.station).filter(Boolean)
: [];
const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId);
const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP';
const handleBookNow = async (adultCount: number, childCount: number) => {
const handleBookNow = async (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => {
if (!selectedTier || !pkg) return;
setBookingContextLoading(true);
setBookingContextError(null);
@@ -460,7 +509,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, departureStationId, departureStationName);
router.push("/booking/passengers");
} catch (err: any) {
@@ -515,6 +565,7 @@ export default function PackageDetailPage() {
loading={bookingContextLoading}
error={bookingContextError}
priceMultiplier={isRoundTripPkg ? 2 : 1}
stations={routeStopStations}
/>
)}

View File

@@ -161,7 +161,7 @@ export default function ProfilePage() {
mutationFn: () => apiClient.delete('/auth/account'),
onSuccess: () => {
logout();
router.push('/booking/search');
window.location.href = '/';
},
});

View File

@@ -1,7 +1,7 @@
'use client';
import { Suspense, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Train, ArrowLeft, ShieldCheck } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
@@ -20,7 +20,6 @@ function isStrongPassword(pw: string): boolean {
}
function VerifyAccountContent() {
const router = useRouter();
const searchParams = useSearchParams();
const login = useAuthStore((s) => s.login);
@@ -65,7 +64,7 @@ function VerifyAccountContent() {
});
// Auto-login with the freshly-set password; login lazy-provisions the passenger record.
await login(email, newPassword);
router.push('/booking/search');
window.location.href = '/';
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
setError(msg || 'Could not verify your account. Check the code and try again, or resend it.');

View File

@@ -88,6 +88,8 @@ interface BookingState {
packageName: string | null;
priceTierId: string | null;
packageTierPriceMinor: number | null;
packageDepartureStationId: string | null;
packageDepartureStationName: string | null;
setSearchCriteria: (criteria: SearchCriteria) => void;
setSelectedSchedule: (schedule: SelectedSchedule) => void;
@@ -100,7 +102,7 @@ interface BookingState {
setPaymentMethod: (method: string) => void;
setCreateAccount: (create: boolean) => void;
setPassengerId: (id: string | null) => void;
setPackageContext: (packageId: string, priceTierId: string, priceMinor: number, packageName?: string) => void;
setPackageContext: (packageId: string, priceTierId: string, priceMinor: number, packageName?: string, departureStationId?: string, departureStationName?: string) => void;
clearBooking: () => void;
}
@@ -121,9 +123,11 @@ export const useBookingStore = create<BookingState>()(persist(
packageName: null,
priceTierId: null,
packageTierPriceMinor: null,
packageDepartureStationId: null,
packageDepartureStationName: null,
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }),
setPackageContext: (packageId, priceTierId, priceMinor, packageName) => set({ packageId, packageName: packageName ?? null, priceTierId, packageTierPriceMinor: priceMinor }),
setPackageContext: (packageId, priceTierId, priceMinor, packageName, departureStationId, departureStationName) => set({ packageId, packageName: packageName ?? null, priceTierId, packageTierPriceMinor: priceMinor, packageDepartureStationId: departureStationId ?? null, packageDepartureStationName: departureStationName ?? null }),
setSelectedSchedule: (schedule) => set({ selectedSchedule: schedule }),
setOutboundSchedule: (schedule) => set({ outboundSchedule: schedule }),
setInboundSchedule: (schedule) => set({ inboundSchedule: schedule }),
@@ -150,6 +154,8 @@ export const useBookingStore = create<BookingState>()(persist(
packageName: null,
priceTierId: null,
packageTierPriceMinor: null,
packageDepartureStationId: null,
packageDepartureStationName: null,
}),
} as BookingState)),
{

View File

@@ -0,0 +1,119 @@
import { NextRequest, NextResponse } from 'next/server';
// Routes that should NOT redirect to home on hard refresh
const PRESERVED_ROUTES = [
'/booking/',
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/set-password',
'/verify-account',
'/fayda-setup',
'/profile',
'/about',
'/contact',
'/help',
'/guide',
'/services',
'/go/',
];
/**
* Build the Content-Security-Policy for a single request.
*
* Production uses a strict, nonce-based policy with `strict-dynamic`: only scripts
* carrying this request's nonce (and scripts they load) may execute, which neutralises
* reflected/stored XSS regardless of any host allowlist. Next.js applies the nonce to
* its own bootstrap/chunk scripts automatically because middleware forwards it on the
* request `Content-Security-Policy` header (see below); our own inline scripts read it
* from the `x-nonce` request header in the root layout.
*
* Development relaxes `script-src` (Next.js HMR/react-refresh needs `unsafe-eval` and
* inline) and allows the HMR websocket, and drops `upgrade-insecure-requests` so
* plain-HTTP localhost keeps working.
*/
function buildCsp(nonce: string): string {
const isProd = process.env.NODE_ENV === 'production';
// Origin the browser calls for API/XHR/fetch — must be allowed in connect-src.
let apiOrigin = '';
try {
apiOrigin = new URL(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000').origin;
} catch {
apiOrigin = '';
}
const scriptSrc = isProd
? `'self' 'nonce-${nonce}' 'strict-dynamic'`
: `'self' 'unsafe-inline' 'unsafe-eval'`;
const connectSrc = isProd
? `'self' ${apiOrigin}`.trim()
: `'self' ${apiOrigin} ws: wss:`.trim();
const directives = [
`default-src 'self'`,
`base-uri 'self'`,
`script-src ${scriptSrc}`,
// Inline styles (Tailwind runtime + React `style=` attributes) can't execute JS;
// nonce-ing them reliably breaks Next/React, so 'unsafe-inline' is the accepted stance.
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: blob: ${apiOrigin}`.trim(),
`font-src 'self' data:`,
`connect-src ${connectSrc}`,
`worker-src 'self' blob:`,
`frame-src 'self'`,
`object-src 'none'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
...(isProd ? ['upgrade-insecure-requests'] : []),
];
return directives.join('; ');
}
export function middleware(request: NextRequest) {
const nonce = btoa(crypto.randomUUID());
const csp = buildCsp(nonce);
// Forward the nonce + CSP on the *request* so Next.js nonces its own scripts and our
// layout can read `x-nonce`. The browser-enforced copy is set on the response below.
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('Content-Security-Policy', csp);
const render = () => NextResponse.next({ request: { headers: requestHeaders } });
const { pathname } = request.nextUrl;
const isHardRefresh = !request.headers.get('referer');
let response: NextResponse;
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api') ||
pathname.includes('.') ||
pathname === '/'
) {
// Next.js internals, static files, API routes, and home: no redirect, just render.
response = render();
} else if (PRESERVED_ROUTES.some((r) => pathname.startsWith(r))) {
// On hard refresh of a preserved route, let it through.
response = render();
} else if (isHardRefresh && pathname.startsWith('/packages')) {
// On hard refresh of package detail or packages list, redirect to home.
response = NextResponse.redirect(new URL('/', request.url));
} else {
response = render();
}
response.headers.set('Content-Security-Policy', csp);
// Anti-clickjacking. `frame-ancestors 'none'` (in the CSP above) is the modern control;
// X-Frame-Options: DENY is the legacy equivalent for older browsers and scanners.
response.headers.set('X-Frame-Options', 'DENY');
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

View File

@@ -28,6 +28,8 @@ export interface Schedule {
};
originStation?: Station; // For backward compatibility
destinationStation?: Station; // For backward compatibility
originStationId?: string; // For backward compatibility
destinationStationId?: string; // For backward compatibility
departureAt?: string; // API returns this
arrivalAt?: string; // API returns this
departureTime?: string; // For backward compatibility