From dc269bed896a66b6f1be98e7dd2d44d89510a6dd Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 13 Jul 2026 22:31:35 +0300 Subject: [PATCH] Fix roundtrip in voucher --- .../src/modules/bookings/bookings.service.ts | 18 +- .../portal/src/app/booking/detail/page.tsx | 180 +++++++++++------- .../portal/src/lib/generate-voucher.ts | 74 +++++-- 3 files changed, 188 insertions(+), 84 deletions(-) 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, }); } };