From 014b1a6d472b8dbad97da179de1e23c09a60f4a8 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 19 Aug 2026 15:28:10 +0300 Subject: [PATCH] fix: ( fayda ) reject a second booking for the same identity on one departure --- .../bookings/booking-identity.util.spec.ts | 130 ++++++++++++++++++ .../modules/bookings/booking-identity.util.ts | 96 +++++++++++++ .../src/modules/bookings/bookings.dto.ts | 19 ++- .../src/modules/bookings/bookings.service.ts | 33 ++++- .../src/modules/bookings/guest-booking.dto.ts | 13 +- .../modules/bookings/guest-booking.service.ts | 35 ++++- .../portal/src/app/booking/review/page.tsx | 6 + 7 files changed, 321 insertions(+), 11 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/bookings/booking-identity.util.spec.ts create mode 100644 apps/edr-passenger-api/src/modules/bookings/booking-identity.util.ts diff --git a/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.spec.ts b/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.spec.ts new file mode 100644 index 000000000..f1acb6cd2 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.spec.ts @@ -0,0 +1,130 @@ +import { BadRequestException } from '@nestjs/common'; +import { IdDocumentType } from '@prisma/client'; +import { + assertIdentitiesNotAlreadyBooked, + resolveIdentityRef, +} from './booking-identity.util'; +import { PrismaService } from '../../common/prisma.service'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const SCHEDULE = 'schedule-1'; +const RETURN_SCHEDULE = 'schedule-2'; + +const makePrisma = (clash: any = null) => + ({ bookingSeat: { findFirst: jest.fn().mockResolvedValue(clash) } }) as unknown as PrismaService; + +const traveller = (passengerName: string, identityRef: string | null) => ({ + passengerName, + identityRef, +}); + +// ── resolveIdentityRef ─────────────────────────────────────────────────────── + +describe('resolveIdentityRef', () => { + it('uses the Fayda sub for national-ID travellers', () => { + expect( + resolveIdentityRef({ + idDocumentType: IdDocumentType.NATIONAL_ID, + faydaSub: 'psut-abc', + passportNumber: 'P1234567', + }), + ).toBe('psut-abc'); + }); + + it('uses the passport number for passport travellers, normalised to upper case', () => { + expect( + resolveIdentityRef({ + idDocumentType: IdDocumentType.PASSPORT, + faydaSub: 'psut-abc', + passportNumber: ' p1234567 ', + }), + ).toBe('P1234567'); + }); + + it('returns null when there is nothing to key on — children and Fayda-disabled bookings', () => { + expect(resolveIdentityRef({ idDocumentType: IdDocumentType.NATIONAL_ID })).toBeNull(); + expect( + resolveIdentityRef({ idDocumentType: IdDocumentType.NATIONAL_ID, faydaSub: ' ' }), + ).toBeNull(); + expect(resolveIdentityRef({ idDocumentType: IdDocumentType.PASSPORT })).toBeNull(); + }); +}); + +// ── assertIdentitiesNotAlreadyBooked ───────────────────────────────────────── + +describe('assertIdentitiesNotAlreadyBooked', () => { + it('rejects the same identity used twice inside one payload', async () => { + const prisma = makePrisma(); + await expect( + assertIdentitiesNotAlreadyBooked( + prisma, + [traveller('Abebe Kebede', 'psut-abc'), traveller('Sara Ali', 'psut-abc')], + [SCHEDULE], + ), + ).rejects.toThrow(BadRequestException); + // Rejected before touching the database. + expect(prisma.bookingSeat.findFirst).not.toHaveBeenCalled(); + }); + + it('ignores passengers with no identity — two children never collide with each other', async () => { + const prisma = makePrisma(); + await expect( + assertIdentitiesNotAlreadyBooked( + prisma, + [traveller('Child One', null), traveller('Child Two', null)], + [SCHEDULE], + ), + ).resolves.toBeUndefined(); + expect(prisma.bookingSeat.findFirst).not.toHaveBeenCalled(); + }); + + it('queries every leg of the booking, de-duplicated, for active bookings only', async () => { + const prisma = makePrisma(); + await assertIdentitiesNotAlreadyBooked( + prisma, + [traveller('Abebe Kebede', 'psut-abc')], + [SCHEDULE, RETURN_SCHEDULE, SCHEDULE, null, undefined], + ); + + const { where } = (prisma.bookingSeat.findFirst as jest.Mock).mock.calls[0][0]; + expect(where.scheduleId).toEqual({ in: [SCHEDULE, RETURN_SCHEDULE] }); + expect(where.idDocumentNumber).toEqual({ in: ['psut-abc'] }); + expect(where.booking.status.in).toEqual(['DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'BOARDED']); + }); + + it('rejects an identity that already holds a ticket on the departure', async () => { + const prisma = makePrisma({ + idDocumentNumber: 'psut-abc', + passengerName: 'Abebe K.', + booking: { bookingRef: 'ABCDEF', status: 'CONFIRMED' }, + }); + + await expect( + assertIdentitiesNotAlreadyBooked(prisma, [traveller('Abebe Kebede', 'psut-abc')], [SCHEDULE]), + ).rejects.toThrow(/Abebe Kebede already has a ticket on this train \(booking ABCDEF\)/); + }); + + it('points an unpaid clash at the booking the traveller still has to settle', async () => { + const prisma = makePrisma({ + idDocumentNumber: 'psut-abc', + passengerName: 'Abebe K.', + booking: { bookingRef: 'ABCDEF', status: 'PENDING_PAYMENT' }, + }); + + await expect( + assertIdentitiesNotAlreadyBooked(prisma, [traveller('Abebe Kebede', 'psut-abc')], [SCHEDULE]), + ).rejects.toThrow(/already has an unpaid booking \(ABCDEF\)/); + }); + + it('allows the booking when nothing active matches — a cancelled ticket frees the identity', async () => { + const prisma = makePrisma(null); + await expect( + assertIdentitiesNotAlreadyBooked( + prisma, + [traveller('Abebe Kebede', 'psut-abc'), traveller('Sara Ali', 'P7654321')], + [SCHEDULE], + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.ts b/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.ts new file mode 100644 index 000000000..cd5385765 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.ts @@ -0,0 +1,96 @@ +import { BadRequestException } from '@nestjs/common'; +import { BookingStatus, IdDocumentType } from '@prisma/client'; +import { PrismaService } from '../../common/prisma.service'; + +/** + * Booking states that still hold a traveller's place on a departure. CANCELLED, REFUNDED and + * NO_SHOW are deliberately excluded: cancelling a ticket must immediately free the identity so + * the same person can book that train again. PENDING_PAYMENT counts — otherwise the whole check + * is bypassable by simply never finishing the first payment. + */ +const ACTIVE_BOOKING_STATUSES: BookingStatus[] = [ + BookingStatus.DRAFT, + BookingStatus.PENDING_PAYMENT, + BookingStatus.CONFIRMED, + BookingStatus.BOARDED, +]; + +/** + * The single value that identifies a human across bookings: the Fayda subject identifier (PSUT) + * for Ethiopians, the passport number for everyone else. It is written to + * `BookingSeat.idDocumentNumber` — an existing column, so no migration — and compared there. + * + * Returns null when there is nothing to key on: children under 5 have no Fayda, and neither does + * a booking made while the Fayda integration is switched off. Those passengers are simply not + * deduplicated rather than being blocked. + * + * Both inputs come from the client, so this stops honest misuse of the booking form, not a + * hand-crafted POST. Binding the sub to the server-side verification session is the follow-up + * that would make it tamper-proof. + */ +export function resolveIdentityRef(passenger: { + idDocumentType?: IdDocumentType | null; + faydaSub?: string | null; + passportNumber?: string | null; +}): string | null { + if (passenger.idDocumentType === IdDocumentType.PASSPORT) { + // Hand-typed, so normalise case — "p1234567" and "P1234567" are the same document. + const passport = passenger.passportNumber?.trim().toUpperCase(); + return passport || null; + } + const sub = passenger.faydaSub?.trim(); + return sub || null; +} + +/** + * Rejects a booking when one identity would occupy more than one seat on the same departure — + * either twice within this payload, or once here and once on an existing active booking. + * + * Keyed on `BookingSeat.scheduleId`, which is per leg, so round-trip outbound/return and transit + * leg-1/leg-2 are naturally treated as separate departures and never collide with each other. + */ +export async function assertIdentitiesNotAlreadyBooked( + prisma: PrismaService, + passengers: Array<{ passengerName: string; identityRef: string | null }>, + scheduleIds: Array, +): Promise { + const nameByIdentity = new Map(); + for (const passenger of passengers) { + if (!passenger.identityRef) continue; + const alreadyUsedBy = nameByIdentity.get(passenger.identityRef); + if (alreadyUsedBy !== undefined) { + throw new BadRequestException( + `${passenger.passengerName} and ${alreadyUsedBy} were verified with the same identity. ` + + `Each traveller must be verified with their own Fayda or passport.`, + ); + } + nameByIdentity.set(passenger.identityRef, passenger.passengerName); + } + + const identityRefs = [...nameByIdentity.keys()]; + const targetScheduleIds = [...new Set(scheduleIds.filter((id): id is string => !!id))]; + if (!identityRefs.length || !targetScheduleIds.length) return; + + const clash = await prisma.bookingSeat.findFirst({ + where: { + scheduleId: { in: targetScheduleIds }, + idDocumentNumber: { in: identityRefs }, + booking: { status: { in: ACTIVE_BOOKING_STATUSES } }, + }, + select: { + idDocumentNumber: true, + passengerName: true, + booking: { select: { bookingRef: true, status: true } }, + }, + }); + if (!clash) return; + + const traveller = nameByIdentity.get(clash.idDocumentNumber!) ?? clash.passengerName; + throw new BadRequestException( + clash.booking.status === BookingStatus.PENDING_PAYMENT + ? `${traveller} already has an unpaid booking (${clash.booking.bookingRef}) on this train. ` + + `Complete or cancel that booking before making a new one.` + : `${traveller} already has a ticket on this train (booking ${clash.booking.bookingRef}). ` + + `Each traveller may hold only one ticket per departure.`, + ); +} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 42d4af0d9..454339516 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -18,6 +18,7 @@ export class PassengerInputDto { dateOfBirth: Date; @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string; + @ApiPropertyOptional({ example: '8267a1f4-...', description: 'Fayda subject identifier (PSUT) from POST /fayda/verification/complete. Stored on the booking seat and compared across bookings so one Fayda identity cannot hold two seats on the same departure.' }) @IsOptional() @IsString() faydaSub?: string; @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string; @ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string; @ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string; @@ -67,11 +68,19 @@ export class RoundTripPassengerDto { description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string; - - @ApiPropertyOptional({ - example: 'P1234567', - description: 'Passport number for non-Ethiopian passengers (no verification)' - }) + + @ApiPropertyOptional({ + example: '8267a1f4-...', + description: + 'Fayda subject identifier (PSUT) from POST /fayda/verification/complete. Stored on the booking seat ' + + 'and compared across bookings so one Fayda identity cannot hold two seats on the same departure.' + }) + @IsOptional() @IsString() faydaSub?: string; + + @ApiPropertyOptional({ + example: 'P1234567', + description: 'Passport number for non-Ethiopian passengers (no verification)' + }) @IsOptional() @IsString() passportNumber?: string; @ApiPropertyOptional({ 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 7f0a6eee5..d2a9825ec 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -6,6 +6,7 @@ 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 { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util'; import { Cron, CronExpression } from '@nestjs/schedule'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { CurrencyService } from '../currency/currency.service'; @@ -861,6 +862,11 @@ export class BookingsService { this.resolveIamContact(dto.passengerId), ]); const { adultCount, childCount } = this.countPassengers(passengersData); + + // One traveller, one seat per departure — checked before any fare/hold work so a rejected + // booking leaves nothing behind. + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [dto.scheduleId]); + const fareCalculation = dto.packageId && dto.priceTierId ? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount) : await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); @@ -988,6 +994,7 @@ export class BookingsService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1058,6 +1065,11 @@ export class BookingsService { ]); const { adultCount, childCount } = this.countPassengers(passengersData); + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [ + dto.scheduleId, + dto.returnScheduleId, + ]); + // Package bookings use fixed tier price split equally across both legs let outboundFare: Awaited>; let returnFare: Awaited>; @@ -1196,6 +1208,7 @@ export class BookingsService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1211,6 +1224,7 @@ export class BookingsService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1301,6 +1315,11 @@ export class BookingsService { ]); const { adultCount, childCount } = this.countPassengers(passengersData); + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [ + dto.scheduleId, + dto.leg2ScheduleId, + ]); + const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId; const [leg1Fare, leg2Fare] = await Promise.all([ this.calculateFare(dto.scheduleId, dto.seatClassId, leg1OriginStop, leg1DestStop, passengersData[0]?.nationality, adultCount, childCount), @@ -1386,6 +1405,7 @@ export class BookingsService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1401,6 +1421,7 @@ export class BookingsService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1495,6 +1516,14 @@ export class BookingsService { this.resolveIamContact(dto.passengerId), ]); const { adultCount, childCount } = this.countPassengers(passengersData); + + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [ + dto.scheduleId, + dto.leg2ScheduleId, + dto.returnScheduleId, + dto.returnLeg2ScheduleId, + ]); + const nat = passengersData[0]?.nationality; const obL2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId; @@ -1558,6 +1587,7 @@ export class BookingsService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1659,7 +1689,7 @@ export class BookingsService { nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); } - processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality, identityRef: resolveIdentityRef(passenger) }); } return processedPassengers; } @@ -1696,6 +1726,7 @@ export class BookingsService { verifaydaVerified, verifaydaData, nationality, + identityRef: resolveIdentityRef(passenger), // Normalise: PassengerInputDto uses seatId/returnSeatId; RoundTripPassengerDto uses // outboundSeatId/returnSeatId. Accept either form so both DTOs work. outboundSeatId: passenger.outboundSeatId ?? passenger.seatId, diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index ac60b8480..7065b6183 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -25,10 +25,19 @@ export class GuestPassengerDto { @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; - @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored)' }) + @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored)' }) @IsOptional() @IsString() idDocumentNumber?: string; - @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopians' }) + @ApiPropertyOptional({ + example: '8267a1f4-...', + description: + 'Fayda subject identifier (PSUT) returned by POST /fayda/verification/complete for this traveller. ' + + 'Stored on the booking seat and compared across bookings so one Fayda identity cannot hold two ' + + 'seats on the same departure. Omit for children under 5 and non-Ethiopians (the passport number is used instead).', + }) + @IsOptional() @IsString() faydaSub?: string; + + @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopians' }) @IsOptional() @IsString() passportNumber?: string; @ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country' }) diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index c8061184f..0414296a4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -12,6 +12,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { PaymentsService } from '../payments/payments.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto'; +import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util'; import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client'; import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; @@ -225,9 +226,14 @@ export class GuestBookingService { verifaydaVerified, verifaydaData, nationality, + identityRef: resolveIdentityRef(passenger), }); } + // One traveller, one seat per departure — checked before any fare/hold work so a rejected + // booking leaves nothing behind. + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [dto.scheduleId]); + // Calculate fare — package bookings use the fixed tier price, bypassing the fare engine const isPackageOneway = !!dto.packageId && !!dto.priceTierId; let baseFareMinor: number; @@ -392,6 +398,7 @@ export class GuestBookingService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -772,9 +779,14 @@ export class GuestBookingService { nationality = nationality || 'Other'; } - passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality, identityRef: resolveIdentityRef(passenger) }); } + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [ + dto.scheduleId, + dto.returnScheduleId, + ]); + // Calculate fares for both legs — package bookings use the fixed tier price split across legs const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId; const isPackageRoundTrip = !!dto.packageId && !!dto.priceTierId; @@ -935,6 +947,7 @@ export class GuestBookingService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -950,6 +963,7 @@ export class GuestBookingService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1072,9 +1086,14 @@ export class GuestBookingService { } else { nationality = nationality || 'Other'; } - passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality, identityRef: resolveIdentityRef(passenger) }); } + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [ + dto.scheduleId, + dto.leg2ScheduleId, + ]); + const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId; const primaryNationality = passengersData[0]?.nationality; const paidChildrenCount = Math.max(0, childCount - 1); @@ -1145,6 +1164,7 @@ export class GuestBookingService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1160,6 +1180,7 @@ export class GuestBookingService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1282,9 +1303,16 @@ export class GuestBookingService { } else { nationality = nationality || 'Other'; } - passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality, identityRef: resolveIdentityRef(passenger) }); } + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [ + dto.scheduleId, + dto.leg2ScheduleId, + dto.returnScheduleId, + dto.returnLeg2ScheduleId, + ]); + const nat = passengersData[0]?.nationality; const paidChildren = Math.max(0, childCount - 1); const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId; @@ -1325,6 +1353,7 @@ export class GuestBookingService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 4b9148001..4d4b954df 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -363,6 +363,9 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', idDocumentNumber: isEthiopian ? (p.nationalId || '') : '', + // Verified Fayda identity for this traveller. The backend stores it on the booking seat and + // refuses a second seat for the same identity on the same departure. + ...(p.faydaSub ? { faydaSub: p.faydaSub } : {}), passportNumber: !isEthiopian ? (p.passportNumber || '') : '', passportCountry: !isEthiopian ? (p.passportCountry || '') : '', nationality: p.nationality, @@ -417,6 +420,9 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', idDocumentNumber: isEthiopian ? (p.nationalId || '') : '', + // Verified Fayda identity for this traveller. The backend stores it on the booking seat and + // refuses a second seat for the same identity on the same departure. + ...(p.faydaSub ? { faydaSub: p.faydaSub } : {}), passportNumber: !isEthiopian ? (p.passportNumber || '') : '', passportCountry: !isEthiopian ? (p.passportCountry || '') : '', nationality: p.nationality,