mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -42,11 +42,7 @@ export class PassengerAuthService {
|
||||
}
|
||||
|
||||
async register(dto: RegisterDto, req: any) {
|
||||
const existing = await this.dataSource.query<{ id: string }[]>(
|
||||
`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');
|
||||
await this.clearPendingOrConflict(dto.email, dto.phoneNumber);
|
||||
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
|
||||
@@ -101,11 +97,7 @@ export class PassengerAuthService {
|
||||
},
|
||||
req: any,
|
||||
): Promise<{ iamUserId: string; passengerId: string }> {
|
||||
const existing = await this.dataSource.query<{ id: string }[]>(
|
||||
`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');
|
||||
await this.clearPendingOrConflict(dto.email, dto.phoneNumber);
|
||||
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
await iamAuthService.signupWithPassword({
|
||||
@@ -600,6 +592,32 @@ export class PassengerAuthService {
|
||||
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> {
|
||||
try {
|
||||
const rows = await this.dataSource.query<{ id: string }[]>(
|
||||
|
||||
@@ -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, 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,14 +92,32 @@ 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
|
||||
?? _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';
|
||||
|
||||
// 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 = {
|
||||
trainNumber: activeSchedule?.trainNumber || 'N/A',
|
||||
trainName: 'EDR Express',
|
||||
@@ -137,7 +155,7 @@ export default function ConfirmationPage() {
|
||||
outboundSchedule: outbound,
|
||||
inboundSchedule: inbound,
|
||||
isRoundTrip,
|
||||
fareMinor: farePerPassenger,
|
||||
fareMinor: getVoucherFare(i),
|
||||
currency: voucherCurrency,
|
||||
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="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>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useState, useEffect } from "react";
|
||||
import { PaymentMethod } from "@/types";
|
||||
import { format } from "date-fns";
|
||||
import { formatTime, getTimePeriod } from '@/utils/format';
|
||||
import { calculatePassengerFare, isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
||||
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
||||
import {
|
||||
CreditCard,
|
||||
Smartphone,
|
||||
@@ -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, 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>
|
||||
|
||||
@@ -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.');
|
||||
@@ -459,14 +474,6 @@ export default function ReviewPage() {
|
||||
fetchFareBreakdown(scheduleId, 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;
|
||||
// packageTierPriceMinor is the per-adult fare for ONE leg.
|
||||
// Round-trip packages multiply by 2.
|
||||
@@ -507,6 +514,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) => {
|
||||
|
||||
@@ -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)),
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user