diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 23a3b6c5a..7789fd56b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -441,6 +441,36 @@ export class BookingsController { return this.service.checkBookingUsage(id); } + @Get('by-phone') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'Find bookings by phone number (no auth required)', + description: `Returns all bookings where the contact phone matches the provided number. +Accepts Ethiopian local format (09XXXXXXXX) and international format (+251XXXXXXXXX). +Results are ordered most-recent first. Use the returned \`bookingRef\` to open booking detail.` + }) + @ApiQuery({ name: 'phone', required: true, description: 'Phone number in local (09…) or international (+251…) format' }) + @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) + @ApiResponse({ status: 200, description: 'Paginated list of bookings for this phone number' }) + @ApiResponse({ status: 400, description: 'Phone number missing or invalid' }) + findByPhone( + @Query('phone') phone?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + if (!phone?.trim()) throw new BadRequestException('Phone number is required'); + const digits = phone.replace(/[^\d]/g, ''); + if (digits.length < 7) throw new BadRequestException('Phone number is too short'); + return this.service.findByPhone(phone.trim(), { + status, + page: page ? parseInt(page) : 1, + pageSize: pageSize ? parseInt(pageSize) : 20, + }); + } + @Get(':bookingRef') @SetMetadata('isPublic', true) @ApiOperation({ diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index b80593cd5..d9c393ee7 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -39,6 +39,37 @@ function resolvePackageRoundTripTotal( return adultCount * adultFareMinor + paidChildren * adultFareMinor; } +/** + * Returns all plausible normalised variants of a raw phone string so that the + * DB query matches regardless of how the number was stored (local 09… vs international +251…). + * Returns an empty array when the input is clearly invalid (< 7 digits). + */ +function normalizePhoneVariants(raw: string): string[] { + // Strip whitespace, dashes, dots, parentheses — keep digits and a leading + + const stripped = raw.replace(/[^\d+]/g, ''); + const digits = stripped.replace(/^\+/, ''); + if (digits.length < 7) return []; + + const variants = new Set([stripped]); + + if (stripped.startsWith('+251') && digits.length === 12) { + // +251 9XXXXXXXX → 09XXXXXXXX + variants.add('0' + digits.slice(3)); + } else if (stripped.startsWith('251') && digits.length === 12) { + // 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX + variants.add('+' + stripped); + variants.add('0' + digits.slice(3)); + } else if (stripped.startsWith('0') && digits.length === 10) { + // 09XXXXXXXX → +251 9XXXXXXXX + variants.add('+251' + digits.slice(1)); + } else if (!stripped.startsWith('+') && digits.length >= 9) { + // bare international digits without + + variants.add('+' + digits); + } + + return [...variants]; +} + function calculateAge(dateOfBirth: Date): number { const today = new Date(); let age = today.getFullYear() - dateOfBirth.getFullYear(); @@ -145,6 +176,70 @@ export class BookingsService { }; } + async findByPhone(rawPhone: string, filters: BookingFilters = {}) { + const variants = normalizePhoneVariants(rawPhone); + if (variants.length === 0) return { items: [], meta: { page: 1, pageSize: 20, total: 0, totalPages: 0 } }; + + const { status, page = 1, pageSize = 20 } = filters; + const skip = (page - 1) * pageSize; + + const where: any = { + OR: [ + { contactPhone: { in: variants } }, + { passenger: { user: { phone: { in: variants } } } }, + ], + }; + if (status) where.status = status; + + const [items, total] = await Promise.all([ + this.prisma.booking.findMany({ + where, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } }, + seats: { select: { id: true } }, + priceTier: { select: { priceMinor: true } }, + }, + }), + this.prisma.booking.count({ where }), + ]); + + return { + items: items.map(booking => ({ + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), + currency: 'ETB', + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + adultCount: booking.adultCount, + childCount: booking.childCount, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, + createdAt: booking.createdAt, + schedule: { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + arrivalAt: booking.schedule.arrivalAt, + }, + payment: booking.paymentIntent ?? undefined, + seatCount: booking.seats.length, + })), + meta: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }; + } + async findByDeviceId(deviceId: string, filters: BookingFilters = {}) { const { search, status, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; @@ -1516,7 +1611,12 @@ export class BookingsService { seat: null, })), payment: (pkgBooking as any).paymentIntent - ? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status } + ? { + method: (pkgBooking as any).paymentIntent.method, + status: (pkgBooking as any).paymentIntent.status, + amountMinor: (pkgBooking as any).paymentIntent.amountMinor, + currency: (pkgBooking as any).paymentIntent.currency, + } : undefined, tickets: [], }; @@ -1556,7 +1656,14 @@ export class BookingsService { seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null, }, })), - payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status } : undefined, + payment: (booking as any).paymentIntent + ? { + method: (booking as any).paymentIntent.method, + status: (booking as any).paymentIntent.status, + amountMinor: (booking as any).paymentIntent.amountMinor, + currency: (booking as any).paymentIntent.currency, + } + : undefined, // One ticket per passenger — matched on the frontend by passengerName, not array // position, since tickets are grouped/created independently of the passengers array. tickets: (booking as any).tickets?.map((t: any) => ({ 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 8ac4bf046..48e29968f 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 @@ -7,7 +7,7 @@ import { useBookingStore } from '@/lib/booking-store'; import { usePaymentStore } from '@/lib/payment-store'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState } from 'react'; import { CheckCircle, Clock, Copy, Train, FileText } from 'lucide-react'; import { format } from 'date-fns'; import { isChild, isFirstChild } from '@/utils/fare-utils'; @@ -19,6 +19,14 @@ type BookingWithTicket = { totalMinor?: number; createdAt?: string; paymentMethod?: string; + // The actual settled amount/currency for this booking's payment — authoritative over any + // client-side session state, since it reflects what was really charged server-side. + payment?: { + method?: string; + status?: string; + amountMinor?: number; + currency?: string; + }; // One ticket per passenger — match by passengerName, not array position (see // bookings.service.ts's getByRef). tickets?: Array<{ @@ -37,7 +45,6 @@ export default function ConfirmationPage() { const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const [copied, setCopied] = useState(false); const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); - const confirmAttempted = useRef(false); // Warms the code-split voucher module ahead of the click so the handler's own // `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered @@ -66,22 +73,11 @@ export default function ConfirmationPage() { // Only trust an actually-confirmed booking to show ticket numbers / a "CONFIRMED" badge — // a gateway redirect back here does not mean payment succeeded (see payment return pages). + // Ticket generation itself is never triggered from this page — the payment webhook + // generates it server-side (for every payment method, wallet included); this page only + // ever fetches and displays whatever the booking query above already returns. const isConfirmed = _booking?.status === 'CONFIRMED'; - useEffect(() => { - if (bookingId && !confirmAttempted.current) { - confirmAttempted.current = true; - - // Only generate ticket if booking is already CONFIRMED (e.g. wallet payment) - // For other payment methods, ticket is generated by the payment webhook after payment completes - apiClient.get(`/bookings/${bookingId}`).then((data: any) => { - if (data?.status === 'CONFIRMED') { - apiClient.post(`/tickets/generate/${bookingId}`).catch(() => {}); - } - }).catch(() => {}); - } - }, [bookingId]); - const copyPNR = () => { if (pnr) { navigator.clipboard.writeText(pnr); @@ -105,15 +101,17 @@ export default function ConfirmationPage() { const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher'); 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 voucherCurrency = 'ETB'; + // The server-confirmed settled amount/currency (what was actually charged) is + // authoritative — prefer it over the ETB booking fare once it's available. + const settledAmountMinor = _booking?.payment?.amountMinor; + const settledCurrency = _booking?.payment?.currency; + const voucherCurrency = settledCurrency || '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. + // Compute per-passenger fares (in ETB) 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 0 on their voucher. const { packageTierPriceMinor } = useBookingStore.getState(); const isPackageBooking = packageTierPriceMinor != null; const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; @@ -121,7 +119,7 @@ export default function ConfirmationPage() { const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgMultiplier : 0; const pkgChildFare = pkgAdultFare; - const getVoucherFare = (idx: number): number => { + const getEtbFare = (idx: number): number => { if (reviewedPassengerFares?.[idx] != null) return reviewedPassengerFares[idx].fareMinor; if (isPackageBooking) { const isPkgChild = idx >= adultCount; @@ -133,6 +131,17 @@ export default function ConfirmationPage() { return Math.round(totalFare / passengers.length); }; + // Real conversion happened (payment settled in something other than ETB) — scale each + // passenger's ETB fare proportionally into the settled currency, rather than showing + // ETB-denominated numbers next to a foreign currency label. + const etbFares = passengers.map((_, idx) => getEtbFare(idx)); + const etbTotal = etbFares.reduce((sum, f) => sum + f, 0); + const needsConversion = settledAmountMinor != null && settledCurrency && settledCurrency !== 'ETB' && etbTotal > 0; + const getVoucherFare = (idx: number): number => { + if (!needsConversion) return etbFares[idx]; + return Math.round(etbFares[idx] * (settledAmountMinor! / etbTotal)); + }; + const outbound = { trainNumber: activeSchedule?.trainNumber || 'N/A', trainName: 'EDR Express', @@ -390,6 +399,11 @@ export default function ConfirmationPage() {

Total paid

{(() => { + // The server-confirmed settled amount is authoritative — prefer it over + // any client-side session state, which can go stale (e.g. after a refresh). + if (_booking?.payment?.amountMinor != null) { + return `${_booking.payment.currency || 'ETB'} ${(_booking.payment.amountMinor / 100).toFixed(2)}`; + } 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)}`; diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index 92df23f23..8af27b035 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -633,6 +633,20 @@ function BookingDetailContent() { + {isConfirmed && ( +

+ Total paid:{' '} + + {booking?.payment?.amountMinor != null + ? `${booking.payment.currency || 'ETB'} ${(booking.payment.amountMinor / 100).toFixed(2)}` + : `ETB ${((booking?.totalMinor ?? 0) / 100).toFixed(2)}`} + + {booking?.payment?.method && ( + via {booking.payment.method} + )} +
+ )} +
{isConfirmed && ( <> diff --git a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx index e65882fd2..da369fb54 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx @@ -1,28 +1,99 @@ "use client"; -import { Search } from "lucide-react"; +import { Search, Phone, Ticket, ChevronRight, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { apiClient } from "@/lib/api-client"; +import { format } from "date-fns"; + +type SearchMode = "pnr" | "phone"; + +interface BookingListItem { + id: string; + bookingRef: string; + status: string; + totalMinor: number; + currency: string; + adultCount: number; + childCount: number; + bookingType: string; + createdAt: string; + schedule: { + originStation: { name: string; city?: string }; + destinationStation: { name: string; city?: string }; + departureAt: string; + }; + payment?: { method: string; status: string }; + seatCount: number; +} + +const STATUS_LABELS: Record = { + CONFIRMED: { label: "Confirmed", className: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" }, + PENDING_PAYMENT: { label: "Pending Payment", className: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" }, + CANCELLED: { label: "Cancelled", className: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300" }, + BOARDED: { label: "Boarded", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" }, + NO_SHOW: { label: "No Show", className: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300" }, + REFUNDED: { label: "Refunded", className: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300" }, +}; export default function BookingLookupPage() { const router = useRouter(); + const [mode, setMode] = useState("pnr"); + + // PNR mode state const [bookingRef, setBookingRef] = useState(""); + + // Phone mode state + const [phone, setPhone] = useState(""); + const [phoneResults, setPhoneResults] = useState(null); + const [phoneLoading, setPhoneLoading] = useState(false); + const [error, setError] = useState(""); - const handleSubmit = (e: React.FormEvent) => { + // ── PNR submit ─────────────────────────────────────────────────────────── + const handlePnrSubmit = (e: React.FormEvent) => { e.preventDefault(); const trimmed = bookingRef.trim().toUpperCase(); - if (!trimmed) { - setError("Please enter a booking reference"); - return; - } + if (!trimmed) { setError("Please enter a booking reference"); return; } router.push(`/booking/detail?ref=${trimmed}`); }; + // ── Phone submit ───────────────────────────────────────────────────────── + const handlePhoneSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = phone.trim(); + if (!trimmed) { setError("Please enter your phone number"); return; } + const digits = trimmed.replace(/[^\d]/g, ""); + if (digits.length < 7) { setError("Please enter a valid phone number"); return; } + + setError(""); + setPhoneLoading(true); + setPhoneResults(null); + try { + const resp: any = await apiClient.get(`/bookings/by-phone?phone=${encodeURIComponent(trimmed)}`); + const items: BookingListItem[] = (resp as any)?.data?.items ?? (resp as any)?.items ?? []; + setPhoneResults(items); + if (items.length === 0) setError("No bookings found for this phone number"); + } catch { + setError("Could not look up bookings. Please check your number and try again."); + } finally { + setPhoneLoading(false); + } + }; + + const switchMode = (next: SearchMode) => { + setMode(next); + setError(""); + setPhoneResults(null); + setBookingRef(""); + setPhone(""); + }; + return (
+ {/* Header */}
@@ -30,39 +101,145 @@ export default function BookingLookupPage() {

Find Your Booking

-

- Enter your booking reference (PNR) to view details +

+ Search by booking reference or phone number

-
-
- - { - setBookingRef(e.target.value.toUpperCase()); - setError(""); - }} - placeholder="Enter your PNR" - className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent dark:bg-gray-700 dark:text-white text-lg font-mono" - /> - {error && ( -

{error}

- )} -
- + {/* Mode tabs */} +
- + +
+ + {/* ── PNR form ── */} + {mode === "pnr" && ( +
+
+ + { setBookingRef(e.target.value.toUpperCase()); setError(""); }} + placeholder="e.g. ABCXYZ" + className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent dark:bg-gray-700 dark:text-white text-lg font-mono uppercase tracking-widest" + /> + {error &&

{error}

} +
+ +
+ )} + + {/* ── Phone form ── */} + {mode === "phone" && ( + <> +
+
+ + { setPhone(e.target.value); setError(""); setPhoneResults(null); }} + placeholder="Enter phone number" + className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent dark:bg-gray-700 dark:text-white text-lg" + /> +

+ Enter the phone number you used when booking +

+ {error &&

{error}

} +
+ +
+ + {/* Results list */} + {phoneResults !== null && phoneResults.length > 0 && ( +
+

+ {phoneResults.length} booking{phoneResults.length !== 1 ? "s" : ""} found — select one to view details: +

+ {phoneResults.map((b) => { + const statusInfo = STATUS_LABELS[b.status] ?? { label: b.status, className: "bg-gray-100 text-gray-700" }; + const amountEtb = (b.totalMinor / 100).toLocaleString("en-ET", { minimumFractionDigits: 2 }); + return ( + + ); + })} +
+ )} + + )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 64f19fdb4..4639bd196 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -465,8 +465,10 @@ function validatePhone(phone: string, nationality: string): string | null { if (!normalized) return 'Phone number is required'; const nat = getPhoneNat(nationality); if (nat === 'ETHIOPIAN') { - if (/^(\+251\d{9}|09\d{8})$/.test(normalized)) return null; - return 'Invalid Ethiopian phone number (e.g., +251912345678 or 0912345678)'; + // Only Ethio Telecom (0/+2519...) and Safaricom Ethiopia (0/+2517...) mobile ranges — + // other prefixes (e.g. landlines, unallocated blocks) are rejected. + if (/^(\+251[79]\d{8}|0[79]\d{8})$/.test(normalized)) return null; + return 'Enter a valid Ethio Telecom or Safaricom Ethiopia number (e.g., +251912345678 or 0712345678)'; } if (nat === 'DJIBOUTIAN') { if (/^\+253\d{8}$/.test(normalized)) return null; diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index ab93c1cf6..8b76721c2 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -313,19 +313,15 @@ export default function ReviewPage() { } // Build booking request for authenticated users - // Package bookings only: free children (first child per adult) don't go through - // seat selection and have no seatId, so they're excluded here — the backend derives - // them from adultCount/childCount instead. Regular bookings DO seat every passenger - // (including the free child, who still gets a real seatId and a $0 fare handled by - // the backend), so they must stay in the array or that passenger — and their - // ticket/seat/childCount — silently never gets created. - const bookingPassengers = passengers.filter((_p, i) => { - if (packageId) { - const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; - return !isFreePkgChild; - } - return true; - }); + // Free children (first child per adult) never go through seat selection and have no + // seatId — true for package bookings AND regular ones (see booking/seats/page.tsx's + // seatEligibility: "First adultCount children are free (no seat)... same rule applies" + // for regular bookings too). Submitting one anyway sends seatId: undefined, which the + // backend's `seat: { connect: { id } }` rejects — hence excluding them here for both + // cases. (Their absence from adultCount/childCount on the confirmed booking is a + // separate, backend-side gap — not something the frontend can paper over by sending + // an unseated passenger.) + const bookingPassengers = passengers.filter((p, i) => !(isChild(p) && isFirstChild(passengers, i))); bookingData = { passengerId: passengerId, @@ -375,19 +371,12 @@ export default function ReviewPage() { if (priceTierId) bookingData.priceTierId = priceTierId; } else { // For guests: send full passenger details array - // Package bookings only: free children (first child per adult) don't go through - // seat selection and have no seatId, so they're excluded here — the backend derives - // them from adultCount/childCount instead. Regular bookings DO seat every passenger - // (including the free child, who still gets a real seatId and a $0 fare handled by - // the backend), so they must stay in the array or that passenger — and their - // ticket/seat/childCount — silently never gets created. - const guestBookingPassengers = passengers.filter((_p, i) => { - if (packageId) { - const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; - return !isFreePkgChild; - } - return true; - }); + // Free children (first child per adult) never go through seat selection and have no + // seatId — true for package bookings AND regular ones (see booking/seats/page.tsx's + // seatEligibility comment). Submitting one anyway sends seatId: undefined, which the + // backend's `seat: { connect: { id } }` rejects — hence excluding them here for both + // cases. + const guestBookingPassengers = passengers.filter((p, i) => !(isChild(p) && isFirstChild(passengers, i))); bookingData = { scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index 7ab72e1a5..7fd0e71f2 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -426,9 +426,18 @@ interface VoucherData { // One ticket per passenger, matched below by passengerName — see bookings.service.ts's // getByRef(). Optional/absent falls back to a client-generated placeholder number. tickets?: Array<{ passengerName?: string; barcodePayload?: string }>; + // The actual settled amount/currency for this booking's payment — preferred over the + // ETB booking total once available, since it reflects what was really charged. + payment?: { amountMinor?: number; currency?: string }; } export const generateVoucherPDF = async (booking: VoucherData): Promise => { + const settledAmountMinor = booking.payment?.amountMinor; + const settledCurrency = booking.payment?.currency; + const useSettledAmount = settledAmountMinor != null && !!settledCurrency; + const voucherCurrency = useSettledAmount ? settledCurrency! : booking.currency; + const totalForSplit = useSettledAmount ? settledAmountMinor! : booking.totalMinor; + // Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between // them — a setTimeout delay here would push later saves outside the click's synchronous // user-activation window and risk iOS Safari silently blocking them. The awaited work @@ -450,8 +459,8 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => status: booking.status, outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass }, isRoundTrip: false, - fareMinor: Math.round(booking.totalMinor / booking.passengers.length), - currency: booking.currency, + fareMinor: Math.round(totalForSplit / booking.passengers.length), + currency: voucherCurrency, createdAt: booking.createdAt, }); }