mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1350 from Tria-plc/alpha
fix: ( fayda ) reject a second booking for the same identity on one d…
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export class PassengerInputDto {
|
|||||||
dateOfBirth: Date;
|
dateOfBirth: Date;
|
||||||
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
@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: '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: '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: '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;
|
@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)'
|
description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)'
|
||||||
})
|
})
|
||||||
@IsOptional() @IsString() idDocumentNumber?: string;
|
@IsOptional() @IsString() idDocumentNumber?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'P1234567',
|
example: '8267a1f4-...',
|
||||||
description: 'Passport number for non-Ethiopian passengers (no verification)'
|
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;
|
@IsOptional() @IsString() passportNumber?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SeatsService } from '../seats/seats.service';
|
|||||||
import { TicketsService } from '../tickets/tickets.service';
|
import { TicketsService } from '../tickets/tickets.service';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
|
import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
|
||||||
|
import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||||
import { CurrencyService } from '../currency/currency.service';
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
@@ -861,6 +862,11 @@ export class BookingsService {
|
|||||||
this.resolveIamContact(dto.passengerId),
|
this.resolveIamContact(dto.passengerId),
|
||||||
]);
|
]);
|
||||||
const { adultCount, childCount } = this.countPassengers(passengersData);
|
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
|
const fareCalculation = dto.packageId && dto.priceTierId
|
||||||
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
|
? 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);
|
: 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,
|
dateOfBirth: p.dateOfBirth,
|
||||||
passengerCategory: p.category,
|
passengerCategory: p.category,
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType,
|
||||||
|
idDocumentNumber: p.identityRef,
|
||||||
passportNumber: p.passportNumber,
|
passportNumber: p.passportNumber,
|
||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
@@ -1058,6 +1065,11 @@ export class BookingsService {
|
|||||||
]);
|
]);
|
||||||
const { adultCount, childCount } = this.countPassengers(passengersData);
|
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
|
// Package bookings use fixed tier price split equally across both legs
|
||||||
let outboundFare: Awaited<ReturnType<typeof this.calculateFare>>;
|
let outboundFare: Awaited<ReturnType<typeof this.calculateFare>>;
|
||||||
let returnFare: Awaited<ReturnType<typeof this.calculateFare>>;
|
let returnFare: Awaited<ReturnType<typeof this.calculateFare>>;
|
||||||
@@ -1196,6 +1208,7 @@ export class BookingsService {
|
|||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
passengerCategory: p.category,
|
passengerCategory: p.category,
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType,
|
||||||
|
idDocumentNumber: p.identityRef,
|
||||||
passportNumber: p.passportNumber,
|
passportNumber: p.passportNumber,
|
||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
@@ -1211,6 +1224,7 @@ export class BookingsService {
|
|||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
passengerCategory: p.category,
|
passengerCategory: p.category,
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType,
|
||||||
|
idDocumentNumber: p.identityRef,
|
||||||
passportNumber: p.passportNumber,
|
passportNumber: p.passportNumber,
|
||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
@@ -1301,6 +1315,11 @@ export class BookingsService {
|
|||||||
]);
|
]);
|
||||||
const { adultCount, childCount } = this.countPassengers(passengersData);
|
const { adultCount, childCount } = this.countPassengers(passengersData);
|
||||||
|
|
||||||
|
await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [
|
||||||
|
dto.scheduleId,
|
||||||
|
dto.leg2ScheduleId,
|
||||||
|
]);
|
||||||
|
|
||||||
const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
|
const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
|
||||||
const [leg1Fare, leg2Fare] = await Promise.all([
|
const [leg1Fare, leg2Fare] = await Promise.all([
|
||||||
this.calculateFare(dto.scheduleId, dto.seatClassId, leg1OriginStop, leg1DestStop, passengersData[0]?.nationality, adultCount, childCount),
|
this.calculateFare(dto.scheduleId, dto.seatClassId, leg1OriginStop, leg1DestStop, passengersData[0]?.nationality, adultCount, childCount),
|
||||||
@@ -1386,6 +1405,7 @@ export class BookingsService {
|
|||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
passengerCategory: p.category,
|
passengerCategory: p.category,
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType,
|
||||||
|
idDocumentNumber: p.identityRef,
|
||||||
passportNumber: p.passportNumber,
|
passportNumber: p.passportNumber,
|
||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
@@ -1401,6 +1421,7 @@ export class BookingsService {
|
|||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
passengerCategory: p.category,
|
passengerCategory: p.category,
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType,
|
||||||
|
idDocumentNumber: p.identityRef,
|
||||||
passportNumber: p.passportNumber,
|
passportNumber: p.passportNumber,
|
||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
@@ -1495,6 +1516,14 @@ export class BookingsService {
|
|||||||
this.resolveIamContact(dto.passengerId),
|
this.resolveIamContact(dto.passengerId),
|
||||||
]);
|
]);
|
||||||
const { adultCount, childCount } = this.countPassengers(passengersData);
|
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 nat = passengersData[0]?.nationality;
|
||||||
|
|
||||||
const obL2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
|
const obL2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
|
||||||
@@ -1558,6 +1587,7 @@ export class BookingsService {
|
|||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
passengerCategory: p.category,
|
passengerCategory: p.category,
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType,
|
||||||
|
idDocumentNumber: p.identityRef,
|
||||||
passportNumber: p.passportNumber,
|
passportNumber: p.passportNumber,
|
||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
@@ -1659,7 +1689,7 @@ export class BookingsService {
|
|||||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
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;
|
return processedPassengers;
|
||||||
}
|
}
|
||||||
@@ -1696,6 +1726,7 @@ export class BookingsService {
|
|||||||
verifaydaVerified,
|
verifaydaVerified,
|
||||||
verifaydaData,
|
verifaydaData,
|
||||||
nationality,
|
nationality,
|
||||||
|
identityRef: resolveIdentityRef(passenger),
|
||||||
// Normalise: PassengerInputDto uses seatId/returnSeatId; RoundTripPassengerDto uses
|
// Normalise: PassengerInputDto uses seatId/returnSeatId; RoundTripPassengerDto uses
|
||||||
// outboundSeatId/returnSeatId. Accept either form so both DTOs work.
|
// outboundSeatId/returnSeatId. Accept either form so both DTOs work.
|
||||||
outboundSeatId: passenger.outboundSeatId ?? passenger.seatId,
|
outboundSeatId: passenger.outboundSeatId ?? passenger.seatId,
|
||||||
|
|||||||
@@ -25,10 +25,19 @@ export class GuestPassengerDto {
|
|||||||
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' })
|
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' })
|
||||||
@IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
@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;
|
@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;
|
@IsOptional() @IsString() passportNumber?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country' })
|
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country' })
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
|||||||
import { PaymentsService } from '../payments/payments.service';
|
import { PaymentsService } from '../payments/payments.service';
|
||||||
import { SmsClientService } from '../notifications/sms-client.service';
|
import { SmsClientService } from '../notifications/sms-client.service';
|
||||||
import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto';
|
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 { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
|
||||||
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
|
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
|
||||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||||
@@ -225,9 +226,14 @@ export class GuestBookingService {
|
|||||||
verifaydaVerified,
|
verifaydaVerified,
|
||||||
verifaydaData,
|
verifaydaData,
|
||||||
nationality,
|
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
|
// Calculate fare — package bookings use the fixed tier price, bypassing the fare engine
|
||||||
const isPackageOneway = !!dto.packageId && !!dto.priceTierId;
|
const isPackageOneway = !!dto.packageId && !!dto.priceTierId;
|
||||||
let baseFareMinor: number;
|
let baseFareMinor: number;
|
||||||
@@ -392,6 +398,7 @@ export class GuestBookingService {
|
|||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
passengerCategory: p.category,
|
passengerCategory: p.category,
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType,
|
||||||
|
idDocumentNumber: p.identityRef,
|
||||||
passportNumber: p.passportNumber,
|
passportNumber: p.passportNumber,
|
||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
@@ -772,9 +779,14 @@ export class GuestBookingService {
|
|||||||
nationality = nationality || 'Other';
|
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
|
// Calculate fares for both legs — package bookings use the fixed tier price split across legs
|
||||||
const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
|
const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
|
||||||
const isPackageRoundTrip = !!dto.packageId && !!dto.priceTierId;
|
const isPackageRoundTrip = !!dto.packageId && !!dto.priceTierId;
|
||||||
@@ -935,6 +947,7 @@ export class GuestBookingService {
|
|||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
passengerCategory: p.category,
|
passengerCategory: p.category,
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType,
|
||||||
|
idDocumentNumber: p.identityRef,
|
||||||
passportNumber: p.passportNumber,
|
passportNumber: p.passportNumber,
|
||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
@@ -950,6 +963,7 @@ export class GuestBookingService {
|
|||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
passengerCategory: p.category,
|
passengerCategory: p.category,
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType,
|
||||||
|
idDocumentNumber: p.identityRef,
|
||||||
passportNumber: p.passportNumber,
|
passportNumber: p.passportNumber,
|
||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
@@ -1072,9 +1086,14 @@ export class GuestBookingService {
|
|||||||
} else {
|
} else {
|
||||||
nationality = nationality || 'Other';
|
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 leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId;
|
||||||
const primaryNationality = passengersData[0]?.nationality;
|
const primaryNationality = passengersData[0]?.nationality;
|
||||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||||
@@ -1145,6 +1164,7 @@ export class GuestBookingService {
|
|||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
passengerCategory: p.category,
|
passengerCategory: p.category,
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType,
|
||||||
|
idDocumentNumber: p.identityRef,
|
||||||
passportNumber: p.passportNumber,
|
passportNumber: p.passportNumber,
|
||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
@@ -1160,6 +1180,7 @@ export class GuestBookingService {
|
|||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
passengerCategory: p.category,
|
passengerCategory: p.category,
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType,
|
||||||
|
idDocumentNumber: p.identityRef,
|
||||||
passportNumber: p.passportNumber,
|
passportNumber: p.passportNumber,
|
||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
@@ -1282,9 +1303,16 @@ export class GuestBookingService {
|
|||||||
} else {
|
} else {
|
||||||
nationality = nationality || 'Other';
|
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 nat = passengersData[0]?.nationality;
|
||||||
const paidChildren = Math.max(0, childCount - 1);
|
const paidChildren = Math.max(0, childCount - 1);
|
||||||
const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId;
|
const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId;
|
||||||
@@ -1325,6 +1353,7 @@ export class GuestBookingService {
|
|||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
passengerCategory: p.category,
|
passengerCategory: p.category,
|
||||||
idDocumentType: p.idDocumentType,
|
idDocumentType: p.idDocumentType,
|
||||||
|
idDocumentNumber: p.identityRef,
|
||||||
passportNumber: p.passportNumber,
|
passportNumber: p.passportNumber,
|
||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
|
|||||||
@@ -363,6 +363,9 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
|||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
||||||
idDocumentNumber: isEthiopian ? (p.nationalId || '') : '',
|
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 || '') : '',
|
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||||
nationality: p.nationality,
|
nationality: p.nationality,
|
||||||
@@ -417,6 +420,9 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
|||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
|
||||||
idDocumentNumber: isEthiopian ? (p.nationalId || '') : '',
|
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 || '') : '',
|
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||||
nationality: p.nationality,
|
nationality: p.nationality,
|
||||||
|
|||||||
Reference in New Issue
Block a user