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 220ecb1b3..515eb4449 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 @@ -8,7 +8,7 @@ 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 { CheckCircle, Copy, Train, FileText } from 'lucide-react'; +import { CheckCircle, Clock, Copy, Train, FileText } from 'lucide-react'; import { format } from 'date-fns'; import { isChild, isFirstChild } from '@/utils/fare-utils'; @@ -45,7 +45,7 @@ export default function ConfirmationPage() { return { id: bookingId || '', pnr: pnr || undefined, - status: 'CONFIRMED', + status: 'PENDING_PAYMENT', totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0), }; } @@ -53,6 +53,10 @@ export default function ConfirmationPage() { enabled: !!bookingId, }); + // 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). + const isConfirmed = _booking?.status === 'CONFIRMED'; + useEffect(() => { if (bookingId && !confirmAttempted.current) { confirmAttempted.current = true; @@ -190,14 +194,33 @@ export default function ConfirmationPage() { {/* Success Header */}
-
- -
+ {isConfirmed ? ( +
+ +
+ ) : ( +
+ +
+ )}
-

- {packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'} -

-

Your train tickets are ready

+ {isConfirmed ? ( + <> +

+ {packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'} +

+

Your train tickets are ready

+ + ) : ( + <> +

+ Booking received — payment pending +

+

+ We haven't confirmed your payment yet. Your tickets will be issued once payment is completed. +

+ + )}
{/* PNR Card */} @@ -340,7 +363,9 @@ export default function ConfirmationPage() {

Status

-

{_booking?.status || 'CONFIRMED'}

+

+ {_booking?.status || 'PENDING_PAYMENT'} +

Passengers

@@ -366,7 +391,9 @@ export default function ConfirmationPage() {
{passengers.map((passenger, index) => { const backendTicket = _booking?.ticket || null; - const ticketNumber = backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`; + const ticketNumber = isConfirmed + ? backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}` + : null; return (
@@ -377,13 +404,17 @@ export default function ConfirmationPage() {

{passenger.name}

Passenger {index + 1}

- CONFIRMED + {isConfirmed ? ( + CONFIRMED + ) : ( + AWAITING PAYMENT + )}
- +

Ticket Number

-

{ticketNumber}

+

{ticketNumber || 'Pending payment'}

Date of Birth

diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx index 33d6a20f6..cf132bc7f 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx @@ -3,15 +3,42 @@ import { useEffect, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; +import { useBookingStore } from '@/lib/booking-store'; import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; -import { CheckCircle, Loader2 } from 'lucide-react'; +import { apiClient } from '@/lib/api-client'; +import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react'; import { Suspense } from 'react'; +type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED'; +type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown'; + +// D-Money redirects the browser to this ONE url regardless of outcome — a hit here is not +// proof of payment. Poll the backend (which reconciles with the provider) before showing +// "Payment Successful". Real confirmation still happens via the webhook; this only decides +// what the browser shows. +async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> { + const maxAttempts = 5; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`); + if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED'; + if (intent?.status === 'FAILED') return 'FAILED'; + } catch { + // transient lookup failure — keep retrying until attempts are exhausted + } + if (attempt < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + } + return 'UNKNOWN'; +} + function DmoneySuccessContent() { const router = useRouter(); const searchParams = useSearchParams(); const { updateStatus } = usePaymentStore(); - const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); + const { bookingId } = useBookingStore(); + const [view, setView] = useState('checking'); const [returnTarget, setReturnTarget] = useState('/booking/confirmation'); // D-Money callback query params (mirrors Telebirr) @@ -19,30 +46,56 @@ function DmoneySuccessContent() { const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; useEffect(() => { - // Actual booking confirmation happens server-side via the provider webhook — this page - // only reflects that back to the user. A Manage Booking payment (paying for an - // already-existing booking) has no in-progress booking-store session to show a - // confirmation from, so it goes back to that booking's detail view instead. + let cancelled = false; + const manageBookingRef = consumeManageBookingPaymentReturn(); const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation'; setReturnTarget(target); - updateStatus('SUCCEEDED'); - setStatus('done'); - setTimeout(() => router.push(target), 1500); + + // Manage Booking sessions don't carry a bookingId in the client store — the detail page + // it lands on re-fetches the booking's real status itself, so there's nothing to verify + // client-side here; just hand off without claiming an outcome we can't confirm. + if (!bookingId) { + if (!cancelled) { + router.push(target); + } + return; + } + + verifyBookingPaid(bookingId).then((result) => { + if (cancelled) return; + if (result === 'SUCCEEDED') { + updateStatus('SUCCEEDED'); + setView('succeeded'); + setTimeout(() => router.push(target), 1500); + } else if (result === 'FAILED') { + updateStatus('FAILED'); + setView('failed'); + } else { + // Still not confirmed after polling — don't claim success or failure. Hand off to + // /booking/confirmation, which now reflects the booking's real (pending) status. + setView('unknown'); + setTimeout(() => router.push(target), 1500); + } + }); + + return () => { + cancelled = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
- {status === 'processing' && ( + {view === 'checking' && ( <>

Confirming payment…

Please wait while we confirm your D-Money payment.

)} - {status === 'done' && ( + {view === 'succeeded' && ( <>

Payment Successful!

@@ -52,15 +105,25 @@ function DmoneySuccessContent() {

Redirecting…

)} - {status === 'error' && ( + {view === 'failed' && ( <> -
- ⚠️ -
-

Something went wrong

-

Unable to confirm payment

+ +

Payment Failed

+

Your D-Money payment was not completed.

+ className="btn-secondary w-full flex items-center justify-center gap-2"> + + Back + + + )} + {view === 'unknown' && ( + <> + +

Still confirming…

+

+ We haven't received final confirmation from D-Money yet. Taking you to your booking status. +

)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx index 1a830cee3..a47fdd07c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx @@ -3,15 +3,42 @@ import { useEffect, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; +import { useBookingStore } from '@/lib/booking-store'; import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; -import { CheckCircle, Loader2 } from 'lucide-react'; +import { apiClient } from '@/lib/api-client'; +import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react'; import { Suspense } from 'react'; +type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED'; +type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown'; + +// Telebirr redirects the browser to this ONE url regardless of outcome — a hit here is not +// proof of payment. Poll the backend (which reconciles with the provider) before showing +// "Payment Successful". Real confirmation still happens via the webhook; this only decides +// what the browser shows. +async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> { + const maxAttempts = 5; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`); + if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED'; + if (intent?.status === 'FAILED') return 'FAILED'; + } catch { + // transient lookup failure — keep retrying until attempts are exhausted + } + if (attempt < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + } + return 'UNKNOWN'; +} + function TelebirrSuccessContent() { const router = useRouter(); const searchParams = useSearchParams(); const { updateStatus } = usePaymentStore(); - const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); + const { bookingId } = useBookingStore(); + const [view, setView] = useState('checking'); const [returnTarget, setReturnTarget] = useState('/booking/confirmation'); // Telebirr callback query params @@ -19,30 +46,56 @@ function TelebirrSuccessContent() { const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; useEffect(() => { - // Actual booking confirmation happens server-side via the provider webhook — this page - // only reflects that back to the user. A Manage Booking payment (paying for an - // already-existing booking) has no in-progress booking-store session to show a - // confirmation from, so it goes back to that booking's detail view instead. + let cancelled = false; + const manageBookingRef = consumeManageBookingPaymentReturn(); const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation'; setReturnTarget(target); - updateStatus('SUCCEEDED'); - setStatus('done'); - setTimeout(() => router.push(target), 1500); + + // Manage Booking sessions don't carry a bookingId in the client store — the detail page + // it lands on re-fetches the booking's real status itself, so there's nothing to verify + // client-side here; just hand off without claiming an outcome we can't confirm. + if (!bookingId) { + if (!cancelled) { + router.push(target); + } + return; + } + + verifyBookingPaid(bookingId).then((result) => { + if (cancelled) return; + if (result === 'SUCCEEDED') { + updateStatus('SUCCEEDED'); + setView('succeeded'); + setTimeout(() => router.push(target), 1500); + } else if (result === 'FAILED') { + updateStatus('FAILED'); + setView('failed'); + } else { + // Still not confirmed after polling — don't claim success or failure. Hand off to + // /booking/confirmation, which now reflects the booking's real (pending) status. + setView('unknown'); + setTimeout(() => router.push(target), 1500); + } + }); + + return () => { + cancelled = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
- {status === 'processing' && ( + {view === 'checking' && ( <>

Confirming payment…

Please wait while we confirm your Telebirr payment.

)} - {status === 'done' && ( + {view === 'succeeded' && ( <>

Payment Successful!

@@ -52,15 +105,25 @@ function TelebirrSuccessContent() {

Redirecting…

)} - {status === 'error' && ( + {view === 'failed' && ( <> -
- ⚠️ -
-

Something went wrong

-

Unable to confirm payment

+ +

Payment Failed

+

Your Telebirr payment was not completed.

+ className="btn-secondary w-full flex items-center justify-center gap-2"> + + Back + + + )} + {view === 'unknown' && ( + <> + +

Still confirming…

+

+ We haven't received final confirmation from Telebirr yet. Taking you to your booking status. +

)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx index c75a0c390..89a8b3098 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx @@ -3,14 +3,41 @@ import { useEffect, useState, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; +import { useBookingStore } from '@/lib/booking-store'; import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; -import { CheckCircle, Loader2 } from 'lucide-react'; +import { apiClient } from '@/lib/api-client'; +import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react'; + +type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED'; +type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown'; + +// Waafi registers a dedicated success URL, but a hit here still isn't proof of payment on +// its own (gateway redirect vs. real settlement can disagree). Poll the backend (which +// reconciles with the provider) before showing "Payment Successful". +async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> { + const maxAttempts = 5; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`); + if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED'; + if (intent?.status === 'FAILED') return 'FAILED'; + } catch { + // transient lookup failure — keep retrying until attempts are exhausted + } + if (attempt < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + } + return 'UNKNOWN'; +} function WaafiSuccessContent() { const router = useRouter(); const searchParams = useSearchParams(); const { updateStatus } = usePaymentStore(); - const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); + const { bookingId } = useBookingStore(); + const [view, setView] = useState('checking'); + const [returnTarget, setReturnTarget] = useState('/booking/confirmation'); // Waafi callback query params const referenceId = searchParams.get('referenceId') || ''; @@ -19,29 +46,56 @@ function WaafiSuccessContent() { const currency = searchParams.get('currency') || ''; useEffect(() => { - // Actual booking confirmation happens server-side via the provider webhook — this page - // only reflects that back to the user. A Manage Booking payment (paying for an - // already-existing booking) has no in-progress booking-store session to show a - // confirmation from, so it goes back to that booking's detail view instead. + let cancelled = false; + const manageBookingRef = consumeManageBookingPaymentReturn(); const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation'; - updateStatus('SUCCEEDED'); - setStatus('done'); - setTimeout(() => router.push(target), 1500); + setReturnTarget(target); + + // Manage Booking sessions don't carry a bookingId in the client store — the detail page + // it lands on re-fetches the booking's real status itself, so there's nothing to verify + // client-side here; just hand off without claiming an outcome we can't confirm. + if (!bookingId) { + if (!cancelled) { + router.push(target); + } + return; + } + + verifyBookingPaid(bookingId).then((result) => { + if (cancelled) return; + if (result === 'SUCCEEDED') { + updateStatus('SUCCEEDED'); + setView('succeeded'); + setTimeout(() => router.push(target), 1500); + } else if (result === 'FAILED') { + updateStatus('FAILED'); + setView('failed'); + } else { + // Still not confirmed after polling — don't claim success or failure. Hand off to + // /booking/confirmation, which now reflects the booking's real (pending) status. + setView('unknown'); + setTimeout(() => router.push(target), 1500); + } + }); + + return () => { + cancelled = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
- {status === 'processing' && ( + {view === 'checking' && ( <>

Confirming payment…

Please wait while we confirm your Waafi payment.

)} - {status === 'done' && ( + {view === 'succeeded' && ( <>

Payment Successful!

@@ -54,6 +108,27 @@ function WaafiSuccessContent() {

Redirecting…

)} + {view === 'failed' && ( + <> + +

Payment Failed

+

Your Waafi payment was not completed.

+ + + )} + {view === 'unknown' && ( + <> + +

Still confirming…

+

+ We haven't received final confirmation from Waafi yet. Taking you to your booking status. +

+ + )}
); diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 574652cde..0f8ac2004 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -951,7 +951,7 @@ export default function ResultsPage() {
-
+

@@ -986,7 +986,7 @@ export default function ResultsPage() {
-
+

@@ -1035,7 +1035,7 @@ export default function ResultsPage() {
-
+

@@ -1173,7 +1173,7 @@ export default function ResultsPage() { alternativeOutbound.length > 0 && (
-
+

@@ -1273,7 +1273,7 @@ export default function ResultsPage() { alternativeInbound.length > 0 && (
-
+

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 9d15af7d8..6ff31aeaf 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -52,11 +52,14 @@ const PAGE_MARGIN = 18; // Encodes everything a gate scanner needs to verify this specific ticket without // a network round-trip: booking reference, ticket number, passenger, train, seat(s), // departure time and fare. Kept as compact JSON so any generic QR reader can parse it. +// Field names (`ref`/`ticketNumber`) must match what the backoffice boarding scanner and +// tickets.service.ts's scanAndBoard() read from the QR payload — see apps/edr-passenger-api/ +// src/modules/tickets/tickets.service.ts. function buildTicketQrPayload(data: PassengerVoucherData): string { return JSON.stringify({ type: 'EDR_TICKET', - pnr: data.bookingRef, - ticket: data.ticketNumber, + ref: data.bookingRef, + ticketNumber: data.ticketNumber, passenger: data.passengerName, status: data.status, train: data.outboundSchedule.trainNumber, @@ -165,6 +168,10 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st const qrSize = 22; const qrPad = 2.5; const cardSize = qrSize + qrPad * 2; + const qrGap = 3; + // The QR box is anchored to the right edge of the card — reserve that space so the + // status pill (also right-anchored) never draws underneath/over it. + const qrCardX = pageWidth - margin - cardSize - qrGap; doc.setFillColor(...SURFACE); doc.setDrawColor(...HAIRLINE); @@ -172,7 +179,8 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'FD'); const padX = 7; - drawStatusPill(doc, status, pageWidth - margin - padX, y + 5.5, 'right'); + const pillRightX = qrDataUrl ? qrCardX - qrGap : pageWidth - margin - padX; + drawStatusPill(doc, status, pillRightX, y + 5.5, 'right'); label(doc, 'Booking reference', margin + padX, y + 12); doc.setTextColor(...INK); doc.setFontSize(21); doc.setFont('helvetica', 'bold'); @@ -183,13 +191,12 @@ function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, st doc.text(ticketNumber, margin + padX + 22, y + 28.7); if (qrDataUrl) { - const cardX = pageWidth - margin - cardSize - 3; const cardY = y + (cardH - cardSize) / 2; doc.setFillColor(255, 255, 255); doc.setDrawColor(...HAIRLINE); doc.setLineWidth(0.3); - doc.roundedRect(cardX, cardY, cardSize, cardSize, 2, 2, 'FD'); - doc.addImage(qrDataUrl, 'PNG', cardX + qrPad, cardY + qrPad, qrSize, qrSize); + doc.roundedRect(qrCardX, cardY, cardSize, cardSize, 2, 2, 'FD'); + doc.addImage(qrDataUrl, 'PNG', qrCardX + qrPad, cardY + qrPad, qrSize, qrSize); } return y + cardH + 10;