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 c5c8a0b49..114c0cc9d 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -1734,6 +1734,7 @@ export class BookingsService {
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
+ returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
priceTier: { select: { priceMinor: true } },
@@ -1822,6 +1823,16 @@ export class BookingsService {
destination: { id: (booking as any).schedule.destinationStation.id, name: (booking as any).schedule.destinationStation.name, code: (booking as any).schedule.destinationStation.code, city: (booking as any).schedule.destinationStation.city },
departureAt: (booking as any).schedule.departureAt, arrivalAt: (booking as any).schedule.arrivalAt,
},
+ returnSchedule: (booking as any).returnSchedule
+ ? {
+ id: (booking as any).returnSchedule.id,
+ trainNumber: (booking as any).returnSchedule.train.number,
+ trainName: (booking as any).returnSchedule.train.name,
+ origin: { id: (booking as any).returnSchedule.originStation.id, name: (booking as any).returnSchedule.originStation.name, code: (booking as any).returnSchedule.originStation.code, city: (booking as any).returnSchedule.originStation.city },
+ destination: { id: (booking as any).returnSchedule.destinationStation.id, name: (booking as any).returnSchedule.destinationStation.name, code: (booking as any).returnSchedule.destinationStation.code, city: (booking as any).returnSchedule.destinationStation.city },
+ departureAt: (booking as any).returnSchedule.departureAt, arrivalAt: (booking as any).returnSchedule.arrivalAt,
+ }
+ : null,
passengers: (booking as any).seats?.map((bs: any) => ({
fullName: bs.passengerName,
category: bs.passengerCategory,
@@ -1844,11 +1855,14 @@ export class BookingsService {
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.
+ // One ticket per passenger per leg (round trips have a separate ticket — and
+ // barcode — for the return leg) — matched on the frontend by passengerName +
+ // leg, not array position, since tickets are grouped/created independently of
+ // the passengers array.
tickets: (booking as any).tickets?.map((t: any) => ({
id: t.id,
passengerName: t.passengerName,
+ leg: t.leg ?? 1,
qrPayload: t.qrPayload,
barcodePayload: t.barcodePayload,
status: t.status,
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 7aef411e6..6442fd0d6 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
@@ -39,6 +39,35 @@ const getIconForMethod = (methodType: string) => {
return Smartphone;
};
+function SeatDetailsGrid({
+ seat,
+}: {
+ seat?: { coach?: string; number?: string; seatClass?: string };
+}) {
+ return (
+
+
+
Coach:
+
+ {seat?.coach || "N/A"}
+
+
+
+
Seat Number:
+
+ {seat?.number || "N/A"}
+
+
+
+
Class:
+
+ {seat?.seatClass || "N/A"}
+
+
+
+ );
+}
+
function BookingDetailContent() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -311,18 +340,22 @@ function BookingDetailContent() {
};
// /bookings/:ref returns one row per passenger PER LEG for round trips (leg 1 =
- // outbound, leg 2 = return) — /booking/payment's fare breakdown, by contrast, shows one
- // combined row per passenger with an Outbound/Return sub-split. Group leg rows back
- // together here so both pages present the same per-passenger total, not a doubled list
- // of half-fare rows.
+ // outbound, leg 2 = return), each carrying that leg's own seat assignment —
+ // /booking/payment's fare breakdown, by contrast, shows one combined row per
+ // passenger with an Outbound/Return sub-split. Group leg rows back together here —
+ // by identity, not by row — so every consumer (fare breakdown, passenger/seat list)
+ // sees one entry per real passenger with both legs' seats attached, not a doubled
+ // list of half-passenger rows.
const isRoundTripBooking = booking.bookingType === "ROUND_TRIP";
- const farePassengers = (() => {
+ const groupedPassengers = (() => {
const rows: any[] = booking.passengers || [];
if (!isRoundTripBooking) {
return rows.map((p) => ({
fullName: p.fullName,
category: p.category,
fareMinor: p.fareMinor ?? 0,
+ outboundSeat: p.seat,
+ returnSeat: undefined as any,
}));
}
const grouped = new Map<
@@ -332,6 +365,8 @@ function BookingDetailContent() {
category: string;
outboundFareMinor: number;
returnFareMinor: number;
+ outboundSeat?: any;
+ returnSeat?: any;
}
>();
rows.forEach((p) => {
@@ -341,9 +376,16 @@ function BookingDetailContent() {
category: p.category,
outboundFareMinor: 0,
returnFareMinor: 0,
+ outboundSeat: undefined,
+ returnSeat: undefined,
};
- if (p.leg === 2) entry.returnFareMinor = p.fareMinor ?? 0;
- else entry.outboundFareMinor = p.fareMinor ?? 0;
+ if (p.leg === 2) {
+ entry.returnFareMinor = p.fareMinor ?? 0;
+ entry.returnSeat = p.seat;
+ } else {
+ entry.outboundFareMinor = p.fareMinor ?? 0;
+ entry.outboundSeat = p.seat;
+ }
grouped.set(key, entry);
});
return Array.from(grouped.values()).map((p) => ({
@@ -352,6 +394,8 @@ function BookingDetailContent() {
fareMinor: p.outboundFareMinor + p.returnFareMinor,
outboundFareMinor: p.outboundFareMinor,
returnFareMinor: p.returnFareMinor,
+ outboundSeat: p.outboundSeat,
+ returnSeat: p.returnSeat,
}));
})();
@@ -374,7 +418,7 @@ function BookingDetailContent() {
Fare breakdown
- {farePassengers.map((passenger: any, idx: number) => {
+ {groupedPassengers.map((passenger: any, idx: number) => {
const isChildPassenger = passenger.category === "CHILD";
const isFreeChild =
isChildPassenger && (passenger.fareMinor ?? 0) === 0;
@@ -1035,69 +1079,77 @@ function BookingDetailContent() {
- Passenger Details ({booking.passengers?.length || 0})
+ Passenger Details ({groupedPassengers.length})
- {booking.passengers?.map((passenger: any, idx: number) => (
+ {groupedPassengers.map((passenger: any, idx: number) => (
-
-
-
-
- {idx + 1}
-
-
- {passenger.fullName}
-
-
- {passenger.category}
-
-
-
-
-
-
- Coach:
-
-
- {passenger.seat?.coach || "N/A"}
-
-
-
-
- Seat Number:
-
-
- {passenger.seat?.number || "N/A"}
-
-
-
-
- Class:
-
-
- {passenger.seat?.seatClass || "N/A"}
-
-
-
-
-
- {isConfirmed && (
-
- )}
+
+
+ {idx + 1}
+
+
+ {passenger.fullName}
+
+
+ {passenger.category}
+
+
+ {isRoundTripBooking ? (
+
+ {(
+ [
+ { legLabel: "Outbound", seat: passenger.outboundSeat },
+ { legLabel: "Return", seat: passenger.returnSeat },
+ ] as const
+ ).map(({ legLabel, seat }) => (
+
+
+
+ {legLabel}
+
+
+
+ {isConfirmed && (
+
+ )}
+
+ ))}
+
+ ) : (
+
+
+
+
+ {isConfirmed && (
+
+ )}
+
+ )}
))}
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 306225a6c..ac2625fad 100644
--- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
+++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
@@ -419,18 +419,33 @@ export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): P
// ─── legacy combined voucher (kept for backward compat) ──────────────────────
+interface VoucherSchedule {
+ trainNumber: string;
+ trainName?: string;
+ origin: { name: string; code: string; city: string };
+ destination: { name: string; code: string; city: string };
+ departureAt: string;
+ arrivalAt: string;
+}
+
interface VoucherData {
bookingRef: string;
status: string;
- passengers: Array<{ fullName: string; category: string; seat?: { number: string; coach: string; seatClass: string } }>;
- schedule: { trainNumber: string; trainName?: string; origin: { name: string; code: string; city: string }; destination: { name: string; code: string; city: string }; departureAt: string; arrivalAt: string };
+ // /bookings/:ref returns one row per passenger PER LEG for round trips (leg 1 =
+ // outbound, leg 2 = return), each with that leg's own seat — see bookings.service.ts's
+ // getByRef(). dateOfBirth is included purely to disambiguate same-name passengers when
+ // grouping leg rows back into one passenger below.
+ passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; seat?: { number: string; coach: string; seatClass: string } }>;
+ schedule: VoucherSchedule;
+ returnSchedule?: VoucherSchedule | null;
totalMinor: number;
currency: string;
bookingType: string;
createdAt: string;
- // 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 }>;
+ // One ticket per passenger per leg (round trips have a separate ticket/barcode for the
+ // return leg) — matched below by passengerName + leg. Optional/absent falls back to a
+ // client-generated placeholder number.
+ tickets?: Array<{ passengerName?: string; leg?: number; 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 };
@@ -447,31 +462,54 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise
=>
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
const voucherCurrency = useSettledAmount ? settledCurrency! : booking.currency;
+ const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
+
+ // Group leg rows back into one entry per real passenger — without this, a round trip
+ // produced two half-passenger vouchers (one per leg, each showing only its own leg's
+ // seat) instead of one voucher per passenger covering both legs.
+ const grouped = new Map<
+ string,
+ { fullName: string; category: string; outboundSeat?: VoucherData['passengers'][number]['seat']; returnSeat?: VoucherData['passengers'][number]['seat'] }
+ >();
+ booking.passengers.forEach((p) => {
+ const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`;
+ const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundSeat: undefined, returnSeat: undefined };
+ if (p.leg === 2) entry.returnSeat = p.seat;
+ else entry.outboundSeat = p.seat;
+ grouped.set(key, entry);
+ });
+
// 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
// inside generatePassengerVoucherPDF is itself just microtasks (cached logo, QR encode),
// which doesn't have that effect.
- for (let i = 0; i < booking.passengers.length; i++) {
- const p = booking.passengers[i];
+ for (const p of grouped.values()) {
+ // The displayed ticket number is always the outbound leg's — matched by leg, not just
+ // name, so a round trip doesn't end up showing whichever ticket happens to sort first.
const matchedTicket =
- booking.tickets?.find((t) => t.passengerName === p.fullName) ?? booking.tickets?.[i] ?? null;
+ booking.tickets?.find((t) => t.passengerName === p.fullName && (t.leg ?? 1) === 1) ??
+ booking.tickets?.find((t) => t.passengerName === p.fullName) ??
+ null;
// No fabricated placeholder — a made-up TKT-... number reads as real and is misleading
// if it doesn't match what's actually on file.
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
await generatePassengerVoucherPDF({
- bookingRef: booking.bookingRef,
+ bookingRef: booking.bookingRef,
ticketNumber,
- passengerName: p.fullName,
- seatNumber: p.seat?.number,
- status: booking.status,
- outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass },
- isRoundTrip: false,
- fareMinor: useSettledAmount ? settledAmountMinor! : booking.totalMinor,
- currency: voucherCurrency,
- fareIsMajorUnits: useSettledAmount,
- createdAt: booking.createdAt,
+ passengerName: p.fullName,
+ status: booking.status,
+ outboundSchedule: { ...booking.schedule, seatClass: p.outboundSeat?.seatClass },
+ inboundSchedule: isRoundTrip ? { ...booking.returnSchedule!, seatClass: p.returnSeat?.seatClass } : undefined,
+ isRoundTrip,
+ seatNumber: isRoundTrip ? undefined : p.outboundSeat?.number,
+ outboundSeatNumber: isRoundTrip ? p.outboundSeat?.number : undefined,
+ inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
+ fareMinor: useSettledAmount ? settledAmountMinor! : booking.totalMinor,
+ currency: voucherCurrency,
+ fareIsMajorUnits: useSettledAmount,
+ createdAt: booking.createdAt,
});
}
};