From 44b4779ca6b52aafe5fe0c0e5e5a20c11bc5e8ff Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 12:54:21 +0300 Subject: [PATCH] Ticketing updates --- .../src/modules/bookings/bookings.module.ts | 3 +- .../src/modules/bookings/bookings.service.ts | 30 +++++++++++++++++++ .../src/modules/tickets/tickets.controller.ts | 11 +++++++ .../src/modules/tickets/tickets.service.ts | 28 +++++++++++++++++ .../backoffice/src/app/tickets/page.tsx | 27 ++++++++++++++++- .../backoffice/src/lib/api/index.ts | 1 + 6 files changed, 98 insertions(+), 2 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index 63fd8be85..0ce77ad5b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -9,9 +9,10 @@ import { VerifaydaModule } from '../verifayda/verifayda.module'; import { CurrencyModule } from '../currency/currency.module'; import { AuthModule } from '../auth/auth.module'; import { FareEngineModule } from '../fare-engine/fare-engine.module'; +import { TicketsModule } from '../tickets/tickets.module'; @Module({ - imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule], + imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule], controllers: [BookingsController], providers: [BookingsService, GuestBookingService], exports: [BookingsService, GuestBookingService] 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 64dcb2c6c..2c4d2cee4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; +import { TicketsService } from '../tickets/tickets.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateBookingDto, ModifyBookingDto } from './bookings.dto'; import { Cron, CronExpression } from '@nestjs/schedule'; @@ -101,6 +102,7 @@ export class BookingsService { private readonly prisma: PrismaService, @InjectDataSource() private readonly dataSource: DataSource, private readonly seatsService: SeatsService, + private readonly ticketsService: TicketsService, private readonly eventEmitter: EventEmitter2, private readonly verifaydaService: VerifaydaService, private readonly currencyService: CurrencyService, @@ -1903,6 +1905,34 @@ export class BookingsService { }; } + // Auto-heal: if booking is CONFIRMED, payment SUCCEEDED, but tickets are missing + // (ticket generation failed silently after payment — see finalizePaymentSuccess in + // payments.service.ts), attempt to generate them now so the confirmation page + // doesn't show "Not yet issued". + if ( + booking.status === 'CONFIRMED' && + (booking as any).tickets?.length === 0 && + (booking as any).paymentIntent?.status === 'SUCCEEDED' + ) { + try { + await this.ticketsService.generate(booking.id); + // Re-fetch to include the newly created tickets + const refreshed = await this.prisma.booking.findUnique({ + where: { id: booking.id }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, + returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, + paymentIntent: true, tickets: true, + priceTier: { select: { priceMinor: true } }, + }, + }); + if (refreshed) Object.assign(booking, refreshed); + } catch (err) { + this.logger.warn(`getByRef: auto-generate tickets failed for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + const outboundSegment = this.resolveSegmentStations( (booking as any).schedule, (booking as any).originStationId, diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index cc53be263..3a48ca2a1 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -9,6 +9,17 @@ import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; export class TicketsController { constructor(private service: TicketsService) {} + @Post('generate-missing') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ + summary: 'Generate tickets for all confirmed bookings that are missing them', + description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed counts.', + }) + generateMissing() { + return this.service.generateMissing(); + } + @Post('smart-assign/:bookingId') @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 1cff652e0..965026c92 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -830,6 +830,34 @@ export class TicketsService { }; } + async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> { + const confirmedWithNoTickets = await this.prisma.booking.findMany({ + where: { + status: 'CONFIRMED', + tickets: { none: {} }, + paymentIntent: { status: 'SUCCEEDED' }, + }, + select: { id: true, bookingRef: true }, + }); + + const details: any[] = []; + let generated = 0; + let failed = 0; + + for (const booking of confirmedWithNoTickets) { + try { + await this.generate(booking.id); + generated++; + details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'generated' }); + } catch (err) { + failed++; + details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'failed', error: err instanceof Error ? err.message : String(err) }); + } + } + + return { processed: confirmedWithNoTickets.length, generated, failed, details }; + } + async delete(id: string) { const ticket = await this.prisma.ticket.findUnique({ where: { id } }); if (!ticket) throw new NotFoundException('Ticket not found'); diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 4b55b37be..5c6354fd4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -98,6 +98,21 @@ export default function TicketsPage() { queryFn: () => apiClient.get('/fleet/coaches'), }); + const [generateMissingResult, setGenerateMissingResult] = useState(null); + + const generateMissingMutation = useMutation({ + mutationFn: () => ticketsApi.generateMissing(), + onSuccess: (result: any) => { + queryClient.invalidateQueries({ queryKey: ['tickets'] }); + setGenerateMissingResult(result); + setSuccessMessage(`Generated ${result.generated} ticket(s) for ${result.processed} booking(s)${result.failed ? ` (${result.failed} failed)` : ''}`); + setTimeout(() => setSuccessMessage(''), 6000); + }, + onError: (error: any) => { + alert(error?.response?.data?.message || error?.message || 'Failed to generate missing tickets'); + }, + }); + const boardMutation = useMutation({ mutationFn: ({ ticketId, leg }: { ticketId: string; leg?: 'outbound' | 'inbound' }) => ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString(), leg: leg === 'inbound' ? 'RETURN' : 'OUTBOUND' }), @@ -546,7 +561,17 @@ export default function TicketsPage() {

Tickets

Manage tickets and validations

- setExportModalOpen(true)}>Export +
+ generateMissingMutation.mutate()} + > + Generate Missing + + setExportModalOpen(true)}>Export +
{/* Filters */} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 1b917afe2..0d35ed2d3 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -224,6 +224,7 @@ export const ticketsApi = { return Array.isArray(response) ? { items: response } : response; }, getById: (id: string) => apiClient.get(`/tickets/${id}`), + generateMissing: () => apiClient.post('/tickets/generate-missing', {}), validate: (ticketId: string, data: any) => apiClient.post(`/tickets/${ticketId}/validate`, data), scanAndBoard: (qrCodeOrRef: string, data: any) => apiClient.post(`/tickets/scan-board/${encodeURIComponent(qrCodeOrRef)}`, data), regenerate: (ticketId: string) => apiClient.post(`/tickets/${ticketId}/regenerate`),