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() { )}