diff --git a/apps/edr-passenger-web/portal/src/lib/api-client.ts b/apps/edr-passenger-web/portal/src/lib/api-client.ts
index 472c66e70..471218532 100644
--- a/apps/edr-passenger-web/portal/src/lib/api-client.ts
+++ b/apps/edr-passenger-web/portal/src/lib/api-client.ts
@@ -21,7 +21,11 @@ class ApiClient {
return config;
});
- const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me'];
+ // /fayda/verification/* is used by guests filling out the passenger form, and a 401
+ // there just means the user canceled/closed the Fayda popup without completing it (no
+ // valid verification session) — that should surface as an inline error on the page,
+ // not force-clear the session and redirect to /login out from under them.
+ const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me', '/fayda/verification'];
this.client.interceptors.response.use(
(response) => response,
diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts
index e2a518b57..ec2b34e5b 100644
--- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts
+++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts
@@ -30,6 +30,15 @@ export interface PassengerDetail {
seatId?: string;
seatNumber?: string;
coachNumber?: string;
+ // Fare for this passenger's actual assigned seat (minor units). Bed coaches price
+ // Upper/Middle/Lower differently, so this can differ from the schedule's flat
+ // baseFareAdult (which is only the coach type's cheapest class) — review/payment
+ // should prefer this per-seat fare when it's available.
+ seatFareMinor?: number;
+ // Raw bed position ("lower"/"middle"/"upper") of the assigned seat, if it's a berth —
+ // used to resolve this passenger's actual seat class server-side (see review page's
+ // fare-breakdown request), since a shared coach-level class can't distinguish berths.
+ bedPosition?: string;
phone?: string;
email?: string;
gender?: string;
@@ -37,9 +46,13 @@ export interface PassengerDetail {
outboundSeatId?: string;
outboundSeatNumber?: string;
outboundCoachNumber?: string;
+ outboundSeatFareMinor?: number;
+ outboundBedPosition?: string;
inboundSeatId?: string;
inboundSeatNumber?: string;
inboundCoachNumber?: string;
+ inboundSeatFareMinor?: number;
+ inboundBedPosition?: string;
returnSeatId?: string;
returnSeatNumber?: string;
}
@@ -63,6 +76,21 @@ export interface SelectedSchedule {
selectedCoachTypeId?: string;
selectedCoachTypeCode?: string;
selectedCoachTypeName?: string;
+ // All coach types (with per-class fares) offered on this schedule at selection time —
+ // kept so the seat map's coach preview can compute the fare difference before letting
+ // a passenger switch to a different coach type.
+ coachTypes?: Array<{
+ coachId: string;
+ coachTypeId: string;
+ coachTypeName: string;
+ coachTypeCode: string;
+ classes: Array<{
+ name: string;
+ baseFareMinor: number;
+ displayCurrency?: string;
+ displayAmountMinor?: number;
+ }>;
+ }>;
}
export interface SeatHold {
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 4dd969d69..37f421d71 100644
--- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
+++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
@@ -1,5 +1,6 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
+import QRCode from 'qrcode';
interface ScheduleInfo {
trainNumber: string;
@@ -36,6 +37,40 @@ const DARK = [51, 51, 51] as const;
const MED = [102, 102, 102] as const;
const LIGHT = [200, 200, 200] as const;
+// ─── QR code ───────────────────────────────────────────────────────────────
+// 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.
+function buildTicketQrPayload(data: PassengerVoucherData): string {
+ return JSON.stringify({
+ type: 'EDR_TICKET',
+ pnr: data.bookingRef,
+ ticket: data.ticketNumber,
+ passenger: data.passengerName,
+ status: data.status,
+ train: data.outboundSchedule.trainNumber,
+ seat: data.isRoundTrip
+ ? { outbound: data.outboundSeatNumber || null, inbound: data.inboundSeatNumber || null }
+ : (data.seatNumber || null),
+ departure: data.outboundSchedule.departureAt,
+ fare: { amountMinor: data.fareMinor, currency: data.currency },
+ });
+}
+
+async function generateTicketQrDataUrl(data: PassengerVoucherData): Promise
{
+ try {
+ return await QRCode.toDataURL(buildTicketQrPayload(data), {
+ width: 240,
+ margin: 0,
+ errorCorrectionLevel: 'M',
+ color: { dark: '#0f172a', light: '#ffffff' },
+ });
+ } catch (error) {
+ console.error('Failed to generate ticket QR code:', error);
+ return null;
+ }
+}
+
async function drawHeader(doc: jsPDF, margin: number): Promise {
const pageWidth = doc.internal.pageSize.getWidth();
@@ -81,22 +116,41 @@ function drawStatusBadge(doc: jsPDF, status: string, y: number, pageWidth: numbe
return y + 12;
}
-function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, y: number, margin: number, pageWidth: number): number {
+function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, qrDataUrl: string | null, y: number, margin: number, pageWidth: number): number {
+ const boxHeight = 32;
+ const qrSize = 24;
+ const qrPad = 3;
+ const qrBlockWidth = qrDataUrl ? qrSize + qrPad * 2 + 5 : 0;
+
doc.setFillColor(245, 245, 245);
- doc.rect(margin, y, pageWidth - margin * 2, 22, 'F');
+ doc.roundedRect(margin, y, pageWidth - margin * 2, boxHeight, 2, 2, 'F');
+ // Booking reference (top-left)
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
- doc.text('BOOKING REFERENCE', margin + 5, y + 6);
- doc.setTextColor(...PRIMARY); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
- doc.text(bookingRef, margin + 5, y + 14);
+ doc.text('BOOKING REFERENCE', margin + 5, y + 8);
+ doc.setTextColor(...PRIMARY); doc.setFontSize(18); doc.setFont('helvetica', 'bold');
+ doc.text(bookingRef, margin + 5, y + 18);
- const rightX = pageWidth - margin - 5;
+ // Ticket number, stacked below — leaves room on the right for the QR block
+ const textRightBound = pageWidth - margin - qrBlockWidth - 5;
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
- doc.text('TICKET NUMBER', rightX, y + 6, { align: 'right' });
+ doc.text('TICKET NUMBER', textRightBound, y + 8, { align: 'right' });
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
- doc.text(ticketNumber, rightX, y + 14, { align: 'right' });
+ doc.text(ticketNumber, textRightBound, y + 16, { align: 'right' });
- return y + 28;
+ // QR code — clean white card with a thin border, right-aligned in the box
+ if (qrDataUrl) {
+ const cardSize = qrSize + qrPad * 2;
+ const cardX = pageWidth - margin - cardSize - 3;
+ const cardY = y + (boxHeight - cardSize) / 2;
+ doc.setFillColor(255, 255, 255);
+ doc.setDrawColor(...LIGHT);
+ doc.setLineWidth(0.4);
+ doc.roundedRect(cardX, cardY, cardSize, cardSize, 2, 2, 'FD');
+ doc.addImage(qrDataUrl, 'PNG', cardX + qrPad, cardY + qrPad, qrSize, qrSize);
+ }
+
+ return y + boxHeight + 6;
}
function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null, y: number, margin: number, pageWidth: number): number {
@@ -244,6 +298,7 @@ export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): P
const margin = 15;
let y = await drawHeader(doc, margin);
+ const qrDataUrl = await generateTicketQrDataUrl(data);
// Title
doc.setTextColor(...DARK); doc.setFontSize(18); doc.setFont('helvetica', 'bold');
@@ -251,7 +306,7 @@ export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): P
y += 10;
y = drawStatusBadge(doc, data.status, y, pageW);
- y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, y, margin, pageW);
+ y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, qrDataUrl, y, margin, pageW);
y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW);
if (data.isRoundTrip && data.inboundSchedule) {
diff --git a/apps/edr-passenger-web/portal/src/lib/payment-store.ts b/apps/edr-passenger-web/portal/src/lib/payment-store.ts
index 057639004..85b74b55c 100644
--- a/apps/edr-passenger-web/portal/src/lib/payment-store.ts
+++ b/apps/edr-passenger-web/portal/src/lib/payment-store.ts
@@ -3,11 +3,17 @@ import { create } from 'zustand';
interface PaymentState {
paymentIntentId: string | null;
paymentStatus: 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED' | null;
+ // The currency actually confirmed for the selected payment option (from
+ // /payments/booking-amount), not just a default — kept in sync by the payment page.
selectedCurrency: 'ETB' | 'DJF' | 'USD';
-
+ // The exact minor-unit amount confirmed for that currency/payment option. Downstream
+ // screens (e.g. the voucher) should use this instead of recomputing a default ETB fare.
+ paidAmountMinor: number | null;
+
setPaymentIntent: (id: string) => void;
updateStatus: (status: 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED') => void;
setCurrency: (currency: 'ETB' | 'DJF' | 'USD') => void;
+ setPaidAmount: (amountMinor: number) => void;
clearPayment: () => void;
}
@@ -15,13 +21,16 @@ export const usePaymentStore = create((set) => ({
paymentIntentId: null,
paymentStatus: null,
selectedCurrency: 'ETB',
+ paidAmountMinor: null,
setPaymentIntent: (id) => set({ paymentIntentId: id }),
updateStatus: (status) => set({ paymentStatus: status }),
setCurrency: (currency) => set({ selectedCurrency: currency }),
+ setPaidAmount: (amountMinor) => set({ paidAmountMinor: amountMinor }),
clearPayment: () => set({
paymentIntentId: null,
paymentStatus: null,
selectedCurrency: 'ETB',
+ paidAmountMinor: null,
}),
}));