UAT - total fare amount display issues resolution

This commit is contained in:
Stephanos A
2026-07-07 08:16:48 +03:00
parent 36055d0d82
commit ae0e48e6ed
4 changed files with 84 additions and 124 deletions

View File

@@ -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() {
<p className="text-sm text-gray-600 dark:text-gray-400">Total paid</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{(() => {
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';
})()}
</p>
</div>

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, 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<string | null>(null);
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
@@ -36,7 +36,7 @@ export default function PaymentPage() {
const [paymentError, setPaymentError] = useState<string | null>(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() {
<JourneyLeg schedule={selectedSchedule} label="Your journey" />
)}
{/* Fare breakdown — same first-child-free logic as the review page */}
{/* Fare breakdown — sourced directly from review page to guarantee totals match */}
<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 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 (
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
@@ -334,25 +292,15 @@ export default function PaymentPage() {
{formatFare(passengerTotal, displayCurrency)}
</span>
</div>
{isRoundTrip && (
{isRoundTrip && !isFreeChild && (
<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(
isPackage
? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare))
: calculatePassengerFare(passengers, i, (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0)),
displayCurrency
)}</span>
<span>Outbound</span>
<span>{formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
</div>
<div className="flex justify-between">
<span>Return {isFreeChild ? '(Free)' : ''}</span>
<span>{formatFare(
isPackage
? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare))
: calculatePassengerFare(passengers, i, (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0)),
displayCurrency
)}</span>
<span>Return</span>
<span>{formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
</div>
</div>
)}
@@ -365,7 +313,7 @@ export default function PaymentPage() {
<div className="flex justify-between items-center">
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
<span className="text-xl font-bold text-primary flex items-center gap-1.5">
{(!isPackage && (loadingAmount || totalAmountDisplay === null)) ? (
{awaitingAmount ? (
<Loader2 className="w-4 h-4 animate-spin text-primary" />
) : (
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
@@ -381,14 +329,14 @@ export default function PaymentPage() {
)}
<button
onClick={handlePayment}
disabled={!selectedMethod || isProcessing || (!isPackage && (loadingAmount || totalAmountDisplay === null))}
disabled={!selectedMethod || isProcessing || awaitingAmount}
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : (!isPackage && (loadingAmount || totalAmountDisplay === null)) ? (
) : awaitingAmount ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
</span>
@@ -527,7 +475,7 @@ export default function PaymentPage() {
<div className="flex items-center justify-between mb-2.5">
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
<span className="text-lg font-bold text-primary flex items-center gap-1.5">
{(!isPackage && (loadingAmount || totalAmountDisplay === null)) ? (
{awaitingAmount ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
@@ -544,14 +492,14 @@ export default function PaymentPage() {
</button>
<button
onClick={handlePayment}
disabled={!selectedMethod || isProcessing || (!isPackage && (loadingAmount || totalAmountDisplay === null))}
disabled={!selectedMethod || isProcessing || awaitingAmount}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : (!isPackage && (loadingAmount || totalAmountDisplay === null)) ? (
) : awaitingAmount ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating...
</span>

View File

@@ -47,11 +47,12 @@ function getPassengerIdFromToken(token: string): string | null {
export default function ReviewPage() {
const router = useRouter();
const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId, searchCriteria, packageId, priceTierId, packageTierPriceMinor, packageName } = useBookingStore();
const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, setReviewedTotal, createAccount, passengerId: storedPassengerId, searchCriteria, packageId, priceTierId, packageTierPriceMinor, packageName } = useBookingStore();
const { user, isAuthenticated } = useAuthStore();
const [timeLeft, setTimeLeft] = useState<string>('');
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
const [fareBreakdown, setFareBreakdown] = useState<any>(null);
const [computedTotal, setComputedTotal] = useState<number>(0);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
@@ -384,6 +385,20 @@ export default function ReviewPage() {
localStorage.setItem('deviceId', bookingData.deviceId);
}
// Persist the review-page total before navigating so payment/confirmation use the same figure
const passengerFares = passengers.map((p, i) => {
const isChildPassenger = isPackageBooking ? i >= adultPassengerCount : isChild(p);
const line = fareBreakdown?.passengers?.[i];
const isFreeChild = isPackageBooking
? (isChildPassenger && (i - adultPassengerCount) < adultPassengerCount)
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
const seatFare = getPassengerSeatFare(p);
const fareMinor = isPackageBooking
? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare))
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
return { fareMinor, isFree: isFreeChild };
});
setReviewedTotal(computedTotal, passengerFares);
await createBookingMutation.mutateAsync(bookingData);
} catch (error) {
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
@@ -507,6 +522,9 @@ export default function ReviewPage() {
return sum + (seatFare ?? line?.fareMinor ?? 0);
}, 0);
// 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) => {

View File

@@ -118,6 +118,12 @@ interface BookingState {
packageTierPriceMinor: number | null;
packageDepartureStationId: string | null;
packageDepartureStationName: string | null;
// The exact total (minor units, ETB) computed on the review page — used as the
// single source of truth on payment and confirmation pages to prevent drift.
reviewedTotalMinor: number | null;
// Per-passenger fare breakdown computed on the review page — guarantees line items
// on the payment page sum to exactly reviewedTotalMinor.
reviewedPassengerFares: Array<{ fareMinor: number; isFree: boolean }> | null;
setSearchCriteria: (criteria: SearchCriteria) => void;
setSelectedSchedule: (schedule: SelectedSchedule) => void;
@@ -131,6 +137,7 @@ interface BookingState {
setCreateAccount: (create: boolean) => void;
setPassengerId: (id: string | null) => void;
setPackageContext: (packageId: string, priceTierId: string, priceMinor: number, packageName?: string, departureStationId?: string, departureStationName?: string) => void;
setReviewedTotal: (totalMinor: number, passengerFares: Array<{ fareMinor: number; isFree: boolean }>) => void;
clearBooking: () => void;
}
@@ -153,6 +160,8 @@ export const useBookingStore = create<BookingState>()(persist(
packageTierPriceMinor: null,
packageDepartureStationId: null,
packageDepartureStationName: null,
reviewedTotalMinor: null,
reviewedPassengerFares: null,
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }),
setPackageContext: (packageId, priceTierId, priceMinor, packageName, departureStationId, departureStationName) => set({ packageId, packageName: packageName ?? null, priceTierId, packageTierPriceMinor: priceMinor, packageDepartureStationId: departureStationId ?? null, packageDepartureStationName: departureStationName ?? null }),
@@ -166,6 +175,7 @@ export const useBookingStore = create<BookingState>()(persist(
setPaymentMethod: (method) => set({ selectedPaymentMethod: method }),
setCreateAccount: (create) => set({ createAccount: create }),
setPassengerId: (id) => set({ passengerId: id }),
setReviewedTotal: (totalMinor, passengerFares) => set({ reviewedTotalMinor: totalMinor, reviewedPassengerFares: passengerFares }),
clearBooking: () => set({
searchCriteria: null,
selectedSchedule: null,
@@ -184,6 +194,8 @@ export const useBookingStore = create<BookingState>()(persist(
packageTierPriceMinor: null,
packageDepartureStationId: null,
packageDepartureStationName: null,
reviewedTotalMinor: null,
reviewedPassengerFares: null,
}),
} as BookingState)),
{