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;