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 af5748ed7..5d992af93 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
@@ -10,7 +10,7 @@ import { apiClient } from '@/lib/api-client';
import { useEffect, useState, useRef } from 'react';
import { CheckCircle, Copy, Train, FileText } from 'lucide-react';
import { format } from 'date-fns';
-import { isChild, isFirstChild, calculatePassengerFare } from '@/utils/fare-utils';
+import { isChild, isFirstChild } from '@/utils/fare-utils';
type BookingWithTicket = {
id: string;
@@ -27,7 +27,7 @@ type BookingWithTicket = {
export default function ConfirmationPage() {
const router = useRouter();
- const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName, packageTierPriceMinor, packageId } = useBookingStore();
+ const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName, packageTierPriceMinor, packageId, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore();
// The currency/amount actually confirmed for the payment option the user selected —
// null when no payment step ran (e.g. a fully-discounted, zero-amount booking).
const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore();
@@ -92,11 +92,11 @@ export default function ConfirmationPage() {
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
// Prefer the amount/currency actually confirmed for the selected payment option;
// only fall back to the ETB booking fare when no payment step ran (e.g. $0 total).
- const totalFare = paidAmountMinor
+ const totalFare = reviewedTotalMinor
+ ?? paidAmountMinor
?? _booking?.totalMinor
?? passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0);
- const voucherCurrency = paidAmountMinor != null ? paidCurrency : 'ETB';
- const farePerPassenger = Math.round(totalFare / passengers.length);
+ const voucherCurrency = 'ETB';
const createdAt = _booking?.createdAt || new Date().toISOString();
const status = _booking?.status || 'CONFIRMED';
@@ -137,7 +137,7 @@ export default function ConfirmationPage() {
outboundSchedule: outbound,
inboundSchedule: inbound,
isRoundTrip,
- fareMinor: farePerPassenger,
+ fareMinor: reviewedPassengerFares?.[i]?.fareMinor ?? Math.round(totalFare / passengers.length),
currency: voucherCurrency,
createdAt,
});
@@ -332,28 +332,10 @@ export default function ConfirmationPage() {
Total paid
{(() => {
+ if (reviewedTotalMinor != null) return `ETB ${(reviewedTotalMinor / 100).toFixed(2)}`;
if (paidAmountMinor != null) return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
- // Recompute the same way the payment page does
- const isPackage = !!packageTierPriceMinor;
- const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1;
- const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
- const adultCount = passengers.filter(p => !isChild(p)).length;
- const childCount = passengers.filter(p => isChild(p)).length;
- const pkgPaidChildrenCount = Math.max(0, childCount - adultCount);
- const fallback = isPackage
- ? adultCount * pkgAdultFare + pkgPaidChildrenCount * pkgAdultFare
- : isRoundTrip
- ? passengers.reduce((sum, p, i) => {
- const outFare = (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0);
- const inFare = (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0);
- return sum + calculatePassengerFare(passengers, i, outFare) + calculatePassengerFare(passengers, i, inFare);
- }, 0)
- : passengers.reduce((sum, p, i) => {
- const fare = (p as any).seatFareMinor ?? (selectedSchedule?.baseFareAdult || 0);
- return sum + calculatePassengerFare(passengers, i, fare);
- }, 0);
- return `ETB ${(fallback / 100).toFixed(2)}`;
+ return 'ETB 0.00';
})()}
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 52ce64a2c..5366bfefb 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
@@ -28,7 +28,7 @@ const getIconForMethod = (methodId: string) => {
export default function PaymentPage() {
const router = useRouter();
- const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, packageTierPriceMinor } = useBookingStore();
+ const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, packageTierPriceMinor, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore();
const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState(null);
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null);
@@ -36,7 +36,7 @@ export default function PaymentPage() {
const [paymentError, setPaymentError] = useState(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
- const isPackage = !!packageTierPriceMinor;
+ const isPackage = !!packageName;
const displayCurrency = 'ETB' as const;
@@ -61,66 +61,42 @@ export default function PaymentPage() {
enabled: !!bookingId,
});
- // Per-leg totals across all passengers.
- // First child per adult = FREE (no seat); additional children = full adult fare.
- const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
- const childCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length;
- const pkgPaidChildrenCount = Math.max(0, childCount - adultCount);
- const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1;
- const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
- const pkgChildFare = pkgAdultFare; // paid children pay full adult fare
- const pkgPerLegAdultFare = isPackage ? packageTierPriceMinor! : 0;
- const pkgPerLegChildFare = pkgPerLegAdultFare; // paid children pay full adult fare per leg
- const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + pkgPaidChildrenCount * pkgPerLegChildFare : 0;
+ // Per-leg subtotals for the journey header — sum each paying passenger's reviewed fare
+ // split equally across both legs. This guarantees leg totals are consistent with the
+ // per-passenger breakdown rows and the overall reviewed total.
+ const outboundBaseFare = isRoundTrip
+ ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
+ : 0;
+ const inboundBaseFare = isRoundTrip
+ ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
+ : 0;
- // Prefer each passenger's own seat fare (set during seat selection) over the schedule's
- // flat baseFareAdult — bed coaches price Upper/Middle/Lower berths differently, so a
- // single schedule-level fare can't correctly represent every passenger's actual seat.
- const outboundBaseFare = isPackage
- ? pkgPerLegTotal
- : (isRoundTrip && outboundSchedule ? passengers.reduce((sum, p, i) => {
- const fare = (p as any).outboundSeatFareMinor ?? (outboundSchedule.baseFareAdult || 0);
- return sum + calculatePassengerFare(passengers, i, fare);
- }, 0) : 0);
-
- const inboundBaseFare = isPackage
- ? pkgPerLegTotal
- : (isRoundTrip && inboundSchedule ? passengers.reduce((sum, p, i) => {
- const fare = (p as any).inboundSeatFareMinor ?? (inboundSchedule.baseFareAdult || 0);
- return sum + calculatePassengerFare(passengers, i, fare);
- }, 0) : 0);
-
- const baseFare = isPackage
- ? adultCount * pkgAdultFare + pkgPaidChildrenCount * pkgChildFare
- : isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => {
- const scheduleFare = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
- const farePerPassenger = (p as any).seatFareMinor ?? scheduleFare;
- return sum + calculatePassengerFare(passengers, i, farePerPassenger);
- }, 0);
-
- // For package bookings the client-side baseFare is authoritative — it applies the
- // round-trip multiplier and child pricing correctly, whereas booking.totalMinor in
- // the DB may have been stored as a single-leg amount for older bookings.
- // For regular bookings the API is the source of truth.
- const totalAmountDisplay = isPackage
- ? baseFare / 100
- : bookingAmountData != null ? bookingAmountData.amount : null;
- const totalAmount = isPackage
- ? baseFare
- : bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : baseFare;
+ // reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display —
+ // they were computed and shown to the user on the review page, so the Total here must match.
+ // The API booking-amount is used only as the charge amount sent to the payment provider.
+ const reviewedTotal = reviewedTotalMinor ?? (reviewedPassengerFares?.reduce((s, f) => s + f.fareMinor, 0) ?? null);
+ const totalAmountDisplay = reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null);
+ const totalAmount = bookingAmountData != null
+ ? Math.round(bookingAmountData.amount * 100)
+ : (reviewedTotal ?? 0);
const confirmedCurrency = bookingAmountData?.currency || amountCurrency;
- // Persist the amount/currency actually confirmed for the selected payment option so
- // downstream screens (e.g. the voucher) use it instead of a default ETB fare.
+ // Show loading spinner only when the API hasn't responded AND we have no review-page
+ // total to fall back on — once reviewedTotalMinor is set the button is always enabled.
+ const awaitingAmount = !isPackage && loadingAmount && totalAmountDisplay === null;
+
useEffect(() => {
- if (isPackage) {
+ // Always store the reviewed total (minor, ETB) as the paid amount — it's what was
+ // shown to the user and matches the fare breakdown. The API amount is only used as
+ // the charge sent to the provider (may differ due to currency conversion).
+ if (reviewedTotal != null) {
setCurrency('ETB');
- setPaidAmount(totalAmount);
+ setPaidAmount(reviewedTotal);
} else if (bookingAmountData != null) {
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
- setPaidAmount(totalAmount);
+ setPaidAmount(Math.round(bookingAmountData.amount * 100));
}
- }, [isPackage, bookingAmountData, confirmedCurrency, totalAmount, setCurrency, setPaidAmount]);
+ }, [bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]);
const paymentMutation = useMutation({
mutationFn: async (data: any) => {
@@ -290,32 +266,14 @@ export default function PaymentPage() {
)}
- {/* Fare breakdown — same first-child-free logic as the review page */}
+ {/* Fare breakdown — sourced directly from review page to guarantee totals match */}
Fare breakdown
{passengers.map((p, i) => {
+ const reviewed = reviewedPassengerFares?.[i];
const isChildPassenger = isChild(p);
- // For package bookings: children ordered after adults; first adultCount children are free
- const childIndex = i - adultCount;
- const isPkgFreeChild = isPackage && isChildPassenger && childIndex >= 0 && childIndex < adultCount;
-
- let passengerTotal: number;
- let isFreeChild = false;
- if (isPackage) {
- isFreeChild = isPkgFreeChild;
- passengerTotal = isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare);
- } else {
- // Prefer this passenger's actual seat fare (varies by berth for bed coaches)
- // over the schedule's flat baseFareAdult.
- const outFare = (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0);
- const inFare = (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0);
- const onewayFare = (p as any).seatFareMinor ?? (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);
- }
+ const isFreeChild = reviewed?.isFree ?? (isChildPassenger && isFirstChild(passengers, i));
+ const passengerTotal = reviewed?.fareMinor ?? 0;
return (
@@ -334,25 +292,15 @@ export default function PaymentPage() {
{formatFare(passengerTotal, displayCurrency)}
- {isRoundTrip && (
+ {isRoundTrip && !isFreeChild && (
- Outbound {isFreeChild ? '(Free)' : ''}
- {formatFare(
- isPackage
- ? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare))
- : calculatePassengerFare(passengers, i, (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0)),
- displayCurrency
- )}
+ Outbound
+ {formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}
- Return {isFreeChild ? '(Free)' : ''}
- {formatFare(
- isPackage
- ? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare))
- : calculatePassengerFare(passengers, i, (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0)),
- displayCurrency
- )}
+ Return
+ {formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}
)}
@@ -365,7 +313,7 @@ export default function PaymentPage() {
Total
- {(!isPackage && (loadingAmount || totalAmountDisplay === null)) ? (
+ {awaitingAmount ? (
) : (
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}>
@@ -381,14 +329,14 @@ export default function PaymentPage() {
)}