diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
index 3aaa85fc2..f4139d842 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
@@ -144,22 +144,33 @@ 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 isPackageOneway = !!dto.packageId;
- const paidChildrenCount = isPackageOneway ? childCount : Math.max(0, childCount - 1);
- const childUnitFare = isPackageOneway ? Math.round(baseFareMinor * 0.1) : baseFareMinor;
const childFareMinor = childUnitFare * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childFareMinor;
@@ -369,19 +380,34 @@ 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 isPackageRoundTrip = !!dto.packageId;
- const paidChildrenCount = isPackageRoundTrip ? childCount : Math.max(0, childCount - 1);
- const outboundChildUnitFare = isPackageRoundTrip ? Math.round(outboundBaseFare * 0.1) : outboundBaseFare;
- const returnChildUnitFare = isPackageRoundTrip ? Math.round(returnBaseFare * 0.1) : returnBaseFare;
+ 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;
diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts
index 05b886ea8..3b14af6dd 100644
--- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts
+++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts
@@ -117,8 +117,8 @@ export class PackagesService {
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,
@@ -204,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 } },
},
});
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts
index 044768e90..80f3ad13b 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts
@@ -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 {
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
index 2d194fd7b..19aa5ed0e 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
@@ -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(', ')}`);
diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
index 6b5bcb715..e84837af1 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
@@ -158,7 +158,7 @@ export default function ConfirmationPage() {
useEffect(() => {
if (!bookingId || !pnr) {
- router.push('/booking/search');
+ window.location.href = '/';
}
}, [bookingId, pnr, router]);
@@ -370,19 +370,19 @@ export default function ConfirmationPage() {
{passenger.nationality}
-
Seat
+
Seat(s)
{isRoundTrip ? (
- Outbound: {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'}{(passenger as any).outboundCoachNumber && (Coach {(passenger as any).outboundCoachNumber})}
+ Outbound: {(passenger as any).outboundCoachNumber && {(passenger as any).outboundCoachNumber}} — {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'}
- Return: {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'}{(passenger as any).inboundCoachNumber && (Coach {(passenger as any).inboundCoachNumber})}
+ Return: {(passenger as any).inboundCoachNumber && {(passenger as any).inboundCoachNumber}} — {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'}
) : (
- {passenger.seatNumber || 'Auto-assigned at boarding'}{passenger.coachNumber && (Coach {passenger.coachNumber})}
+ {passenger.coachNumber && (Coach {passenger.coachNumber})} — {passenger.seatNumber || 'Auto-assigned at boarding'}
)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
index 4263108d1..ea2dbc88d 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
@@ -962,7 +962,7 @@ export default function PassengersPage() {
useEffect(() => {
if (!searchCriteria) {
- router.push('/booking/search');
+ window.location.href = '/';
}
}, [searchCriteria, router]);
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
index dc20d76d8..c2c32a8cf 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
@@ -69,19 +69,24 @@ export default function PaymentPage() {
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 = !isPackage && 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 = !isPackage && 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 inboundBaseFare = isPackage
+ ? pkgPerLegTotal
+ : (isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0), 0) : 0);
const baseFare = isPackage
- ? (searchCriteria?.adultCount ?? 0) * pkgAdultFare + (searchCriteria?.childCount ?? 0) * pkgChildFare
+ ? 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);
@@ -299,15 +304,25 @@ export default function PaymentPage() {
{formatFare(passengerTotal, displayCurrency)}
- {!isPackage && isRoundTrip && (
+ {isRoundTrip && (
- Outbound {isFreeChild ? '(Free)' : ''}
- {formatFare(calculatePassengerFare(passengers, i, outboundSchedule?.baseFareAdult || 0), displayCurrency)}
+ Outbound {!isPackage && isFreeChild ? '(Free)' : ''}
+ {formatFare(
+ isPackage
+ ? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)
+ : calculatePassengerFare(passengers, i, outboundSchedule?.baseFareAdult || 0),
+ displayCurrency
+ )}
- Return {isFreeChild ? '(Free)' : ''}
- {formatFare(calculatePassengerFare(passengers, i, inboundSchedule?.baseFareAdult || 0), displayCurrency)}
+ Return {!isPackage && isFreeChild ? '(Free)' : ''}
+ {formatFare(
+ isPackage
+ ? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)
+ : calculatePassengerFare(passengers, i, inboundSchedule?.baseFareAdult || 0),
+ displayCurrency
+ )}
)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
index 7b449dc8b..862a0ba8f 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
@@ -191,7 +191,7 @@ export default function ReviewPage() {
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
alert('Missing search criteria. Please start over.');
- router.push('/booking/search');
+ window.location.href = '/';
return;
}
@@ -372,13 +372,13 @@ export default function ReviewPage() {
if (isRoundTrip) {
if (!outboundSchedule || !inboundSchedule || !passengers.length) {
if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
- router.push('/booking/search');
+ window.location.href = '/';
}
}
} else {
if (!selectedSchedule || !passengers.length) {
if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
- router.push('/booking/search');
+ window.location.href = '/';
}
}
}
@@ -441,8 +441,8 @@ export default function ReviewPage() {
const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
const pkgChildFare = isPackageBooking ? Math.round(pkgAdultFare * 0.1) : 0;
- const adultPassengerCount = searchCriteria?.adultCount ?? passengers.length;
- const childPassengerCount = searchCriteria?.childCount ?? 0;
+ const 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
diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx
index 69558e49e..16f9197e7 100644
--- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx
@@ -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 {
@@ -432,25 +438,10 @@ export default function PackageDetailPage() {
enabled: !!id,
});
- const { data: stationsData } = useQuery({
- queryKey: ["stations-simple"],
- queryFn: async () => (await apiClient.get(`/stations?pageSize=100`)) as any,
- });
-
- const allStations: Station[] = Array.isArray(stationsData) ? stationsData : stationsData?.items || stationsData?.data?.items || [];
-
- // Fetch stops for the outbound schedule to filter stations to only those on the route
- const outboundScheduleId = pkg?.outboundSchedule?.id;
- const { data: scheduleStops } = useQuery({
- queryKey: ["schedule-stops", outboundScheduleId],
- queryFn: async () => (await apiClient.get(`/schedules/${outboundScheduleId}/stops`)) as any[],
- enabled: !!outboundScheduleId,
- });
-
- // Only show stations that are actual stops on the route
- const stations: Station[] = scheduleStops?.length
- ? allStations.filter((s) => scheduleStops.some((stop: any) => stop.stationId === s.id || stop.station?.id === s.id))
- : allStations;
+ // 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);
@@ -574,7 +565,7 @@ export default function PackageDetailPage() {
loading={bookingContextLoading}
error={bookingContextError}
priceMultiplier={isRoundTripPkg ? 2 : 1}
- stations={stations}
+ stations={routeStopStations}
/>
)}
diff --git a/apps/edr-passenger-web/portal/src/app/profile/page.tsx b/apps/edr-passenger-web/portal/src/app/profile/page.tsx
index dbc64d4a9..d44474976 100644
--- a/apps/edr-passenger-web/portal/src/app/profile/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/profile/page.tsx
@@ -161,7 +161,7 @@ export default function ProfilePage() {
mutationFn: () => apiClient.delete('/auth/account'),
onSuccess: () => {
logout();
- router.push('/booking/search');
+ window.location.href = '/';
},
});
diff --git a/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx b/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx
index f9f0b61c9..b2fd491b3 100644
--- a/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx
@@ -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.');