Fix roundtrip in voucher

This commit is contained in:
Roba Boru
2026-07-13 22:31:35 +03:00
parent e2cc9772e6
commit dc269bed89
3 changed files with 188 additions and 84 deletions

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,
});
}
};