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 e77ab70b4..220ecb1b3 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
@@ -102,7 +102,7 @@ export default function ConfirmationPage() {
const { packageTierPriceMinor } = useBookingStore.getState();
const isPackageBooking = packageTierPriceMinor != null;
const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
- const pkgMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
+ const pkgMultiplier = isPackageBooking ? 2 : 1;
const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgMultiplier : 0;
const pkgChildFare = pkgAdultFare;
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 194b47dff..c07a812c4 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
@@ -149,6 +149,31 @@ export default function ReviewPage() {
fetchSeatDetails();
}, [selectedSchedule?.id, outboundSchedule?.id, inboundSchedule?.id, passengers, isRoundTrip]);
+ const isPackageBooking = packageTierPriceMinor !== null || !!packageName;
+ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
+ const pkgAdultFare = isPackageBooking && packageTierPriceMinor != null ? packageTierPriceMinor * 2 : 0;
+ const pkgChildFare = pkgAdultFare;
+
+ const isPackageChild = (index: number) =>
+ isPackageBooking ? index >= adultPassengerCount : isChild(passengers[index]);
+
+ const isPkgFreeChild = (index: number) => {
+ if (!isPackageBooking) return false;
+ if (!isPackageChild(index)) return false;
+ const childIndex = index - adultPassengerCount;
+ return childIndex < adultPassengerCount;
+ };
+
+ const getPassengerSeatFare = (p: any): number | null => {
+ if (isRoundTrip) {
+ if (p.outboundSeatFareMinor == null && p.inboundSeatFareMinor == null) return null;
+ if (isPackageBooking) return (p.outboundSeatFareMinor ?? 0) * 2;
+ return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0);
+ }
+ if (p.seatFareMinor == null) return null;
+ return isPackageBooking ? p.seatFareMinor * 2 : p.seatFareMinor;
+ };
+
const createBookingMutation = useMutation({
mutationFn: (data: any) => {
const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest';
@@ -393,8 +418,9 @@ export default function ReviewPage() {
? (isChildPassenger && (i - adultPassengerCount) < adultPassengerCount)
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
const seatFare = getPassengerSeatFare(p);
+ const pkgFallback = isChildPassenger ? pkgChildFare : pkgAdultFare;
const fareMinor = isPackageBooking
- ? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare))
+ ? (isFreeChild ? 0 : (seatFare ?? pkgFallback))
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
return { fareMinor, isFree: isFreeChild };
});
@@ -423,7 +449,7 @@ export default function ReviewPage() {
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;
+ if (isPackageBooking) return;
try {
const seatClasses: any[] = await apiClient.get('/seat-classes');
@@ -433,9 +459,6 @@ export default function ReviewPage() {
const fallbackSeatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id;
if (!fallbackSeatClassId) return;
- // Bed coaches price Upper/Middle/Lower as separate classes, so a passenger's own
- // assigned berth (captured on the seats page) must resolve its own seatClassId here
- // — a single shared class can't correctly price passengers in different berths.
const resolveSeatClassId = (p: any): string => {
const bedPosition: string | undefined = isRoundTrip ? (p as any).outboundBedPosition : (p as any).bedPosition;
if (!bedPosition) return fallbackSeatClassId;
@@ -465,7 +488,7 @@ export default function ReviewPage() {
setFareBreakdown(result);
} catch (err) {
}
- }, [packageTierPriceMinor, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
+ }, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
useEffect(() => {
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
@@ -474,37 +497,17 @@ export default function ReviewPage() {
fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId);
}, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]);
- const isPackageBooking = packageTierPriceMinor !== null;
- // packageTierPriceMinor is the per-adult fare for ONE leg.
- // Round-trip packages multiply by 2.
- // First child per adult travels FREE; additional children pay full adult fare.
- const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
- const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length;
- const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
- const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
- const pkgPaidChildrenCount = Math.max(0, childPassengerCount - adultPassengerCount);
- // Paid children pay full adult fare
- const pkgChildFare = pkgAdultFare; // full fare for paid children
-
// For package bookings, passengers are initialized without dateOfBirth so isChild() is
// unreliable. Use the stored adultCount from searchCriteria to determine category by index.
- const isPackageChild = (index: number) =>
- isPackageBooking ? index >= adultPassengerCount : isChild(passengers[index]);
-
- // Per-seat fare captured on the seats page (bed-position-aware, computed locally from
- // the schedule's own coachTypes/classes) is guaranteed correct for berths, unlike the
- // backend /search/fare-breakdown call whose seatClassId matching for bed positions can't
- // be verified here. Prefer it whenever the passenger actually has an assigned seat.
- const getPassengerSeatFare = (p: any): number | null => {
- if (isRoundTrip) {
- if (p.outboundSeatFareMinor == null && p.inboundSeatFareMinor == null) return null;
- return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0);
- }
- return p.seatFareMinor ?? null;
- };
-
const total = isPackageBooking
- ? adultPassengerCount * pkgAdultFare + pkgPaidChildrenCount * pkgChildFare
+ ? passengers.reduce((sum, p, i) => {
+ const isChild_ = isPackageChild(i);
+ const isFreeChild = isChild_ && (i - adultPassengerCount) < adultPassengerCount;
+ if (isFreeChild) return sum;
+ const seatFare = getPassengerSeatFare(p);
+ const pkgFallback = isChild_ ? pkgChildFare : pkgAdultFare;
+ return sum + (seatFare ?? pkgFallback);
+ }, 0)
: passengers.reduce((sum, p, i) => {
const isChildPassenger = isChild(p);
const line = fareBreakdown?.passengers?.[i];
@@ -517,16 +520,6 @@ export default function ReviewPage() {
// Keep computedTotal in sync so handleConfirm can persist it to the store
useEffect(() => { setComputedTotal(total); }, [total]);
- // For package bookings, determine if a child is free (first per adult) or paid.
- // Children are ordered after adults in the passengers array (set on package detail page).
- const isPkgFreeChild = (index: number) => {
- if (!isPackageBooking) return false;
- if (!isPackageChild(index)) return false;
- // childIndex = position among children (0-based)
- const childIndex = index - adultPassengerCount;
- return childIndex < adultPassengerCount; // first adultCount children are free
- };
-
// Shared fare sidebar — rendered in right column (desktop) and inline (mobile)
const FareSidebar = () => (
@@ -541,7 +534,7 @@ export default function ReviewPage() {
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
const seatFare = getPassengerSeatFare(p);
const passengerTotal = isPackageBooking
- ? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare))
+ ? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
return (
@@ -618,9 +611,6 @@ export default function ReviewPage() {
Outbound Journey
-
- {outboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
-
{/* Flight-style timeline */}
@@ -695,9 +685,6 @@ export default function ReviewPage() {
Return Journey
-
- {inboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
-
{/* Flight-style timeline */}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
index ddf115084..a5c30f323 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
@@ -174,6 +174,7 @@ export default function SeatsPage() {
packageName,
packageId,
priceTierId,
+ packageTierPriceMinor,
packageDepartureStationId,
packageDepartureStationName,
setPackageContext,
@@ -369,10 +370,10 @@ export default function SeatsPage() {
const getSeatFare = useCallback(
(seat: any): number | null => {
if (!currentCoachTypeClasses.length) return null;
- if (seat?.bedPosition) {
+ if (seat?.bedPosition) {
const match = currentCoachTypeClasses.find((c: any) =>
c.name?.toLowerCase().includes(seat.bedPosition),
- );
+ );
if (match) return match.baseFareMinor;
}
const regular = currentCoachTypeClasses.find((c: any) => /regular/i.test(c.name || ""));
@@ -495,7 +496,7 @@ export default function SeatsPage() {
setModalState({
isOpen: true,
title: "Fare Will Change",
- message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ETB ${(newFare / 100 * 2).toFixed(2)} per adult (currently ETB ${(currentFare / 100 * 2).toFixed(2)}). Continue?`,
+ message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ETB ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult (currently ETB ${(currentFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)}). Continue?`,
type: "warning",
showCancel: true,
confirmText: "Switch Coach",
@@ -510,7 +511,7 @@ export default function SeatsPage() {
isOpen: true,
title: "Switch Coach Type",
message: `Switch to ${coach.label}${coachTypeName ? ` (${coachTypeName})` : ""}?${
- newFare != null ? ` Fare: ETB ${(newFare / 100 * 2).toFixed(2)} per adult.` : " This will have a fare change."
+ newFare != null ? ` Fare: ETB ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult.` : " This will have a fare change."
}`,
type: "info",
showCancel: true,
@@ -769,26 +770,32 @@ export default function SeatsPage() {
const newSeat = validSeats?.find((s: any) => s.id === seatId);
const newFare = newSeat ? getSeatFare(newSeat) : null;
- // Package bookings: fare-change warning on individual seat clicks is suppressed.
- // The only fare-change confirmation is when switching coach type via Train Coach Preview.
- if (newFare != null && !isPackageBooking) {
+ if (newFare != null) {
let referenceFare: number | null = null;
let referenceLabel = "the fare you originally selected";
- if (originalFareForCurrentLeg != null && originalFareForCurrentLeg !== newFare) {
- referenceFare = originalFareForCurrentLeg;
+ if (isPackageBooking) {
+ // For package bookings, compare against the stored tier price (per leg).
+ const pkgLegFare = packageTierPriceMinor;
+ if (pkgLegFare != null && newFare !== pkgLegFare) {
+ referenceFare = pkgLegFare;
+ }
} else {
- const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => {
- if (Number(idx) === activePassengerIndex) return false;
- const otherSeat = validSeats?.find((s: any) => s.id === sid);
- const otherFare = otherSeat ? getSeatFare(otherSeat) : null;
- return otherFare != null && otherFare !== newFare;
- });
+ if (originalFareForCurrentLeg != null && originalFareForCurrentLeg !== newFare) {
+ referenceFare = originalFareForCurrentLeg;
+ } else {
+ const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => {
+ if (Number(idx) === activePassengerIndex) return false;
+ const otherSeat = validSeats?.find((s: any) => s.id === sid);
+ const otherFare = otherSeat ? getSeatFare(otherSeat) : null;
+ return otherFare != null && otherFare !== newFare;
+ });
- if (differingEntry) {
- const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]);
- referenceFare = otherSeat ? getSeatFare(otherSeat) : null;
- referenceLabel = "another already-selected seat";
+ if (differingEntry) {
+ const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]);
+ referenceFare = otherSeat ? getSeatFare(otherSeat) : null;
+ referenceLabel = "another already-selected seat";
+ }
}
}
@@ -797,23 +804,52 @@ export default function SeatsPage() {
const positionLabel = newSeat?.bedPosition
? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth`
: "This seat";
+ const legMultiplier = isRoundTrip ? 2 : 1;
setModalState({
isOpen: true,
title: "Fare Will Change",
- message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100 * 2).toFixed(2)}, different from ${referenceLabel}. Continue with this selection?`,
+ message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100 * legMultiplier).toFixed(2)}, different from ${referenceLabel} (ETB ${(referenceFare / 100 * legMultiplier).toFixed(2)}). Continue with this selection?`,
type: "warning",
showCancel: true,
confirmText: "Continue",
- onConfirm: () => commitSeatAssignment(seatId),
+ onConfirm: () => {
+ commitSeatAssignment(seatId);
+ // For package bookings, sync the stored tier price to the selected berth fare
+ // so review/payment/confirmation pages use the correct amount.
+ if (isPackageBooking && packageId) {
+ setPackageContext(
+ packageId,
+ priceTierId ?? '',
+ newFare,
+ packageName ?? undefined,
+ packageDepartureStationId ?? undefined,
+ packageDepartureStationName ?? undefined,
+ );
+ }
+ },
});
return;
}
+
+ // No fare change — but for package bookings still sync the tier price to the
+ // actual berth fare (handles the case where the first seat picked matches the
+ // stored price but we still want it explicitly confirmed).
+ if (isPackageBooking && packageId && newFare !== packageTierPriceMinor) {
+ setPackageContext(
+ packageId,
+ priceTierId ?? '',
+ newFare,
+ packageName ?? undefined,
+ packageDepartureStationId ?? undefined,
+ packageDepartureStationName ?? undefined,
+ );
+ }
}
commitSeatAssignment(seatId);
},
- [passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, originalFareForCurrentLeg, commitSeatAssignment],
+ [passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, originalFareForCurrentLeg, commitSeatAssignment, isPackageBooking, packageTierPriceMinor, packageId, priceTierId, packageName, packageDepartureStationId, packageDepartureStationName, setPackageContext, isRoundTrip],
);
const allSeatsAssigned =
@@ -1034,6 +1070,26 @@ export default function SeatsPage() {
};
});
setPassengers(updatedPassengers);
+
+ // For one-way package bookings, sync the stored tier price with the actual berth
+ // fare so review/payment/confirmation pages reflect the correct amount.
+ if (!isRoundTrip && isPackageBooking && packageId) {
+ const firstEligibleIdx = seatEligibleIndices[0];
+ const firstSeatData = firstEligibleIdx != null
+ ? validSeats?.find((s: any) => s.id === seatIds[firstEligibleIdx])
+ : null;
+ const berthFare = firstSeatData ? getSeatFare(firstSeatData) : null;
+ if (berthFare != null) {
+ setPackageContext(
+ packageId,
+ priceTierId ?? '',
+ berthFare,
+ packageName ?? undefined,
+ packageDepartureStationId ?? undefined,
+ packageDepartureStationName ?? undefined,
+ );
+ }
+ }
} catch (error: any) {
setModalState({
isOpen: true,
@@ -1678,6 +1734,9 @@ export default function SeatsPage() {
? validSeats?.find((s: any) => s.id === assignedSeatId)
: null;
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
+ const seatFare = assignedSeat
+ ? (getSeatFare(assignedSeat) ?? (isPackageBooking ? packageTierPriceMinor ?? null : null))
+ : isPackageBooking && assignedSeatId ? (packageTierPriceMinor ?? null) : null;
const isActive = i === activePassengerIndex;
const isClickable = i <= maxSelectableIndex;
return (
@@ -1719,13 +1778,20 @@ export default function SeatsPage() {
)}
-
- {assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"}
-
+
+
+ {assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"}
+
+ {assignedSeat && seatFare != null && (
+
+ ETB {(seatFare / 100 * (isPackageBooking ? 2 : 1)).toFixed(2)}
+
+ )}
+
);
})}
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 c7ef3fe52..32e3f1178 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
@@ -566,7 +566,7 @@ export default function PackageDetailPage() {
coachTypeId: g.coachTypeId,
coachTypeName: g.coachTypeName,
coachTypeCode: g.coachTypeCode,
- classes: [{ name: g.coachTypeName, baseFareMinor: g.minPrice }],
+ classes: g.tiers.map((t) => ({ name: t.label, baseFareMinor: t.priceMinor })),
})),
});