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 0bbd1c7ae..6a45eeb69 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -177,7 +177,7 @@ export class TicketsService { } : null, status: t.status, validatedAt: t.validatedAt, - boardedAt: t.validatedAt, + boardedAt: t.boardedAt ?? t.validatedAt, qrCode: t.qrPayload ?? null, createdAt: t.issuedAt, }; @@ -663,6 +663,13 @@ export class TicketsService { // Use existing validation logic to handle round trips properly const result = await this.validate(bookingRef, validatorId, gateId); + if ((result as any).alreadyValidated) { + return { + success: false, + error: 'Ticket already used', + errorCode: 'ALREADY_USED', + }; + } // Get seat information const seatInfo = (booking as any).seats[0]; @@ -756,12 +763,23 @@ export class TicketsService { const type = booking.bookingType; const now = new Date(); + const markTicketUsed = async () => { + if (ticket.status !== 'USED') { + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'USED' } }); + ticket.status = 'USED'; + } + }; + // ── ONE_WAY / TRANSIT (single scan) ─────────────────────────────────── if (type === 'ONE_WAY') { if (ticket.validatedAt) { + await markTicketUsed(); return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true }; } - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.ticket.update({ + where: { id: ticket.id }, + data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId, status: 'USED' }, + }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); this.fireBoardingPassNotification(booking, ticket, null); await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: 'ONE_WAY' } }); @@ -778,13 +796,22 @@ export class TicketsService { const alreadyValidated = logs.some(l => l.leg === resolvedLeg); if (alreadyValidated) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); + await markTicketUsed(); throw new BadRequestException(`${resolvedLeg} already validated`); } - if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } }); + const validatedAt = ticket.validatedAt ?? now; + if (!ticket.validatedAt) { + await this.prisma.ticket.update({ + where: { id: ticket.id }, + data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId, status: 'USED' }, + }); + } else { + await markTicketUsed(); + } await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); this.fireBoardingPassNotification(booking, ticket, resolvedLeg); await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } }); - return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt }; } // ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ──────────────────────── @@ -798,12 +825,14 @@ export class TicketsService { if (resolvedLeg === 'OUTBOUND') { if ((booking as any).outboundBoardedAt) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); + await markTicketUsed(); throw new BadRequestException('Outbound leg already validated'); } bookingData.outboundBoardedAt = now; } else if (resolvedLeg === 'RETURN') { if ((booking as any).returnBoardedAt) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); + await markTicketUsed(); throw new BadRequestException('Return leg already validated'); } bookingData.returnBoardedAt = now; @@ -812,13 +841,19 @@ export class TicketsService { } await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); + const validatedAt = ticket.validatedAt ?? now; if (!ticket.validatedAt) { - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.ticket.update({ + where: { id: ticket.id }, + data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId, status: 'USED' }, + }); + } else { + await markTicketUsed(); } await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); this.fireBoardingPassNotification(booking, ticket, resolvedLeg); await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } }); - return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt }; } throw new BadRequestException(`Unsupported booking type: ${type}`); diff --git a/apps/edr-passenger-api/test/ticketing.e2e-spec.ts b/apps/edr-passenger-api/test/ticketing.e2e-spec.ts index b8a380577..ee5baeb7d 100644 --- a/apps/edr-passenger-api/test/ticketing.e2e-spec.ts +++ b/apps/edr-passenger-api/test/ticketing.e2e-spec.ts @@ -228,6 +228,24 @@ describe("Ticketing — generate / scanAndBoard / validate / smart-reassign", () const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } }); expect(ticket?.validatedAt).toBeTruthy(); + expect(ticket?.status).toBe('USED'); + }); + + it("does not allow boarding the same ticket twice", async () => { + const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-REUSE-${Date.now()}`, departureAt: future(60), arrivalAt: future(120) }); + const booking = await createOneWayBooking(schedule.id, seats[0].id); + await markSucceeded(booking.id); + await ticketsService.generate(booking.id); + + const first = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1"); + expect(first.success).toBe(true); + + const second = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1"); + expect(second.success).toBe(false); + expect(second.error).toMatch(/already used/i); + + const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } }); + expect(ticket?.status).toBe('USED'); }); it("refuses boarding before the boarding window opens", async () => {