Ticket generation updates

This commit is contained in:
Stephanos A
2026-07-22 10:29:48 +03:00
parent 0d8e122de7
commit 838cedae0d
3 changed files with 41 additions and 27 deletions

View File

@@ -373,33 +373,37 @@ export class TicketsService {
await this.prisma.ticket.deleteMany({ where: { bookingId } });
// Generate one ticket per unique passenger (grouped by passengerName)
// Generate one ticket per passenger per leg.
// Round-trip / transit bookings have seats on multiple legs — each leg needs its own
// ticket so the voucher can match by (passengerName, leg) and gate scanners can
// validate each leg independently.
const tickets = [];
// Group seats by passenger
const passengerSeatsMap = new Map<string, any[]>();
// Group seats by (passengerName, leg)
const passengerLegSeatsMap = new Map<string, any[]>();
for (const bookingSeat of (booking as any).seats) {
const key = bookingSeat.passengerName;
if (!passengerSeatsMap.has(key)) {
passengerSeatsMap.set(key, []);
const key = `${bookingSeat.passengerName}|${bookingSeat.leg ?? 1}`;
if (!passengerLegSeatsMap.has(key)) {
passengerLegSeatsMap.set(key, []);
}
passengerSeatsMap.get(key)!.push(bookingSeat);
passengerLegSeatsMap.get(key)!.push(bookingSeat);
}
// Create one ticket per passenger
for (const [passengerName, passengerSeats] of passengerSeatsMap.entries()) {
// Use first seat for primary data
const primarySeat = passengerSeats[0];
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
// Create one ticket per (passenger, leg)
for (const [key, legSeats] of passengerLegSeatsMap.entries()) {
const [passengerName] = key.split('|');
const primarySeat = legSeats[0];
const leg = primarySeat.leg ?? 1;
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}L${leg}`;
// Re-encode QR with ticketNumber included
const qrDataWithTicket = JSON.stringify({
ref: booking.bookingRef,
ticketNumber: barcodePayload,
type: booking.bookingType,
passenger: passengerName,
seats: passengerSeats.map(ps => ({
leg,
seats: legSeats.map(ps => ({
seat: ps.seat?.seatNumber,
coach: ps.seat?.coach?.number,
leg: ps.leg || 1,
@@ -414,7 +418,7 @@ export class TicketsService {
bookingRef: booking.bookingRef,
passengerName,
seatId: primarySeat.seatId,
leg: primarySeat.leg || 1,
leg,
scheduleId: primarySeat.scheduleId || booking.scheduleId,
qrPayload: qrPayloadFinal,
barcodePayload,

View File

@@ -480,10 +480,6 @@ function BookingsPageContent() {
<p className="text-xs text-muted-foreground mb-2">Payment Status</p>
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>{b.paymentIntent?.status || 'PENDING'}</Badge>
</div>
<Field label="Method" value={b.paymentIntent?.method || '—'} />
<Field label="Paid At" value={b.paidAt ? formatDateTime(b.paidAt) : 'Not paid'} />
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
</div>
</section>

View File

@@ -115,11 +115,14 @@ export default function ConfirmationPage() {
// Keep polling after CONFIRMED until tickets are issued — ticket generation runs
// async after the booking transaction commits (see finalizePaymentSuccess in
// payments.service.ts), so the first CONFIRMED fetch often returns an empty
// tickets array.
// tickets array. Free children have no ticket, so compare against adult count
// (at minimum 1 ticket must exist) rather than total passenger count.
refetchInterval: (query) => {
const data = query.state.data;
if (!data || data.status !== "CONFIRMED") return false;
return (data.tickets?.length ?? 0) >= passengers.length ? false : FAST_POLL_INTERVAL_MS;
const adultCount = searchCriteria?.adultCount ?? passengers.filter((p) => !isChild(p)).length;
const expectedTickets = Math.max(1, adultCount);
return (data.tickets?.length ?? 0) >= expectedTickets ? false : FAST_POLL_INTERVAL_MS;
},
});
@@ -266,12 +269,23 @@ export default function ConfirmationPage() {
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout)
// between them — a setTimeout delay would push later saves outside the click's
// synchronous user-activation window and risk iOS Safari silently blocking them.
// Build a consumed-ticket set so each ticket is used at most once even when
// multiple passengers share the same name (e.g. parent + child same surname).
const remainingTickets = [...(bookingData?.tickets ?? [])];
const consumeTicket = (name: string, idx: number) => {
// 1. exact name match
let ti = remainingTickets.findIndex((t) => t.passengerName === name);
// 2. case-insensitive match (Verifayda may rewrite casing server-side)
if (ti === -1) ti = remainingTickets.findIndex((t) => t.passengerName?.toLowerCase() === name.toLowerCase());
// 3. positional fallback
if (ti === -1 && idx < remainingTickets.length) ti = idx;
if (ti === -1) return null;
return remainingTickets.splice(ti, 1)[0];
};
for (let i = 0; i < passengers.length; i++) {
const p = passengers[i];
const matchedTicket =
bookingData?.tickets?.find((t) => t.passengerName === p.name) ??
bookingData?.tickets?.[i] ??
null;
const matchedTicket = consumeTicket(p.name || "", i);
const ticketNumber = matchedTicket?.barcodePayload || "Not yet issued";
await generatePassengerVoucherPDF({