mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Fix roundtrip in voucher
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -39,6 +39,35 @@ const getIconForMethod = (methodType: string) => {
|
||||
return Smartphone;
|
||||
};
|
||||
|
||||
function SeatDetailsGrid({
|
||||
seat,
|
||||
}: {
|
||||
seat?: { coach?: string; number?: string; seatClass?: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Coach:</span>
|
||||
<div className="font-mono font-semibold text-gray-900 dark:text-white">
|
||||
{seat?.coach || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Seat Number:</span>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{seat?.number || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500 dark:text-gray-400">Class:</span>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{seat?.seatClass || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||
Fare breakdown
|
||||
</h3>
|
||||
{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() {
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white mb-4">
|
||||
Passenger Details ({booking.passengers?.length || 0})
|
||||
Passenger Details ({groupedPassengers.length})
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{booking.passengers?.map((passenger: any, idx: number) => (
|
||||
{groupedPassengers.map((passenger: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-xl p-4"
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-xl p-4 space-y-4"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="w-6 h-6 bg-primary text-white rounded-full flex items-center justify-center text-xs font-bold">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white">
|
||||
{passenger.fullName}
|
||||
</h3>
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-full">
|
||||
{passenger.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Coach:
|
||||
</span>
|
||||
<div className="font-mono font-semibold text-gray-900 dark:text-white">
|
||||
{passenger.seat?.coach || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Seat Number:
|
||||
</span>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{passenger.seat?.number || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Class:
|
||||
</span>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{passenger.seat?.seatClass || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isConfirmed && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-white p-3 rounded-lg border-2 border-gray-200">
|
||||
<QRCode
|
||||
value={`TICKET:${booking.bookingRef}-${passenger.seat?.id || idx}`}
|
||||
size={80}
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-6 h-6 bg-primary text-white rounded-full flex items-center justify-center text-xs font-bold">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white">
|
||||
{passenger.fullName}
|
||||
</h3>
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-full">
|
||||
{passenger.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isRoundTripBooking ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
{ legLabel: "Outbound", seat: passenger.outboundSeat },
|
||||
{ legLabel: "Return", seat: passenger.returnSeat },
|
||||
] as const
|
||||
).map(({ legLabel, seat }) => (
|
||||
<div
|
||||
key={legLabel}
|
||||
className="flex items-center gap-4 pt-3 border-t border-gray-100 dark:border-gray-800 first:border-t-0 first:pt-0 md:border-t-0 md:pt-0"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<span className="inline-block mb-2 text-xs font-semibold px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{legLabel}
|
||||
</span>
|
||||
<SeatDetailsGrid seat={seat} />
|
||||
</div>
|
||||
{isConfirmed && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-white p-3 rounded-lg border-2 border-gray-200">
|
||||
<QRCode
|
||||
value={`TICKET:${booking.bookingRef}-${seat?.id || `${idx}-${legLabel}`}`}
|
||||
size={72}
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<SeatDetailsGrid seat={passenger.outboundSeat} />
|
||||
</div>
|
||||
{isConfirmed && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-white p-3 rounded-lg border-2 border-gray-200">
|
||||
<QRCode
|
||||
value={`TICKET:${booking.bookingRef}-${passenger.outboundSeat?.id || idx}`}
|
||||
size={80}
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<void> =>
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user