This commit is contained in:
Roba Boru
2026-07-07 14:26:59 +03:00
5 changed files with 133 additions and 145 deletions

View File

@@ -42,11 +42,7 @@ export class PassengerAuthService {
} }
async register(dto: RegisterDto, req: any) { async register(dto: RegisterDto, req: any) {
const existing = await this.dataSource.query<{ id: string }[]>( await this.clearPendingOrConflict(dto.email, dto.phoneNumber);
`SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`,
[dto.email, dto.phoneNumber],
);
if (existing.length) throw new ConflictException('Email or phone already registered');
const iamAuthService = await this.resolveIamAuthService(req); const iamAuthService = await this.resolveIamAuthService(req);
@@ -101,11 +97,7 @@ export class PassengerAuthService {
}, },
req: any, req: any,
): Promise<{ iamUserId: string; passengerId: string }> { ): Promise<{ iamUserId: string; passengerId: string }> {
const existing = await this.dataSource.query<{ id: string }[]>( await this.clearPendingOrConflict(dto.email, dto.phoneNumber);
`SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`,
[dto.email, dto.phoneNumber],
);
if (existing.length) throw new ConflictException('Email or phone already registered');
const iamAuthService = await this.resolveIamAuthService(req); const iamAuthService = await this.resolveIamAuthService(req);
await iamAuthService.signupWithPassword({ await iamAuthService.signupWithPassword({
@@ -600,6 +592,32 @@ export class PassengerAuthService {
return `+${digits}`; return `+${digits}`;
} }
/**
* Pre-signup uniqueness guard. Throws `ConflictException` only when a
* *fully-registered* account (`has_set_password = true`) already owns the
* email or phone. Abandoned PENDING signups — where the user received the OTP
* but never completed `set-password` — are deleted so this fresh attempt can
* re-create the account and re-send the code, instead of being blocked with a
* 409 forever. Matches `resendRegistrationCode`'s `has_set_password = false`
* notion of "still pending".
*/
private async clearPendingOrConflict(email: string, phoneNumber: string): Promise<void> {
const matches = await this.dataSource.query<
{ id: string; email: string; has_set_password: boolean }[]
>(
`SELECT id, email, has_set_password FROM iam.users WHERE email = $1 OR phone_number = $2`,
[email, phoneNumber],
);
if (!matches.length) return;
if (matches.some((u) => u.has_set_password)) {
throw new ConflictException('Email or phone already registered');
}
// Every match is an abandoned pending signup — clean it up so the caller can proceed.
for (const u of matches) {
await this.compensateIamSignup(u.email);
}
}
private async compensateIamSignup(email: string): Promise<void> { private async compensateIamSignup(email: string): Promise<void> {
try { try {
const rows = await this.dataSource.query<{ id: string }[]>( const rows = await this.dataSource.query<{ id: string }[]>(

View File

@@ -10,7 +10,7 @@ import { apiClient } from '@/lib/api-client';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { CheckCircle, Copy, Train, FileText } from 'lucide-react'; import { CheckCircle, Copy, Train, FileText } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { isChild, isFirstChild, calculatePassengerFare } from '@/utils/fare-utils'; import { isChild, isFirstChild } from '@/utils/fare-utils';
type BookingWithTicket = { type BookingWithTicket = {
id: string; id: string;
@@ -27,7 +27,7 @@ type BookingWithTicket = {
export default function ConfirmationPage() { export default function ConfirmationPage() {
const router = useRouter(); 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, packageId, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore();
// The currency/amount actually confirmed for the payment option the user selected — // 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). // null when no payment step ran (e.g. a fully-discounted, zero-amount booking).
const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore(); const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore();
@@ -92,14 +92,32 @@ export default function ConfirmationPage() {
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule; const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
// Prefer the amount/currency actually confirmed for the selected payment option; // 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). // only fall back to the ETB booking fare when no payment step ran (e.g. $0 total).
const totalFare = paidAmountMinor const voucherCurrency = 'ETB';
?? _booking?.totalMinor
?? passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0);
const voucherCurrency = paidAmountMinor != null ? paidCurrency : 'ETB';
const farePerPassenger = Math.round(totalFare / passengers.length);
const createdAt = _booking?.createdAt || new Date().toISOString(); const createdAt = _booking?.createdAt || new Date().toISOString();
const status = _booking?.status || 'CONFIRMED'; const status = _booking?.status || 'CONFIRMED';
// Compute per-passenger fares using the same logic as the review/payment pages.
// reviewedPassengerFares is the authoritative source; rebuild from package context
// as a fallback so free children always show ETB 0.00 on their voucher.
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 pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgMultiplier : 0;
const pkgChildFare = pkgAdultFare;
const getVoucherFare = (idx: number): number => {
if (reviewedPassengerFares?.[idx] != null) return reviewedPassengerFares[idx].fareMinor;
if (isPackageBooking) {
const isPkgChild = idx >= adultCount;
const isFreeChild = isPkgChild && (idx - adultCount) < adultCount;
if (isFreeChild) return 0;
return isPkgChild ? pkgChildFare : pkgAdultFare;
}
const totalFare = reviewedTotalMinor ?? paidAmountMinor ?? _booking?.totalMinor ?? 0;
return Math.round(totalFare / passengers.length);
};
const outbound = { const outbound = {
trainNumber: activeSchedule?.trainNumber || 'N/A', trainNumber: activeSchedule?.trainNumber || 'N/A',
trainName: 'EDR Express', trainName: 'EDR Express',
@@ -137,7 +155,7 @@ export default function ConfirmationPage() {
outboundSchedule: outbound, outboundSchedule: outbound,
inboundSchedule: inbound, inboundSchedule: inbound,
isRoundTrip, isRoundTrip,
fareMinor: farePerPassenger, fareMinor: getVoucherFare(i),
currency: voucherCurrency, currency: voucherCurrency,
createdAt, createdAt,
}); });
@@ -332,28 +350,10 @@ export default function ConfirmationPage() {
<p className="text-sm text-gray-600 dark:text-gray-400">Total paid</p> <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"> <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 (paidAmountMinor != null) return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`; if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
// Recompute the same way the payment page does return 'ETB 0.00';
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)}`;
})()} })()}
</p> </p>
</div> </div>

View File

@@ -9,7 +9,7 @@ import { useState, useEffect } from "react";
import { PaymentMethod } from "@/types"; import { PaymentMethod } from "@/types";
import { format } from "date-fns"; import { format } from "date-fns";
import { formatTime, getTimePeriod } from '@/utils/format'; import { formatTime, getTimePeriod } from '@/utils/format';
import { calculatePassengerFare, isChild, isFirstChild, formatFare } from '@/utils/fare-utils'; import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
import { import {
CreditCard, CreditCard,
Smartphone, Smartphone,
@@ -28,7 +28,7 @@ const getIconForMethod = (methodId: string) => {
export default function PaymentPage() { export default function PaymentPage() {
const router = useRouter(); const router = useRouter();
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, packageTierPriceMinor } = useBookingStore(); const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore();
const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore(); const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null); const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [selectedMethodCurrency, setSelectedMethodCurrency] = 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 [paymentError, setPaymentError] = useState<string | null>(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const isPackage = !!packageTierPriceMinor; const isPackage = !!packageName;
const displayCurrency = 'ETB' as const; const displayCurrency = 'ETB' as const;
@@ -61,66 +61,42 @@ export default function PaymentPage() {
enabled: !!bookingId, enabled: !!bookingId,
}); });
// Per-leg totals across all passengers. // Per-leg subtotals for the journey header — sum each paying passenger's reviewed fare
// First child per adult = FREE (no seat); additional children = full adult fare. // split equally across both legs. This guarantees leg totals are consistent with the
const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; // per-passenger breakdown rows and the overall reviewed total.
const childCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; const outboundBaseFare = isRoundTrip
const pkgPaidChildrenCount = Math.max(0, childCount - adultCount); ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1; : 0;
const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; const inboundBaseFare = isRoundTrip
const pkgChildFare = pkgAdultFare; // paid children pay full adult fare ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
const pkgPerLegAdultFare = isPackage ? packageTierPriceMinor! : 0; : 0;
const pkgPerLegChildFare = pkgPerLegAdultFare; // paid children pay full adult fare per leg
const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + pkgPaidChildrenCount * pkgPerLegChildFare : 0;
// Prefer each passenger's own seat fare (set during seat selection) over the schedule's // reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display —
// flat baseFareAdult — bed coaches price Upper/Middle/Lower berths differently, so a // they were computed and shown to the user on the review page, so the Total here must match.
// single schedule-level fare can't correctly represent every passenger's actual seat. // The API booking-amount is used only as the charge amount sent to the payment provider.
const outboundBaseFare = isPackage const reviewedTotal = reviewedTotalMinor ?? (reviewedPassengerFares?.reduce((s, f) => s + f.fareMinor, 0) ?? null);
? pkgPerLegTotal const totalAmountDisplay = reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null);
: (isRoundTrip && outboundSchedule ? passengers.reduce((sum, p, i) => { const totalAmount = bookingAmountData != null
const fare = (p as any).outboundSeatFareMinor ?? (outboundSchedule.baseFareAdult || 0); ? Math.round(bookingAmountData.amount * 100)
return sum + calculatePassengerFare(passengers, i, fare); : (reviewedTotal ?? 0);
}, 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;
const confirmedCurrency = bookingAmountData?.currency || amountCurrency; const confirmedCurrency = bookingAmountData?.currency || amountCurrency;
// Persist the amount/currency actually confirmed for the selected payment option so // Show loading spinner only when the API hasn't responded AND we have no review-page
// downstream screens (e.g. the voucher) use it instead of a default ETB fare. // total to fall back on — once reviewedTotalMinor is set the button is always enabled.
const awaitingAmount = !isPackage && loadingAmount && totalAmountDisplay === null;
useEffect(() => { 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'); setCurrency('ETB');
setPaidAmount(totalAmount); setPaidAmount(reviewedTotal);
} else if (bookingAmountData != null) { } else if (bookingAmountData != null) {
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD'); 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({ const paymentMutation = useMutation({
mutationFn: async (data: any) => { mutationFn: async (data: any) => {
@@ -290,32 +266,14 @@ export default function PaymentPage() {
<JourneyLeg schedule={selectedSchedule} label="Your journey" /> <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"> <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> <h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
{passengers.map((p, i) => { {passengers.map((p, i) => {
const reviewed = reviewedPassengerFares?.[i];
const isChildPassenger = isChild(p); const isChildPassenger = isChild(p);
// For package bookings: children ordered after adults; first adultCount children are free const isFreeChild = reviewed?.isFree ?? (isChildPassenger && isFirstChild(passengers, i));
const childIndex = i - adultCount; const passengerTotal = reviewed?.fareMinor ?? 0;
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);
}
return ( return (
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0"> <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)} {formatFare(passengerTotal, displayCurrency)}
</span> </span>
</div> </div>
{isRoundTrip && ( {isRoundTrip && !isFreeChild && (
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400"> <div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between"> <div className="flex justify-between">
<span>Outbound {isFreeChild ? '(Free)' : ''}</span> <span>Outbound</span>
<span>{formatFare( <span>{formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
isPackage
? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare))
: calculatePassengerFare(passengers, i, (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0)),
displayCurrency
)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>Return {isFreeChild ? '(Free)' : ''}</span> <span>Return</span>
<span>{formatFare( <span>{formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
isPackage
? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare))
: calculatePassengerFare(passengers, i, (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0)),
displayCurrency
)}</span>
</div> </div>
</div> </div>
)} )}
@@ -365,7 +313,7 @@ export default function PaymentPage() {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span> <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"> <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" /> <Loader2 className="w-4 h-4 animate-spin text-primary" />
) : ( ) : (
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</> <>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
@@ -381,14 +329,14 @@ export default function PaymentPage() {
)} )}
<button <button
onClick={handlePayment} 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" className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isProcessing ? ( {isProcessing ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Processing... <Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span> </span>
) : (!isPackage && (loadingAmount || totalAmountDisplay === null)) ? ( ) : awaitingAmount ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount... <Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
</span> </span>
@@ -527,7 +475,7 @@ export default function PaymentPage() {
<div className="flex items-center justify-between mb-2.5"> <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-sm text-gray-600 dark:text-gray-400">Total</span>
<span className="text-lg font-bold text-primary flex items-center gap-1.5"> <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" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />
) : ( ) : (
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</> <>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
@@ -544,14 +492,14 @@ export default function PaymentPage() {
</button> </button>
<button <button
onClick={handlePayment} 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" className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isProcessing ? ( {isProcessing ? (
<span className="flex items-center justify-center gap-1.5"> <span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Processing... <Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span> </span>
) : (!isPackage && (loadingAmount || totalAmountDisplay === null)) ? ( ) : awaitingAmount ? (
<span className="flex items-center justify-center gap-1.5"> <span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating... <Loader2 className="w-4 h-4 animate-spin" /> Calculating...
</span> </span>

View File

@@ -47,11 +47,12 @@ function getPassengerIdFromToken(token: string): string | null {
export default function ReviewPage() { export default function ReviewPage() {
const router = useRouter(); 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 { user, isAuthenticated } = useAuthStore();
const [timeLeft, setTimeLeft] = useState<string>(''); const [timeLeft, setTimeLeft] = useState<string>('');
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({}); const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
const [fareBreakdown, setFareBreakdown] = useState<any>(null); const [fareBreakdown, setFareBreakdown] = useState<any>(null);
const [computedTotal, setComputedTotal] = useState<number>(0);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
@@ -384,6 +385,20 @@ export default function ReviewPage() {
localStorage.setItem('deviceId', bookingData.deviceId); 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); await createBookingMutation.mutateAsync(bookingData);
} catch (error) { } catch (error) {
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.'); alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
@@ -459,14 +474,6 @@ export default function ReviewPage() {
fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId); fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId);
}, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]); }, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]);
if (isRoundTrip && (!outboundSchedule || !inboundSchedule || !passengers.length)) {
return null;
}
if (!isRoundTrip && (!selectedSchedule || !passengers.length)) {
return null;
}
const isPackageBooking = packageTierPriceMinor !== null; const isPackageBooking = packageTierPriceMinor !== null;
// packageTierPriceMinor is the per-adult fare for ONE leg. // packageTierPriceMinor is the per-adult fare for ONE leg.
// Round-trip packages multiply by 2. // Round-trip packages multiply by 2.
@@ -507,6 +514,9 @@ export default function ReviewPage() {
return sum + (seatFare ?? line?.fareMinor ?? 0); return sum + (seatFare ?? line?.fareMinor ?? 0);
}, 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. // 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). // Children are ordered after adults in the passengers array (set on package detail page).
const isPkgFreeChild = (index: number) => { const isPkgFreeChild = (index: number) => {

View File

@@ -118,6 +118,12 @@ interface BookingState {
packageTierPriceMinor: number | null; packageTierPriceMinor: number | null;
packageDepartureStationId: string | null; packageDepartureStationId: string | null;
packageDepartureStationName: 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; setSearchCriteria: (criteria: SearchCriteria) => void;
setSelectedSchedule: (schedule: SelectedSchedule) => void; setSelectedSchedule: (schedule: SelectedSchedule) => void;
@@ -131,6 +137,7 @@ interface BookingState {
setCreateAccount: (create: boolean) => void; setCreateAccount: (create: boolean) => void;
setPassengerId: (id: string | null) => void; setPassengerId: (id: string | null) => void;
setPackageContext: (packageId: string, priceTierId: string, priceMinor: number, packageName?: string, departureStationId?: string, departureStationName?: string) => 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; clearBooking: () => void;
} }
@@ -153,6 +160,8 @@ export const useBookingStore = create<BookingState>()(persist(
packageTierPriceMinor: null, packageTierPriceMinor: null,
packageDepartureStationId: null, packageDepartureStationId: null,
packageDepartureStationName: null, packageDepartureStationName: null,
reviewedTotalMinor: null,
reviewedPassengerFares: null,
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }), 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 }), 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 }), setPaymentMethod: (method) => set({ selectedPaymentMethod: method }),
setCreateAccount: (create) => set({ createAccount: create }), setCreateAccount: (create) => set({ createAccount: create }),
setPassengerId: (id) => set({ passengerId: id }), setPassengerId: (id) => set({ passengerId: id }),
setReviewedTotal: (totalMinor, passengerFares) => set({ reviewedTotalMinor: totalMinor, reviewedPassengerFares: passengerFares }),
clearBooking: () => set({ clearBooking: () => set({
searchCriteria: null, searchCriteria: null,
selectedSchedule: null, selectedSchedule: null,
@@ -184,6 +194,8 @@ export const useBookingStore = create<BookingState>()(persist(
packageTierPriceMinor: null, packageTierPriceMinor: null,
packageDepartureStationId: null, packageDepartureStationId: null,
packageDepartureStationName: null, packageDepartureStationName: null,
reviewedTotalMinor: null,
reviewedPassengerFares: null,
}), }),
} as BookingState)), } as BookingState)),
{ {