diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index 1784261e5..2b15cdc7c 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -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 { + 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 { try { const rows = await this.dataSource.query<{ id: string }[]>( 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..e77ab70b4 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, 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() {

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..0b27dfa87 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 @@ -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(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() { )}