fix: ( fayda ) reject a second booking for the same identity on one departure

This commit is contained in:
Abubeker Yasin
2026-08-19 15:28:10 +03:00
parent 1f38b5a3c6
commit 014b1a6d47
7 changed files with 321 additions and 11 deletions

View File

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

View File

@@ -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<string | null | undefined>,
): Promise<void> {
const nameByIdentity = new Map<string, string>();
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.`,
);
}

View File

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

View File

@@ -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<ReturnType<typeof this.calculateFare>>;
let returnFare: Awaited<ReturnType<typeof this.calculateFare>>;
@@ -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,

View File

@@ -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' })

View File

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

View File

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