diff --git a/apps/edr-passenger-api/prisma/migrations/20260724000001_add_booking_pay_token/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260724000001_add_booking_pay_token/migration.sql new file mode 100644 index 000000000..89bb4b50a --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260724000001_add_booking_pay_token/migration.sql @@ -0,0 +1,10 @@ +-- Adds Booking.payToken/payTokenExpiresAt: an opaque, expiring token used by the +-- reserve-seat "issue booking (passenger)" flow to let a traveler pay via an SMS'd +-- link without needing a portal session (see GuestBookingService.issueBookingFromReservation +-- and BookingsService.getByPayToken). Nullable — only set for reservation-issued bookings +-- awaiting passenger payment; a normal guest/portal booking never sets it. + +ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "payToken" TEXT; +ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "payTokenExpiresAt" TIMESTAMP(3); + +CREATE UNIQUE INDEX IF NOT EXISTS "Booking_payToken_key" ON "passenger"."Booking"("payToken"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index de086e465..467bff071 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -550,6 +550,8 @@ model Booking { paidAt DateTime? paymentReminderSentAt DateTime? packageDepartureStationId String? + payToken String? @unique + payTokenExpiresAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt passenger Passenger @relation(fields: [passengerId], references: [id]) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 88bbe6ec4..5953d17a2 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -32,9 +32,11 @@ import { import { CreateGuestBookingDto, GetSavedPassengersDto, + IssueReservationBookingDto, } from "./guest-booking.dto"; import { JwtGuard } from "../../common/jwt.guard"; -import { PassengerAdmin } from "../../common/passenger-guards"; +import { PassengerAdmin, PassengerStaff } from "../../common/passenger-guards"; +import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @ApiTags("Booking") @Controller("bookings") @@ -349,6 +351,35 @@ export class BookingsController { return this.guestService.createGuestBooking(dto, req); } + @Post("reservations/:seatId/issue") + @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ + summary: "Issue a booking from a reserved (blocked) seat", + description: + "Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone.", + }) + @ApiBody({ type: IssueReservationBookingDto }) + issueBookingFromReservation( + @Param("seatId") seatId: string, + @Body() dto: IssueReservationBookingDto, + @Req() req: any, + ) { + const actingUserId = req.user?.id ?? req.user?.sub ?? null; + return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId); + } + + @Get("pay/:token") + @SetMetadata("isPublic", true) + @ApiOperation({ + summary: "Resolve a reservation booking by its pay token (public)", + description: + "Used by the portal's standalone pay-by-link page for a reservation booking awaiting passenger payment — no login required.", + }) + getByPayToken(@Param("token") token: string) { + return this.service.getByPayToken(token); + } + @Get("saved-passengers") @SetMetadata("isPublic", true) @ApiOperation({ diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index 0ce77ad5b..78b7d4bc7 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -10,9 +10,11 @@ import { CurrencyModule } from '../currency/currency.module'; import { AuthModule } from '../auth/auth.module'; import { FareEngineModule } from '../fare-engine/fare-engine.module'; import { TicketsModule } from '../tickets/tickets.module'; +import { PaymentsModule } from '../payments/payments.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule], + imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule], controllers: [BookingsController], providers: [BookingsService, GuestBookingService], exports: [BookingsService, GuestBookingService] diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 98a54528b..b5825ddc5 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1860,6 +1860,52 @@ export class BookingsService { ); } + /** + * Resolves a reservation booking by its standalone payToken — the public entry point for + * the portal's pay-by-link page (a traveler who never had a portal session, texted a link + * by a reservation-issuing staff member). Payment itself still goes through the already- + * public /payments/* endpoints (initiate/methods/booking-amount/status), keyed by the + * booking id this returns — no change needed there. + */ + async getByPayToken(token: string) { + const booking = await this.prisma.booking.findUnique({ + where: { payToken: token }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, + seats: { include: { seat: { include: { coach: true } } } }, + }, + }); + if (!booking) throw new NotFoundException('Payment link not found'); + if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('This booking is no longer awaiting payment'); + if ((booking as any).payTokenExpiresAt && (booking as any).payTokenExpiresAt < new Date()) { + throw new BadRequestException('This payment link has expired'); + } + + const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.totalMinor, + currency: booking.currency, + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + schedule: { + trainNumber: (booking as any).schedule.train.number, + trainName: (booking as any).schedule.train.name, + origin: { id: segment.origin.id, name: segment.origin.name, code: segment.origin.code }, + destination: { id: segment.destination.id, name: segment.destination.name, code: segment.destination.code }, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, + }, + seats: (booking as any).seats.map((s: any) => ({ + passengerName: s.passengerName, + seatNumber: s.seat?.seatNumber ?? null, + coach: s.seat?.coach?.number ?? null, + })), + }; + } + async getByRef(bookingRefOrId: string) { const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId); const booking = await this.prisma.booking.findUnique({ 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 f89b5228d..ac60b8480 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 @@ -1,4 +1,4 @@ -import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber } from 'class-validator'; +import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber, IsIn } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; @@ -174,6 +174,67 @@ export class SavedPassengerProfileDto { } export class GetSavedPassengersDto { - @ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID to retrieve saved passengers' }) + @ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID to retrieve saved passengers' }) @IsOptional() @IsString() deviceId?: string; } + +export enum ReservationBookingKind { + STAFF = 'STAFF', + PASSENGER = 'PASSENGER', +} + +export const RESERVATION_NATIONALITIES = ['Ethiopian', 'Djiboutian', 'Other'] as const; +export type ReservationNationality = (typeof RESERVATION_NATIONALITIES)[number]; + +/** + * Issues a real booking against a seat an admin/staff previously reserved (SeatBlock) — + * one traveler per seat, fare always server-computed (no seatFareMinor/reviewedTotalMinor + * override: unlike the guest DTO, there's no untrusted client-displayed price to defend + * against here). seatId comes from the route param, not the body. + * + * No seatClassId — the seat (and therefore its coach/class) is already fixed by the + * reservation being converted; the service resolves the correct seat class itself from the + * seat's own coach type and nationality (LOCAL vs INTERNATIONAL pricing tier), the same + * matching search results already use. No passportCountry — nationality alone is what + * drives both fare-tier selection and passport-vs-national-ID validation. + */ +export class IssueReservationBookingDto { + @ApiProperty({ example: 'schedule-uuid' }) + @IsString() scheduleId: string; + + @ApiProperty({ example: 'station-uuid' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid' }) + @IsString() destinationStationId: string; + + @ApiProperty({ enum: ReservationBookingKind, description: 'STAFF: no fee, ticket issued immediately. PASSENGER: a payment link is sent to phone.' }) + @IsEnum(ReservationBookingKind) bookingKind: ReservationBookingKind; + + @ApiProperty({ example: 'Abebe Kebede' }) + @IsString() passengerName: string; + + @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation' }) + @IsDateString() dateOfBirth: string; + + @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) + @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; + + @ApiPropertyOptional({ example: 'ET123456789' }) + @IsOptional() @IsString() idDocumentNumber?: string; + + @ApiPropertyOptional({ example: 'P1234567' }) + @IsOptional() @IsString() passportNumber?: string; + + @ApiProperty({ example: 'Ethiopian', enum: RESERVATION_NATIONALITIES, description: 'Drives both the LOCAL/INTERNATIONAL fare tier and passport-vs-national-ID validation.' }) + @IsIn(RESERVATION_NATIONALITIES) nationality: ReservationNationality; + + @ApiPropertyOptional({ example: '+251912345678', description: 'Required when bookingKind is PASSENGER — the payment link is sent here.' }) + @IsOptional() @IsString() phone?: string; + + @ApiPropertyOptional({ example: 'abebe@email.com' }) + @IsOptional() @IsString() email?: string; + + @ApiPropertyOptional({ example: 'ETB', enum: Currency }) + @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; +} 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 3e2a225e6..0e27eb989 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 @@ -6,9 +6,14 @@ import { CurrencyService } from '../currency/currency.service'; import { PassengerAuthService } from '../auth/passenger-auth.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; -import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; +import { PaymentsService } from '../payments/payments.service'; +import { AuditService } from '../../common/audit.service'; +import { SmsClientService } from '../notifications/sms-client.service'; +import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto'; +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'; +import { randomUUID } from 'crypto'; /** * Throws if the given boarding stop's own configurable check-in cutoff (route/stop @@ -25,6 +30,29 @@ function assertWithinCheckinCutoff(schedule: any, stopTime: any, stationId: stri } } +/** + * Resolves the seat class for a specific, already-known seat (its coach type only ever + * offers a fixed set of classes) given the traveler's nationality tier — mirrors + * search.service.ts's own LOCAL/INTERNATIONAL + bed-position matching so the price a + * reservation-issued booking charges is the exact same "already configured price setup" + * search results would have quoted, without asking the admin to redundantly re-pick a class + * for a seat whose class is already fixed. + */ +function resolveSeatClassForSeat(seat: any, nationality: string): { id: string; name: string } { + const nationalityUpper = (nationality ?? '').toUpperCase(); + const resolvedNationalityType = nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN' ? 'LOCAL' : 'INTERNATIONAL'; + const candidates = (seat.coach?.coachType?.seatClasses ?? []).filter( + (sc: any) => !sc.nationalityType || sc.nationalityType === resolvedNationalityType, + ); + const matchingClass = seat.bedPosition + ? candidates.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition) + : candidates[0]; + if (!matchingClass) { + throw new BadRequestException('No seat class is configured for this seat and nationality — set up seat classes for this coach type first.'); + } + return matchingClass; +} + function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); @@ -65,6 +93,9 @@ export class GuestBookingService { private passengerAuthService: PassengerAuthService, private fareEngine: FareEngineService, private eventEmitter: EventEmitter2, + private paymentsService: PaymentsService, + private auditService: AuditService, + private smsClient: SmsClientService, ) { } /** @@ -384,6 +415,228 @@ export class GuestBookingService { }; } + /** + * Converts an admin-reserved seat (SeatBlock) into a real booking for one traveler — + * no SeatHold involved (the seat was already set aside), so this mirrors + * createGuestOneWayBooking's schedule/fare/passenger resolution but skips the hold check + * and instead validates+releases the SeatBlock. STAFF bookings are fee-waived and + * finalized immediately via the same PaymentsService.finalizePaymentSuccess() path every + * real payment webhook uses; PASSENGER bookings are left PENDING_PAYMENT with a payToken + * texted to the traveler so they can pay via the existing, already-public /payments/* + * endpoints without a portal session. + */ + async issueBookingFromReservation( + seatId: string, + dto: IssueReservationBookingDto, + actingUserId: string | null, + ): Promise<{ booking: any; payUrl?: string }> { + if (dto.bookingKind === ReservationBookingKind.PASSENGER && !dto.phone) { + throw new BadRequestException('Phone number is required for a passenger booking'); + } + if (dto.idDocumentType === IdDocumentType.NATIONAL_ID && dto.nationality !== 'Ethiopian') { + throw new BadRequestException('National ID is only valid for Ethiopian nationality — use a passport instead'); + } + + const seatBlock = await this.prisma.seatBlock.findFirst({ + where: { seatId, OR: [{ scheduleId: dto.scheduleId }, { scheduleId: null }] }, + }); + if (!seatBlock) throw new NotFoundException('Seat is not reserved'); + + // The seat (and therefore its coach) is already fixed by the reservation — resolve the + // seat class from the seat's own coach type + the traveler's nationality tier, the same + // LOCAL/INTERNATIONAL + bed-position matching search results already use, instead of + // asking the admin to redundantly pick a class. + const seat = await this.prisma.seat.findUnique({ + where: { id: seatId }, + include: { coach: { include: { coachType: { include: { seatClasses: true } } } } }, + }); + if (!seat) throw new NotFoundException('Seat not found'); + const resolvedSeatClass = resolveSeatClassForSeat(seat, dto.nationality); + + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + include: { + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + route: { include: { stops: true } }, + }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId) + ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined); + const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId) + ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined); + if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); + + assertWithinCheckinCutoff(schedule, originStop, dto.originStationId); + + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; + const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; + + // Single-traveler passenger processing. nationality is a fixed dropdown value (Ethiopian/ + // Djiboutian/Other), so — unlike the guest-booking loop this mirrors — there's no need to + // infer it from document type/country; only the Verifayda check (NATIONAL_ID) and the + // passport-number requirement (PASSPORT) still depend on the chosen document type. + const dateOfBirth = new Date(dto.dateOfBirth); + const age = calculateAge(dateOfBirth); + const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; + + let passengerName = dto.passengerName; + let verifaydaVerified = false; + let verifaydaData: Record | undefined; + + if (dto.idDocumentType === IdDocumentType.NATIONAL_ID) { + if (dto.idDocumentNumber) { + const verification = await this.verifaydaService.verifyNationalId(dto.idDocumentNumber); + if (!verification.verified) { + throw new BadRequestException(`Verifayda verification failed for ${dto.passengerName}: ${verification.failureReason}`); + } + passengerName = verification.passengerData?.fullName || passengerName; + verifaydaVerified = true; + verifaydaData = verification.passengerData?.profileData; + } + } else if (dto.idDocumentType === IdDocumentType.PASSPORT) { + if (!dto.passportNumber) { + throw new BadRequestException(`Passport number required for ${dto.passengerName}`); + } + } + + const passengerData = { + passengerName, + dateOfBirth, + category, + verifaydaVerified, + verifaydaData, + nationality: dto.nationality, + idDocumentType: dto.idDocumentType, + idDocumentNumber: dto.idDocumentNumber, + passportNumber: dto.passportNumber, + phone: dto.phone, + email: dto.email, + }; + + const baseFareMinor = await this.getBaseFare( + dto.scheduleId, + resolvedSeatClass.id, + segmentRoute, + fullRoute, + dto.nationality, + dto.originStationId, + dto.destinationStationId, + ); + + const isStaff = dto.bookingKind === ReservationBookingKind.STAFF; + const displayCurrency = dto.displayCurrency || Currency.ETB; + const totalMinor = isStaff ? 0 : baseFareMinor; + if (!isStaff) { + // Defense-in-depth — there's no client-forgeable price on this DTO, but keep the + // same authoritative-fare floor every other booking path enforces. + this.assertTotalNotUnderAuthoritative(totalMinor, baseFareMinor, 'issueBookingFromReservation'); + } + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + const { guestPassengerId } = await this.resolveGuestPassenger({}, passengerData); + + const payToken = isStaff ? undefined : randomUUID(); + const payTokenExpiresAt = isStaff ? undefined : computePaymentDeadline(new Date(), schedule.departureAt); + + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: guestPassengerId, + scheduleId: dto.scheduleId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + status: 'PENDING_PAYMENT', + totalMinor, + currency: Currency.ETB, + adultCount: category === PassengerCategory.ADULT ? 1 : 0, + childCount: category === PassengerCategory.CHILD ? 1 : 0, + displayCurrency, + displayTotalMinor, + bookingType: 'ONE_WAY', + source: 'BACKOFFICE_RESERVATION', + contactEmail: dto.email || null, + contactPhone: dto.phone || null, + payToken, + payTokenExpiresAt, + seats: { + create: [{ + seat: { connect: { id: seatId } }, + scheduleId: dto.scheduleId, + passengerName: passengerData.passengerName, + dateOfBirth: passengerData.dateOfBirth, + passengerCategory: passengerData.category, + idDocumentType: passengerData.idDocumentType, + passportNumber: passengerData.passportNumber, + verifaydaVerified: passengerData.verifaydaVerified, + verifaydaData: passengerData.verifaydaData || undefined, + fareMinor: totalMinor, + displayCurrency, + }], + }, + }, + include: { + seats: { include: { seat: { include: { coach: true } } } }, + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + }, + }); + + await this.createTravelerProfiles(guestPassengerId, [passengerData]); + + // Release the reservation using the SAME scope it was created with (global vs + // schedule-scoped) — unblockSeat already correctly resets Seat.status for a global + // block; reimplementing that here would risk missing that reset. + await this.seatsService.unblockSeat(seatId, seatBlock.scheduleId ?? undefined); + await this.seatsService.confirmSeats([seatId]); + this.eventEmitter.emit('booking.created', { booking }); + + if (isStaff) { + await this.auditService.log({ + userId: actingUserId ?? undefined, + action: 'CREATE', + entityType: 'Booking', + entityId: booking.id, + newData: { feeWaived: true, waivedBy: actingUserId, originalFareMinor: baseFareMinor }, + }); + + const intent = await this.prisma.paymentIntent.create({ + data: { + bookingId: booking.id, + amountMinor: 0, + currency: 'ETB', + // WALLET is an internal-only method that never leaves this app (see + // payments.service.ts) — safe, inert placeholder for a zero-charge waiver. + method: PaymentMethodType.WALLET, + status: PaymentIntentStatus.REQUIRES_ACTION, + }, + }); + await this.paymentsService.finalizePaymentSuccess({ intentId: intent.id }); + // finalizePaymentSuccess mutates the booking (status -> CONFIRMED) in the DB — + // re-fetch so the caller sees the actual outcome, not the pre-finalization snapshot. + const confirmedBooking = await this.prisma.booking.findUnique({ where: { id: booking.id } }); + return { booking: confirmedBooking }; + } + + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; + const payUrl = `${portalUrl}/reserve/pay/${payToken}`; + const amountStr = (totalMinor / 100).toFixed(2); + try { + await this.smsClient.sendSms({ + to: dto.phone!, + message: `EDR: Your seat is reserved. Pay ${amountStr} ETB to confirm your ticket: ${payUrl}`, + }); + } catch (err) { + this.logger.warn(`Reservation payment-link SMS failed for booking ${booking.bookingRef}: ${err}`); + } + + return { booking, payUrl }; + } + private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) { if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) { throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP'); diff --git a/apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts b/apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts index b05495296..b02820af4 100644 --- a/apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts +++ b/apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts @@ -1,15 +1,12 @@ /** * Auth/authorization gaps (matrix Suite J), via route guard metadata — no boot needed. * - * C-8 🔴 The exchange-rate write routes (PUT upsert, PATCH update) carry no METHOD-LEVEL guard, so - * they get only the global JwtGuard (authentication) and NOT @PassengerAdmin (authorization) - * — unlike DELETE, which is admin-gated. Net effect (verified live in - * e2e-ui .../pb-config-propagation.spec.ts BC-11): anonymous → 401, but ANY authenticated - * user incl. a passenger → 200 rewrites live FX. fare-engine/currency.controller.ts:25,32,42 - * - * NOTE: this metadata check proves the missing ADMIN guard, NOT "unauthenticated" — a global - * APP_GUARD=JwtGuard (SharedAuthModule) still requires a valid token. The earlier "unauthenticated - * FX write" reading was a false positive corrected by the live BC-11 test. + * C-8 ✅ FIXED (was 🔴 "The exchange-rate write routes (PUT upsert, PATCH update) carry no + * METHOD-LEVEL guard, so they get only the global JwtGuard, not @PassengerAdmin — + * unlike DELETE, which was admin-gated. Net effect: any authenticated user incl. a + * passenger could rewrite live FX rates."): currency.controller.ts now decorates + * upsert/update/remove all with @PassengerAdmin() — confirmed by reading the source. + * Updated below to assert all three routes are admin-gated, not just DELETE. */ import "reflect-metadata"; import { CurrencyController } from "../src/modules/fare-engine/currency.controller"; @@ -20,15 +17,15 @@ function guardsOn(handler: unknown): unknown[] { } describe("Auth gaps (Suite J)", () => { - it("C-8 🔴 PUT upsert exchange-rate has NO admin guard (only the global JwtGuard applies)", () => { - expect(guardsOn(CurrencyController.prototype.upsert)).toHaveLength(0); + it("C-8 ✅ PUT upsert exchange-rate IS admin-gated", () => { + expect(guardsOn(CurrencyController.prototype.upsert).length).toBeGreaterThan(0); }); - it("C-8 🔴 PATCH update exchange-rate has NO admin guard (only the global JwtGuard applies)", () => { - expect(guardsOn(CurrencyController.prototype.update)).toHaveLength(0); + it("C-8 ✅ PATCH update exchange-rate IS admin-gated", () => { + expect(guardsOn(CurrencyController.prototype.update).length).toBeGreaterThan(0); }); - it("C-8 control: DELETE exchange-rate IS admin-gated — proving writes should be too", () => { + it("C-8 control: DELETE exchange-rate IS admin-gated too — all three writes consistently guarded", () => { expect(guardsOn(CurrencyController.prototype.remove).length).toBeGreaterThan(0); }); }); diff --git a/apps/edr-passenger-api/test/authed-booking-passengerid.e2e-spec.ts b/apps/edr-passenger-api/test/authed-booking-passengerid.e2e-spec.ts index ff4a59944..3fa50f98d 100644 --- a/apps/edr-passenger-api/test/authed-booking-passengerid.e2e-spec.ts +++ b/apps/edr-passenger-api/test/authed-booking-passengerid.e2e-spec.ts @@ -82,6 +82,7 @@ describe("Authenticated booking passengerId resolution (regression)", () => { prisma as any, { query: async () => [] } as any, // dataSource (resolveIamContact raw SQL → []) asyncStub(), // seatsService + asyncStub(), // ticketsService — constructor gained this param since this test was written { emit: () => true } as any, asyncStub(), // verifaydaService (PASSPORT skips) asyncStub(), // currencyService (ETB skips) diff --git a/apps/edr-passenger-api/test/booking-types.e2e-spec.ts b/apps/edr-passenger-api/test/booking-types.e2e-spec.ts new file mode 100644 index 000000000..105be8a2e --- /dev/null +++ b/apps/edr-passenger-api/test/booking-types.e2e-spec.ts @@ -0,0 +1,365 @@ +/** + * Booking-type coverage — ROUND_TRIP, TRANSIT, ROUND_TRIP_TRANSIT end-to-end through + * GuestBookingService.createGuestBooking(). ONE_WAY is already covered by + * stop-based-booking-segment.e2e-spec.ts / checkin-cutoff.e2e-spec.ts and is not repeated + * here. + * + * TRANSIT/ROUND_TRIP_TRANSIT use seed-core's single A(seq1)->B(seq2)->C(seq3) route for the + * outbound/leg2 direction (transit at B). ROUND_TRIP/ROUND_TRIP_TRANSIT additionally need a + * genuine return direction — createGuestRoundTripBooking does NOT validate stop-sequence + * ordering between origin/destination (confirmed by reading the method), so it would silently + * accept a "return" leg on the same forward route, but that's not what a real round trip is. + * A local reverse route (C(seq1)->B(seq2)->A(seq3)) is created per-test instead, keeping + * seed-core.ts itself untouched (this reverse route isn't a general-purpose fixture need). + * + * Uses the same harness/Tier-2 pattern as reserve-seat-issue-booking.e2e-spec.ts and + * stop-based-booking-segment.e2e-spec.ts. + */ +import { IdDocumentType, PassengerCategory } from "@prisma/client"; +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { SeatsService } from "../src/modules/seats/seats.service"; +import { CurrencyService } from "../src/modules/currency/currency.service"; +import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; +import { SystemConfigService } from "../src/modules/system-config/system-config.service"; +import { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, DISTANCE, resetAndSeedCore } from "./fixtures/seed-core"; + +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +describe("Booking types — ROUND_TRIP / TRANSIT / ROUND_TRIP_TRANSIT", () => { + let harness: ServiceHarness; + let schedulesService: SchedulesService; + let seatsService: SeatsService; + let guestBookingService: GuestBookingService; + let reverseRouteId: string; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + const currencyService = harness.moduleRef.get(CurrencyService); + const fareEngine = harness.moduleRef.get(FareEngineService); + const systemConfig = new SystemConfigService(harness.prisma as any); + + seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + guestBookingService = new GuestBookingService( + harness.prisma as any, + seatsService, + asyncStub(), // verifaydaService — test passengers use PASSPORT, not NATIONAL_ID + currencyService, + asyncStub(), // passengerAuthService — no createAccount in these DTOs + fareEngine, + { emit: () => true } as any, // eventEmitter + asyncStub(), // paymentsService — not reached by createGuestBooking + asyncStub(), // auditService + asyncStub(), // smsClient + ); + }); + + afterAll(async () => { + await harness?.close(); + }); + + beforeEach(async () => { + await resetAndSeedCore(harness.prisma); + reverseRouteId = await createReverseRoute(); + }); + + /** Mirrors seed-core's A->B->C route but reversed (C->B->A), for a genuine return leg. */ + async function createReverseRoute(): Promise { + const route = await harness.prisma.route.create({ + data: { + code: `RT-REV-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + name: "Reverse Line", + effectiveFrom: new Date("2020-01-01T00:00:00.000Z"), + active: true, + stops: { + create: [ + { stationId: IDS.stationC, sequence: 1, distanceKm: 0 }, + { stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.C - DISTANCE.B }, + { stationId: IDS.stationA, sequence: 3, distanceKm: DISTANCE.C - DISTANCE.A }, + ], + }, + }, + }); + return route.id; + } + + /** Coach assigned via coachIds at creation time — createSchedule rejects zero coaches. */ + async function createTestSchedule(opts: { trainNumber: string; routeId: string; departureAt: Date; arrivalAt: Date }) { + const train = await harness.prisma.train.create({ + data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` }, + }); + const coach = await harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 6, sequence: 1, status: "ACTIVE" }, + }); + const seats = await Promise.all( + ["1A", "1B", "1C", "1D", "1E", "1F"].map((seatNumber, i) => + harness.prisma.seat.create({ + data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 }, + }), + ), + ); + const schedule = await schedulesService.createSchedule({ + trainId: train.id, + routeId: opts.routeId, + departureAt: opts.departureAt.toISOString(), + arrivalAt: opts.arrivalAt.toISOString(), + coachIds: [coach.id], + } as any); + return { schedule, seats }; + } + + async function hold(scheduleId: string, originStationId: string, destinationStationId: string, seatId: string, passengerId: string) { + return seatsService.holdSeats({ + scheduleId, + originStationId, + destinationStationId, + passengers: [{ passengerId, seatId }], + } as any); + } + + /** Holds several seats on the SAME leg in one SeatHold row (one holdId covers them all) — + * required when a single booking carries multiple travelers on one leg: holdSeats rejects a + * second call for the same passengerId+leg as a conflict, so multi-passenger holds must be + * one call with distinct passengerIds, not N sequential single-seat calls. */ + async function holdMany(scheduleId: string, originStationId: string, destinationStationId: string, seatIds: string[]) { + return seatsService.holdSeats({ + scheduleId, + originStationId, + destinationStationId, + passengers: seatIds.map((seatId, i) => ({ passengerId: `44444444-4444-4444-8444-40000000000${i}`, seatId })), + } as any); + } + + function passenger(overrides: Partial> = {}) { + return { + passengerName: "Test Traveler", + dateOfBirth: "1990-01-01", + idDocumentType: IdDocumentType.PASSPORT, + passportNumber: "X123456", + passportCountry: "Djibouti", + nationality: "Djiboutian", + ...overrides, + }; + } + + const future = (mins: number) => new Date(Date.now() + mins * 60_000); + + describe("ROUND_TRIP", () => { + it("creates a booking with correct fields for both legs", async () => { + const dep = future(180); + const arr = future(280); + const { schedule: outbound, seats: outSeats } = await createTestSchedule({ trainNumber: `RT-OB-${Date.now()}`, routeId: IDS.route, departureAt: dep, arrivalAt: arr }); + const { schedule: ret, seats: retSeats } = await createTestSchedule({ trainNumber: `RT-RET-${Date.now()}`, routeId: reverseRouteId, departureAt: future(400), arrivalAt: future(480) }); + + const outHold = await hold(outbound.id, IDS.stationA, IDS.stationC, outSeats[0].id, "11111111-1111-4111-8111-100000000001"); + const retHold = await hold(ret.id, IDS.stationC, IDS.stationA, retSeats[0].id, "11111111-1111-4111-8111-100000000001"); + + const result: any = await guestBookingService.createGuestBooking({ + bookingType: "ROUND_TRIP", + scheduleId: outbound.id, + holdId: (outHold as any).holdId, + originStationId: IDS.stationA, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + returnScheduleId: ret.id, + returnHoldId: (retHold as any).holdId, + returnOriginStationId: IDS.stationC, + returnDestinationStationId: IDS.stationA, + passengers: [passenger({ seatId: outSeats[0].id, returnSeatId: retSeats[0].id })], + } as any); + + expect(result.bookingType).toBe("ROUND_TRIP"); + expect(result.returnScheduleId).toBe(ret.id); + expect(result.returnOriginStationId).toBe(IDS.stationC); + expect(result.returnDestinationStationId).toBe(IDS.stationA); + expect(result.totalMinor).toBeGreaterThan(0); + + const seatRows = await harness.prisma.bookingSeat.findMany({ where: { bookingId: result.id } }); + expect(seatRows).toHaveLength(2); // one BookingSeat per leg + expect(seatRows.map((s) => s.scheduleId).sort()).toEqual([outbound.id, ret.id].sort()); + }); + + it("rejects a ROUND_TRIP missing return fields with the exact message", async () => { + const { schedule: outbound, seats } = await createTestSchedule({ trainNumber: `RT-MISS-${Date.now()}`, routeId: IDS.route, departureAt: future(180), arrivalAt: future(280) }); + const outHold = await hold(outbound.id, IDS.stationA, IDS.stationC, seats[0].id, "11111111-1111-4111-8111-100000000002"); + + await expect( + guestBookingService.createGuestBooking({ + bookingType: "ROUND_TRIP", + scheduleId: outbound.id, + holdId: (outHold as any).holdId, + originStationId: IDS.stationA, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + passengers: [passenger({ seatId: seats[0].id })], + } as any), + ).rejects.toThrow(/returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required/i); + }); + + // NOTE: a "first child travels free" test was attempted here and dropped — the + // fareMinor-by-seatId-presence branch (`p.seatId ? childUnitFare : 0`, commented "Free + // children have no seat (frontend excludes them from the DTO)") is UNREACHABLE in + // practice: every passenger row unconditionally goes through + // `seat: { connect: { id: p.seatId } } }` when the booking is created (confirmed in + // ONE_WAY at line ~367, ROUND_TRIP at ~874, TRANSIT at ~1075) — a passenger with no + // `seatId` makes that Prisma `connect` throw, so a truly seat-less "free child" can never + // reach the booking-creation step at all. FLAGGED, not fixed — see the final report. + + it("two adults on the same booking are each charged the full per-leg fare", async () => { + const dep = future(180); + const { schedule: outbound, seats: outSeats } = await createTestSchedule({ trainNumber: `RT-2ADULT-OB-${Date.now()}`, routeId: IDS.route, departureAt: dep, arrivalAt: future(280) }); + const { schedule: ret, seats: retSeats } = await createTestSchedule({ trainNumber: `RT-2ADULT-RET-${Date.now()}`, routeId: reverseRouteId, departureAt: future(400), arrivalAt: future(480) }); + + const outHold = await holdMany(outbound.id, IDS.stationA, IDS.stationC, [outSeats[0].id, outSeats[1].id]); + const retHold = await holdMany(ret.id, IDS.stationC, IDS.stationA, [retSeats[0].id, retSeats[1].id]); + + const result: any = await guestBookingService.createGuestBooking({ + bookingType: "ROUND_TRIP", + scheduleId: outbound.id, + holdId: (outHold as any).holdId, + originStationId: IDS.stationA, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + returnScheduleId: ret.id, + returnHoldId: (retHold as any).holdId, + returnOriginStationId: IDS.stationC, + returnDestinationStationId: IDS.stationA, + passengers: [ + passenger({ seatId: outSeats[0].id, returnSeatId: retSeats[0].id, passengerName: "Adult One" }), + passenger({ seatId: outSeats[1].id, returnSeatId: retSeats[1].id, passengerName: "Adult Two" }), + ], + } as any); + + expect(result.adultCount).toBe(2); + expect(result.childCount).toBe(0); + const seatFares = await harness.prisma.bookingSeat.findMany({ where: { bookingId: result.id } }); + expect(seatFares).toHaveLength(4); // 2 passengers x 2 legs + const fareMinors = new Set(seatFares.map((s) => s.fareMinor)); + expect(fareMinors.size).toBe(1); // every seat on every leg is the same base fare + expect([...fareMinors][0]).toBeGreaterThan(0); + const total = seatFares.reduce((sum, s) => sum + s.fareMinor, 0); + expect(result.totalMinor).toBe(total); + }); + }); + + describe("TRANSIT", () => { + it("creates a single booking spanning leg-1 and leg-2 via the transit station", async () => { + const dep = future(180); + const { schedule: leg1, seats: leg1Seats } = await createTestSchedule({ trainNumber: `TR-L1-${Date.now()}`, routeId: IDS.route, departureAt: dep, arrivalAt: future(220) }); + const { schedule: leg2, seats: leg2Seats } = await createTestSchedule({ trainNumber: `TR-L2-${Date.now()}`, routeId: IDS.route, departureAt: future(260), arrivalAt: future(320) }); + + const leg1Hold = await hold(leg1.id, IDS.stationA, IDS.stationB, leg1Seats[0].id, "22222222-2222-4222-8222-200000000001"); + const leg2Hold = await hold(leg2.id, IDS.stationB, IDS.stationC, leg2Seats[0].id, "22222222-2222-4222-8222-200000000001"); + + const result: any = await guestBookingService.createGuestBooking({ + bookingType: "TRANSIT", + scheduleId: leg1.id, + holdId: (leg1Hold as any).holdId, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + seatClassId: IDS.seatClassLocal, + leg2ScheduleId: leg2.id, + leg2HoldId: (leg2Hold as any).holdId, + transitStationId: IDS.stationB, + leg2DestinationStationId: IDS.stationC, + passengers: [passenger({ seatId: leg1Seats[0].id, leg2SeatId: leg2Seats[0].id })], + } as any); + + expect(result.bookingType).toBe("TRANSIT"); + expect(result.originStationId).toBe(IDS.stationA); + expect(result.destinationStationId).toBe(IDS.stationC); // full journey span, not just leg-1 + expect(result.leg2ScheduleId).toBe(leg2.id); + expect(result.leg2OriginStationId).toBe(IDS.stationB); + expect(result.leg2DestinationStationId).toBe(IDS.stationC); + + const seatRows = await harness.prisma.bookingSeat.findMany({ where: { bookingId: result.id } }); + expect(seatRows).toHaveLength(2); + expect(seatRows.map((s) => s.scheduleId).sort()).toEqual([leg1.id, leg2.id].sort()); + }); + + it("rejects a TRANSIT missing leg-2 fields with the exact message", async () => { + const { schedule: leg1, seats } = await createTestSchedule({ trainNumber: `TR-MISS-${Date.now()}`, routeId: IDS.route, departureAt: future(180), arrivalAt: future(220) }); + const leg1Hold = await hold(leg1.id, IDS.stationA, IDS.stationB, seats[0].id, "22222222-2222-4222-8222-200000000002"); + + await expect( + guestBookingService.createGuestBooking({ + bookingType: "TRANSIT", + scheduleId: leg1.id, + holdId: (leg1Hold as any).holdId, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + seatClassId: IDS.seatClassLocal, + passengers: [passenger({ seatId: seats[0].id })], + } as any), + ).rejects.toThrow(/leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required/i); + }); + }); + + describe("ROUND_TRIP_TRANSIT", () => { + it("creates a single booking spanning all 4 legs (outbound x2, return x2)", async () => { + const { schedule: obL1, seats: obL1Seats } = await createTestSchedule({ trainNumber: `RTT-OB1-${Date.now()}`, routeId: IDS.route, departureAt: future(180), arrivalAt: future(220) }); + const { schedule: obL2, seats: obL2Seats } = await createTestSchedule({ trainNumber: `RTT-OB2-${Date.now()}`, routeId: IDS.route, departureAt: future(260), arrivalAt: future(320) }); + const { schedule: retL1, seats: retL1Seats } = await createTestSchedule({ trainNumber: `RTT-RET1-${Date.now()}`, routeId: reverseRouteId, departureAt: future(500), arrivalAt: future(560) }); + const { schedule: retL2, seats: retL2Seats } = await createTestSchedule({ trainNumber: `RTT-RET2-${Date.now()}`, routeId: reverseRouteId, departureAt: future(600), arrivalAt: future(660) }); + + const pid = "33333333-3333-4333-8333-300000000001"; + const obL1Hold = await hold(obL1.id, IDS.stationA, IDS.stationB, obL1Seats[0].id, pid); + const obL2Hold = await hold(obL2.id, IDS.stationB, IDS.stationC, obL2Seats[0].id, pid); + const retL1Hold = await hold(retL1.id, IDS.stationC, IDS.stationB, retL1Seats[0].id, pid); + const retL2Hold = await hold(retL2.id, IDS.stationB, IDS.stationA, retL2Seats[0].id, pid); + + const result: any = await guestBookingService.createGuestBooking({ + bookingType: "ROUND_TRIP_TRANSIT", + scheduleId: obL1.id, + holdId: (obL1Hold as any).holdId, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + seatClassId: IDS.seatClassLocal, + leg2ScheduleId: obL2.id, + leg2HoldId: (obL2Hold as any).holdId, + transitStationId: IDS.stationB, + leg2DestinationStationId: IDS.stationC, + returnScheduleId: retL1.id, + returnHoldId: (retL1Hold as any).holdId, + returnOriginStationId: IDS.stationC, + returnDestinationStationId: IDS.stationB, + returnLeg2ScheduleId: retL2.id, + returnLeg2HoldId: (retL2Hold as any).holdId, + returnTransitStationId: IDS.stationB, + returnLeg2DestinationStationId: IDS.stationA, + passengers: [passenger({ + seatId: obL1Seats[0].id, + leg2SeatId: obL2Seats[0].id, + returnSeatId: retL1Seats[0].id, + returnLeg2SeatId: retL2Seats[0].id, + })], + } as any); + + expect(result.bookingType).toBe("ROUND_TRIP_TRANSIT"); + const seatRows = await harness.prisma.bookingSeat.findMany({ where: { bookingId: result.id } }); + expect(seatRows).toHaveLength(4); + expect(seatRows.map((s) => s.scheduleId).sort()).toEqual([obL1.id, obL2.id, retL1.id, retL2.id].sort()); + }); + + it("rejects a ROUND_TRIP_TRANSIT missing any leg's fields with the exact message", async () => { + const { schedule: obL1, seats } = await createTestSchedule({ trainNumber: `RTT-MISS-${Date.now()}`, routeId: IDS.route, departureAt: future(180), arrivalAt: future(220) }); + const obL1Hold = await hold(obL1.id, IDS.stationA, IDS.stationB, seats[0].id, "33333333-3333-4333-8333-300000000002"); + + await expect( + guestBookingService.createGuestBooking({ + bookingType: "ROUND_TRIP_TRANSIT", + scheduleId: obL1.id, + holdId: (obL1Hold as any).holdId, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + seatClassId: IDS.seatClassLocal, + passengers: [passenger({ seatId: seats[0].id })], + } as any), + ).rejects.toThrow(/ROUND_TRIP_TRANSIT requires all 4 holds and all transit\/return station fields/i); + }); + }); +}); diff --git a/apps/edr-passenger-api/test/config-validation.e2e-spec.ts b/apps/edr-passenger-api/test/config-validation.e2e-spec.ts index d17f568a1..2bdd4267b 100644 --- a/apps/edr-passenger-api/test/config-validation.e2e-spec.ts +++ b/apps/edr-passenger-api/test/config-validation.e2e-spec.ts @@ -3,10 +3,13 @@ * enforces exactly these class-validator DTOs, so validating the DTOs directly reproduces what a * raw API call (bypassing the HTML-only frontend checks) would be allowed to submit. * H1 🔴 CreateFareRuleDto.baseFareMinor accepts NEGATIVE (no @Min) — while the sibling - * CreateSegmentFareDto.baseFareMinor has @Min(0) (inconsistent). - * H2 🔴 CreateSeatClassDto.basePrice accepts negative/zero (no @Min) — drives every distance fare. - * H4 🔴 CreatePromotionDto.percentOff accepts 200 (no @Max(100)) → discount > subtotal. + * CreateSegmentFareDto.baseFareMinor has @Min(0) (inconsistent). Still unfixed. + * H2 ✅ FIXED (was 🔴 "CreateSeatClassDto.basePrice accepts negative/zero, no @Min"): + * seat-classes.dto.ts now has @Min(0) on basePrice. Test updated to assert the fix. + * H4 ✅ FIXED (was 🔴 "CreatePromotionDto.percentOff accepts 200, no @Max(100)"): + * promos.dto.ts now has @Min(0) @Max(100) on percentOff. Test updated to assert the fix. * H5 🔴 CreatePromotionDto.validUntil is @IsString (not @IsDateString) → accepts non-dates. + * Still unfixed. */ import "reflect-metadata"; import { plainToInstance } from "class-transformer"; @@ -44,23 +47,23 @@ describe("Backoffice config validation (Suite H)", () => { expect(await erroredProps(dto)).toContain("baseFareMinor"); }); - it("H2 🔴 CreateSeatClassDto accepts a negative basePrice (no @Min)", async () => { + it("H2 ✅ CreateSeatClassDto rejects a negative basePrice (@Min(0))", async () => { const dto = plainToInstance(CreateSeatClassDto, { coachTypeId: "ct-1", name: "Economy", basePrice: -5000, }); - expect(await erroredProps(dto)).not.toContain("basePrice"); + expect(await erroredProps(dto)).toContain("basePrice"); }); - it("H4 🔴 CreatePromotionDto accepts percentOff = 200 (no @Max(100))", async () => { + it("H4 ✅ CreatePromotionDto rejects percentOff = 200 (@Max(100))", async () => { const dto = plainToInstance(CreatePromotionDto, { code: "OVER", title: "Overshoot", percentOff: 200, validUntil: "2026-12-31T23:59:59Z", }); - expect(await erroredProps(dto)).not.toContain("percentOff"); + expect(await erroredProps(dto)).toContain("percentOff"); }); it("H5 🔴 CreatePromotionDto.validUntil accepts a non-date string (@IsString, not @IsDateString)", async () => { diff --git a/apps/edr-passenger-api/test/critical-repro.e2e-spec.ts b/apps/edr-passenger-api/test/critical-repro.e2e-spec.ts index 83a7d8d6b..8058066c7 100644 --- a/apps/edr-passenger-api/test/critical-repro.e2e-spec.ts +++ b/apps/edr-passenger-api/test/critical-repro.e2e-spec.ts @@ -2,8 +2,10 @@ * Executable reproducers for the highest-severity findings that were previously inspection-only. * All Tier-2 (direct instantiation, real Prisma + stubbed collaborators). * - * C-1 🔴 BookingsService trusts client `reviewedTotalMinor`: a booking is stored with totalMinor=1 - * while the server fare engine computed ~30000. + * C-1 ✅ FIXED (was 🔴 "BookingsService trusts client reviewedTotalMinor: a booking is + * stored with totalMinor=1 while the server fare engine computed ~30000") — + * createOneWayBooking now runs assertTotalNotUnderAuthoritative before persisting; + * a forged low total is rejected, not stored. Test below updated to assert this. * C-4 🔴 finalizePaymentSuccess confirms a booking without comparing the paid amount: an intent for * 1 minor confirms a 30000 booking. * C-6 🔴 Concurrent WALLET payments double-spend one balance (no row lock): a wallet funded for one @@ -164,14 +166,16 @@ describe("Critical reproducers (Tier-2)", () => { ], }; - const result: any = await (bookings as any).createOneWayBooking(dto); + // FIXED (was 🔴): createOneWayBooking now runs the forged reviewedTotalMinor through + // assertTotalNotUnderAuthoritative before ever writing a Booking row — a client total + // below the server-computed fare (minus a 1% rounding tolerance) is rejected outright, + // not silently persisted. Confirmed by reading bookings.service.ts's C-1 guard comment. + await expect((bookings as any).createOneWayBooking(dto)).rejects.toThrow( + /Booking total does not match the authoritative fare/i, + ); - // The server engine computed the real fare… - expect(result.fareBreakdown.totalMinor).toBeGreaterThanOrEqual(30_000); - // …but the booking was stored at the client's forged 1 minor. - expect(result.totalMinor).toBe(1); - const stored = await prisma.booking.findUnique({ where: { id: result.id } }); - expect(stored?.totalMinor).toBe(1); + const stored = await prisma.booking.findFirst({ where: { scheduleId: schedule.id, passengerId: passenger.id } }); + expect(stored).toBeNull(); // no under-priced booking left behind }); // ── C-4 ────────────────────────────────────────────────────────────────── diff --git a/apps/edr-passenger-api/test/currency-through-booking.e2e-spec.ts b/apps/edr-passenger-api/test/currency-through-booking.e2e-spec.ts new file mode 100644 index 000000000..b3ab0a76a --- /dev/null +++ b/apps/edr-passenger-api/test/currency-through-booking.e2e-spec.ts @@ -0,0 +1,146 @@ +/** + * Currency conversion through a REAL booking — CurrencyService/FareEngineService are only + * exercised in isolation elsewhere (pricing-currency.e2e-spec.ts, pricing-fare-engine.e2e-spec.ts); + * nothing previously drove an actual GuestBookingService.createGuestBooking() call in a + * non-ETB displayCurrency and asserted the converted amount. + * + * Also covers the consequence of the C2 fix documented in pricing-currency.e2e-spec.ts: + * CurrencyService.getExchangeRate() now fails closed (throws) on a missing rate instead of + * silently pricing at parity — confirms that failure mode actually propagates out of booking + * creation as a real rejection, not a silently wrong charge. + */ +import { IdDocumentType, Currency } from "@prisma/client"; +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { SeatsService } from "../src/modules/seats/seats.service"; +import { CurrencyService } from "../src/modules/currency/currency.service"; +import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; +import { SystemConfigService } from "../src/modules/system-config/system-config.service"; +import { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, resetAndSeedCore, USD_TO_ETB, ETB_TO_DJF } from "./fixtures/seed-core"; + +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +describe("Currency conversion through a real booking", () => { + let harness: ServiceHarness; + let schedulesService: SchedulesService; + let seatsService: SeatsService; + let guestBookingService: GuestBookingService; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + const currencyService = harness.moduleRef.get(CurrencyService); + const fareEngine = harness.moduleRef.get(FareEngineService); + const systemConfig = new SystemConfigService(harness.prisma as any); + + seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + guestBookingService = new GuestBookingService( + harness.prisma as any, + seatsService, + asyncStub(), + currencyService, + asyncStub(), + fareEngine, + { emit: () => true } as any, + asyncStub(), + asyncStub(), + asyncStub(), + ); + }); + + afterAll(async () => { + await harness?.close(); + }); + + beforeEach(async () => { + await resetAndSeedCore(harness.prisma); + }); + + const future = (mins: number) => new Date(Date.now() + mins * 60_000); + + async function createTestSchedule(trainNumber: string) { + const train = await harness.prisma.train.create({ data: { number: trainNumber, name: "Test" } }); + const coach = await harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `${trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, + }); + const seats = await Promise.all( + ["1A", "1B", "1C"].map((seatNumber, i) => + harness.prisma.seat.create({ data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 } }), + ), + ); + const schedule = await schedulesService.createSchedule({ + trainId: train.id, routeId: IDS.route, + departureAt: future(180).toISOString(), arrivalAt: future(220).toISOString(), + coachIds: [coach.id], + } as any); + return { schedule, seats }; + } + + async function bookInCurrency(scheduleId: string, seatId: string, displayCurrency: Currency, passengerId: string, nationality: "Djiboutian" | "Ethiopian" = "Djiboutian") { + const hold = await seatsService.holdSeats({ + scheduleId, originStationId: IDS.stationA, destinationStationId: IDS.stationB, + passengers: [{ passengerId, seatId }], + } as any); + return guestBookingService.createGuestBooking({ + scheduleId, + holdId: (hold as any).holdId, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + seatClassId: IDS.seatClassLocal, + displayCurrency, + passengers: [{ + seatId, passengerName: "Test Traveler", dateOfBirth: "1990-01-01", + idDocumentType: IdDocumentType.PASSPORT, passportNumber: "X123456", passportCountry: "Djibouti", nationality, + }], + } as any) as Promise; + } + + it("charges the SAME ETB amount regardless of displayCurrency, but displays it converted by the seeded rate", async () => { + const { schedule, seats } = await createTestSchedule(`CUR-${Date.now()}`); + + const etbBooking = await bookInCurrency(schedule.id, seats[0].id, Currency.ETB, "66666666-6666-4666-8666-600000000001"); + const usdBooking = await bookInCurrency(schedule.id, seats[1].id, Currency.USD, "66666666-6666-4666-8666-600000000002"); + const djfBooking = await bookInCurrency(schedule.id, seats[2].id, Currency.DJF, "66666666-6666-4666-8666-600000000003"); + + // ETB charge basis (totalMinor) is identical across all three — displayCurrency only + // affects what's SHOWN to the passenger, never the authoritative ETB amount collected. + expect(usdBooking.totalMinor).toBe(etbBooking.totalMinor); + expect(djfBooking.totalMinor).toBe(etbBooking.totalMinor); + expect(etbBooking.currency).toBe("ETB"); + expect(usdBooking.currency).toBe("ETB"); + expect(djfBooking.currency).toBe("ETB"); + + // displayTotalMinor derived from the exact seeded rates (see seed-core.ts). + expect(etbBooking.displayTotalMinor).toBe(etbBooking.totalMinor); + expect(usdBooking.displayTotalMinor).toBe(etbBooking.totalMinor * (1 / USD_TO_ETB)); + expect(djfBooking.displayTotalMinor).toBe(etbBooking.totalMinor * ETB_TO_DJF); + + expect(etbBooking.displayCurrency).toBe("ETB"); + expect(usdBooking.displayCurrency).toBe("USD"); + expect(djfBooking.displayCurrency).toBe("DJF"); + }); + + it("rejects booking creation when the requested displayCurrency has no configured FX rate (fails closed, doesn't price at parity)", async () => { + const { schedule, seats } = await createTestSchedule(`CUR-NORATE-${Date.now()}`); + // Remove BOTH directions of the ETB<->DJF rate. Nationality is Ethiopian (not Djiboutian) + // specifically so FareEngineService's OWN internal billing-currency conversion (tied to + // nationality, resolves to ETB for an Ethiopian passenger — a same-currency no-op that + // never touches the DB) doesn't also need this rate; only the OUTER + // GuestBookingService.createGuestOneWayBooking's displayCurrency conversion does. This + // isolates the failure to that one call rather than fare calculation itself. + await harness.prisma.currencyExchangeRate.deleteMany({ + where: { OR: [{ fromCurrency: "ETB", toCurrency: "DJF" }, { fromCurrency: "DJF", toCurrency: "ETB" }] }, + }); + + await expect( + bookInCurrency(schedule.id, seats[0].id, Currency.DJF, "66666666-6666-4666-8666-600000000004", "Ethiopian"), + ).rejects.toThrow(/No exchange rate configured/i); + + // No half-created booking left behind by the failed conversion. + const orphan = await harness.prisma.booking.findFirst({ where: { scheduleId: schedule.id, displayCurrency: "DJF" } }); + expect(orphan).toBeNull(); + }); +}); diff --git a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts index 02ca87863..5a91601ab 100644 --- a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts +++ b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts @@ -85,6 +85,7 @@ describe("Money integrity (Tier-2 direct instantiation)", () => { prisma as any, asyncStub(), // dataSource asyncStub(), // seatsService + asyncStub(), // ticketsService — constructor gained this param since this test was written { emit: () => true } as any, // eventEmitter asyncStub(), // verifaydaService asyncStub(), // currencyService diff --git a/apps/edr-passenger-api/test/payments-webhook-refund.e2e-spec.ts b/apps/edr-passenger-api/test/payments-webhook-refund.e2e-spec.ts new file mode 100644 index 000000000..f4f80c694 --- /dev/null +++ b/apps/edr-passenger-api/test/payments-webhook-refund.e2e-spec.ts @@ -0,0 +1,249 @@ +/** + * Payments webhook + non-wallet provider flow — PaymentsService.handlePaymentEvent() (the + * real "webhook" entry point, invoked by both the RabbitMQ consumer and + * InternalPaymentsController — see payment-events.consumer.ts / + * internal-payments.controller.ts) had zero e2e coverage before this suite. Also exercises + * initiatePayment() for a non-wallet (provider-routed) method, mocking PaymentClientService at + * the boundary — no real payment provider is contacted. + * + * Refund disbursement is deliberately NOT re-tested here: money-integrity.e2e-spec.ts already + * covers `cancel()` computing an 80% refund that's never actually disbursed (no PaymentRefund + * row, no wallet credit) in detail — re-run that suite rather than duplicating it. Confirmed + * still true as of this session (unrelated to the module changed here). + * + * NOTE: apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts (an existing "E2E" + * suite that boots the full AppModule over HTTP) is never actually executed by any script — + * it lives outside test/jest-e2e.json's rootDir (`test/`) and doesn't match the plain `test` + * script's `.spec.ts$` regex either (the filename ends `...e2e-spec.ts`, not `...spec.ts` + * immediately preceded by a dot). Flagging as an orphaned test file, not fixed here. + */ +import { IdDocumentType, PaymentIntentStatus, PaymentMethodType } from "@prisma/client"; +import { PaymentService as PaymentServiceEnum, PaymentReferenceType, ProviderMethod, ProviderPaymentStatus } from "@edr/types"; +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { SeatsService } from "../src/modules/seats/seats.service"; +import { TicketsService } from "../src/modules/tickets/tickets.service"; +import { PaymentsService } from "../src/modules/payments/payments.service"; +import { CurrencyService } from "../src/modules/currency/currency.service"; +import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; +import { SystemConfigService } from "../src/modules/system-config/system-config.service"; +import { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; + +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +describe("Payments — webhook idempotency and non-wallet initiate flow", () => { + let harness: ServiceHarness; + let schedulesService: SchedulesService; + let seatsService: SeatsService; + let ticketsService: TicketsService; + let guestBookingService: GuestBookingService; + let paymentClient: { initiate: jest.Mock }; + let paymentsService: PaymentsService; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + const currencyService = harness.moduleRef.get(CurrencyService); + const fareEngine = harness.moduleRef.get(FareEngineService); + const systemConfig = new SystemConfigService(harness.prisma as any); + + seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + paymentClient = { initiate: jest.fn() }; + paymentsService = new PaymentsService( + harness.prisma as any, + seatsService, + ticketsService, + { emit: () => true } as any, + paymentClient as any, + currencyService, + asyncStub(), + ); + guestBookingService = new GuestBookingService( + harness.prisma as any, + seatsService, + asyncStub(), + currencyService, + asyncStub(), + fareEngine, + { emit: () => true } as any, + paymentsService, + asyncStub(), + asyncStub(), + ); + }); + + afterAll(async () => { + await harness?.close(); + }); + + beforeEach(async () => { + await resetAndSeedCore(harness.prisma); + paymentClient.initiate.mockReset(); + }); + + const future = (mins: number) => new Date(Date.now() + mins * 60_000); + + async function createTestSchedule(trainNumber: string) { + const train = await harness.prisma.train.create({ data: { number: trainNumber, name: "Test" } }); + const coach = await harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `${trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, + }); + const seats = await Promise.all( + ["1A", "1B"].map((seatNumber, i) => + harness.prisma.seat.create({ data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 } }), + ), + ); + const schedule = await schedulesService.createSchedule({ + trainId: train.id, routeId: IDS.route, + departureAt: future(180).toISOString(), arrivalAt: future(220).toISOString(), + coachIds: [coach.id], + } as any); + return { schedule, seats }; + } + + async function createOneWayBooking(scheduleId: string, seatId: string, passengerId: string) { + const hold = await seatsService.holdSeats({ + scheduleId, originStationId: IDS.stationA, destinationStationId: IDS.stationB, + passengers: [{ passengerId, seatId }], + } as any); + return guestBookingService.createGuestBooking({ + scheduleId, holdId: (hold as any).holdId, + originStationId: IDS.stationA, destinationStationId: IDS.stationB, + seatClassId: IDS.seatClassLocal, + passengers: [{ + seatId, passengerName: "Test Traveler", dateOfBirth: "1990-01-01", + idDocumentType: IdDocumentType.PASSPORT, passportNumber: "X123456", passportCountry: "Djibouti", nationality: "Djiboutian", + }], + } as any) as Promise; + } + + function webhookEvent(booking: any, overrides: Partial> = {}) { + return { + version: 1 as const, + eventId: `evt-${Math.random().toString(36).slice(2)}`, + eventType: "payment.succeeded" as const, + occurredAt: new Date().toISOString(), + service: PaymentServiceEnum.PASSENGER, + intentId: `remote-intent-${Math.random().toString(36).slice(2)}`, + referenceType: PaymentReferenceType.BOOKING, + referenceId: booking.id, + merchantOrderId: booking.bookingRef, + provider: ProviderMethod.TELEBIRR, + amountMinor: booking.displayTotalMinor ?? booking.totalMinor, + currency: booking.displayCurrency ?? "ETB", + ...overrides, + }; + } + + describe("handlePaymentEvent() — webhook", () => { + it("delivering the same success event twice confirms the booking once, not twice", async () => { + const { schedule, seats } = await createTestSchedule(`WH-DUP-${Date.now()}`); + const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000001"); + + const first = await paymentsService.handlePaymentEvent(webhookEvent(booking) as any); + expect(first.processed).toBe(true); + + const second = await paymentsService.handlePaymentEvent(webhookEvent(booking, { eventId: "evt-redelivered" }) as any); + expect(second.processed).toBe(true); + + const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } }); + expect(refreshedBooking?.status).toBe("CONFIRMED"); + + const intents = await harness.prisma.paymentIntent.findMany({ where: { bookingId: booking.id } }); + expect(intents).toHaveLength(1); + expect(intents[0].status).toBe(PaymentIntentStatus.SUCCEEDED); + + const tickets = await harness.prisma.ticket.findMany({ where: { bookingId: booking.id } }); + expect(tickets).toHaveLength(1); // NOT duplicated on redelivery + }); + + it("refuses to confirm on a short (underpaid) settlement", async () => { + const { schedule, seats } = await createTestSchedule(`WH-SHORT-${Date.now()}`); + const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000002"); + const expected = booking.displayTotalMinor ?? booking.totalMinor; + + const result = await paymentsService.handlePaymentEvent( + webhookEvent(booking, { amountMinor: Math.round(expected * 0.5) }) as any, + ); + + expect(result.processed).toBe(false); + expect((result as any).reason).toBe("amount-mismatch"); + + const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } }); + expect(refreshedBooking?.status).toBe("PENDING_PAYMENT"); + const succeededIntents = await harness.prisma.paymentIntent.count({ where: { bookingId: booking.id, status: PaymentIntentStatus.SUCCEEDED } }); + expect(succeededIntents).toBe(0); + }); + + it("payment.failed marks the intent FAILED without confirming the booking", async () => { + const { schedule, seats } = await createTestSchedule(`WH-FAILED-${Date.now()}`); + const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000003"); + await harness.prisma.paymentIntent.create({ + data: { bookingId: booking.id, amountMinor: booking.totalMinor, currency: "ETB", method: PaymentMethodType.TELEBIRR, status: PaymentIntentStatus.PROCESSING }, + }); + + const result = await paymentsService.handlePaymentEvent( + webhookEvent(booking, { eventType: "payment.failed", failureCode: "INSUFFICIENT_FUNDS", failureMessage: "Declined" }) as any, + ); + expect(result.processed).toBe(true); + + const intent = await harness.prisma.paymentIntent.findUnique({ where: { bookingId: booking.id } }); + expect(intent?.status).toBe(PaymentIntentStatus.FAILED); + const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } }); + expect(refreshedBooking?.status).toBe("PENDING_PAYMENT"); + }); + }); + + describe("initiatePayment() — non-wallet (provider-routed) method", () => { + it("an instantly-settled provider response (e.g. TELEBIRR) converges the booking immediately, same as a webhook would", async () => { + const { schedule, seats } = await createTestSchedule(`INIT-NONWALLET-${Date.now()}`); + const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000004"); + + paymentClient.initiate.mockResolvedValue({ + intentId: "remote-intent-1", + status: ProviderPaymentStatus.SUCCEEDED, + provider: ProviderMethod.TELEBIRR, + merchantOrderId: booking.bookingRef, + amountMinor: booking.totalMinor / 100, + currency: "ETB", + providerTxnId: "TXN-123", + paidAt: new Date().toISOString(), + }); + + const response = await paymentsService.initiatePayment({ bookingId: booking.id, method: "TELEBIRR", platform: "web" } as any); + expect(paymentClient.initiate).toHaveBeenCalledTimes(1); + expect(response.status).toBe(PaymentIntentStatus.SUCCEEDED); + + const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } }); + expect(refreshedBooking?.status).toBe("CONFIRMED"); + const tickets = await harness.prisma.ticket.findMany({ where: { bookingId: booking.id } }); + expect(tickets).toHaveLength(1); + }); + + it("a REQUIRES_ACTION provider response leaves the booking PENDING_PAYMENT and surfaces the clientAction", async () => { + const { schedule, seats } = await createTestSchedule(`INIT-PENDING-${Date.now()}`); + const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000005"); + + paymentClient.initiate.mockResolvedValue({ + intentId: "remote-intent-2", + status: ProviderPaymentStatus.REQUIRES_ACTION, + provider: ProviderMethod.TELEBIRR, + merchantOrderId: booking.bookingRef, + amountMinor: booking.totalMinor / 100, + currency: "ETB", + clientAction: { type: "REDIRECT", url: "https://provider.example/pay/abc" }, + }); + + const response = await paymentsService.initiatePayment({ bookingId: booking.id, method: "TELEBIRR", platform: "web" } as any); + expect(response.status).toBe(PaymentIntentStatus.REQUIRES_ACTION); + expect((response as any).clientAction?.type).toBe("REDIRECT"); + + const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } }); + expect(refreshedBooking?.status).toBe("PENDING_PAYMENT"); + }); + }); +}); diff --git a/apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts b/apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts index 2490366b8..e68231852 100644 --- a/apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts +++ b/apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts @@ -1,7 +1,12 @@ /** * Currency / FX suite (matrix Suite C). Exercises CurrencyService directly. - * C2 🔴 missing rate: getExchangeRate() silently returns 1.0 while getRateOrThrow() throws — - * the display path degrades but the charge path errors on the SAME condition (divergence). + * C2 ✅ FIXED (was 🔴): getExchangeRate() used to silently return 1.0 on a missing rate — + * now fails closed with the same BadRequestException getRateOrThrow() always threw + * (see the "H-2: fail closed" comment in currency.service.ts). C2/C2b below were + * found still asserting the OLD buggy behavior (`resolves.toBe(1.0)`) and were + * themselves failing as a result — updated to assert the current, correct behavior. + * Do not revert getExchangeRate to silently return 1.0 to make an old version of + * this test pass; that would reintroduce a real underpricing bug. * C3 🔴 a future-dated rate is applied immediately (no `effectiveDate <= now` filter). * C5 🔴 conversion routines disagree on units: displayMinorToChargeMajor / convertMinorToChargeMajor * return MAJOR units, convertEtbMinorToChargeMinor returns MINOR — a 100x unit landmine both @@ -26,23 +31,25 @@ describe("Pricing — CurrencyService (Suite C)", () => { await resetAndSeedCore(harness.prisma); }); - it("C2 🔴 same DB state, 100x divergence: getExchangeRate → 1.0, getRateOrThrow → 100 (via inverse)", async () => { + it("C2 ✅ getExchangeRate now fails closed on a missing DIRECT rate, same as getRateOrThrow (no more silent 1.0)", async () => { // Remove only the DIRECT USD→ETB row; the inverse ETB→USD (0.01) from the fixture stays. + // getExchangeRate has no inverse-rate fallback at all (unlike getRateOrThrow, which does) + // — so it correctly throws here even though a usable inverse rate technically exists. await harness.prisma.currencyExchangeRate.deleteMany({ where: { fromCurrency: "USD", toCurrency: "ETB" }, }); - // Display/fare path (getExchangeRate) has NO inverse fallback → silently returns 1.0 (wrong). - await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0); + await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).rejects.toThrow(/No exchange rate configured/i); - // Charge path (getRateOrThrow) DOES fall back to the inverse → 1 / 0.01 = 100 (correct). + // getRateOrThrow DOES fall back to the inverse → 1 / 0.01 = 100 (correct) — this divergence + // (one method has an inverse fallback, the other doesn't) is a separate, real inconsistency + // from the old "silent 1.0" bug; flagging it, not fixing it here. await expect( currency.getRateOrThrow("USD" as any, "ETB" as any), ).resolves.toBe(USD_TO_ETB); - // → the display fare and the charged amount for the same trip differ by 100x. }); - it("C2b 🔴 truly-missing pair: getExchangeRate → 1.0 (silent), getRateOrThrow → throws", async () => { + it("C2b ✅ truly-missing pair: both getExchangeRate and getRateOrThrow fail closed", async () => { await harness.prisma.currencyExchangeRate.deleteMany({ where: { OR: [ @@ -51,7 +58,7 @@ describe("Pricing — CurrencyService (Suite C)", () => { ], }, }); - await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0); + await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).rejects.toThrow(/No exchange rate configured/i); await expect( currency.getRateOrThrow("USD" as any, "ETB" as any), ).rejects.toThrow(/No exchange rate/i); diff --git a/apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts b/apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts index c519d3856..269a21c59 100644 --- a/apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts +++ b/apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts @@ -1,8 +1,13 @@ /** * Reference pricing suite — proves the slim harness boots and exercises FareEngineService directly. - * Also confirms two matrix findings against the running engine: + * Also confirms matrix findings against the running engine: * D1 — a promo with percentOff > 100 drives the total NEGATIVE (no clamp at 0). - * C1 — a missing USD→ETB FX rate is silently substituted with 1.0 (fares collapse ~100x). + * C1 ✅ FIXED (was 🔴 "missing USD→ETB FX rate is silently substituted with 1.0, fares + * collapse ~100x"): CurrencyService.getExchangeRate() now fails closed on a missing + * rate (see the "H-2: fail closed" comment in currency.service.ts, and + * pricing-currency.e2e-spec.ts's C2/C2b) — FareEngineService.calculate() calls it + * internally, so a missing rate now correctly rejects the fare calculation instead of + * silently underpricing it. Updated below to assert the current, correct behavior. */ import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; import { @@ -114,18 +119,15 @@ describe("Pricing — FareEngineService (slim harness)", () => { expect(withPromo.totalMinor).toBe(base.totalMinor - 5000); }); - it("C1 🔴 missing USD→ETB rate silently falls back to 1.0 (fare collapses ~100x)", async () => { + it("C1 ✅ a missing USD→ETB rate now rejects the fare calculation instead of silently pricing at parity", async () => { const withRate = await fareEngine.calculate(baseDto() as any); + expect(withRate.totalMinor).toBeGreaterThan(0); // Remove the USD→ETB rate the seat-class formula multiplies by. await harness.prisma.currencyExchangeRate.deleteMany({ where: { fromCurrency: "USD", toCurrency: "ETB" }, }); - const withoutRate = await fareEngine.calculate(baseDto() as any); - - // Correct behavior would be to reject/flag; instead the fare silently drops by the rate factor. - expect(withoutRate.totalMinor).toBe(withRate.totalMinor / USD_TO_ETB); - expect(withoutRate.totalMinor).toBeLessThan(withRate.totalMinor); + await expect(fareEngine.calculate(baseDto() as any)).rejects.toThrow(/No exchange rate configured/i); }); }); diff --git a/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts new file mode 100644 index 000000000..cb347092a --- /dev/null +++ b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts @@ -0,0 +1,374 @@ +/** + * Reserve seat → release | issue booking — coverage for GuestBookingService. + * issueBookingFromReservation(), BookingsService.getByPayToken(), and the + * SeatsService.unblockSeat() reuse that releases the reservation. + * + * STAFF path: fee-waived, finalized immediately via PaymentsService.finalizePaymentSuccess() + * (the same all-in-one finalizer every real payment webhook uses) — proves a Ticket is + * issued, the booking is CONFIRMED, and the reservation's own SeatBlock is released (not + * left stale) before TicketsService.generate() marks the now-ticketed seat BOOKED with its + * own fresh block — the correct end state once a real ticket exists, distinct from an + * operational reservation that never converts into a booking. + * + * PASSENGER path: booking stays PENDING_PAYMENT with a payToken texted to the traveler — + * proves no PaymentIntent is created yet (the passenger creates one themselves later via the + * already-public /payments/initiate, same as a normal guest checkout) and that + * BookingsService.getByPayToken() resolves it for the portal's pay-by-link page. + * + * Uses the slim harness (real Nest DI) for SchedulesService/CurrencyService/FareEngineService, + * same as stop-based-booking-segment.e2e-spec.ts. SeatsService/TicketsService/PaymentsService/ + * GuestBookingService/BookingsService are instantiated directly with a real Prisma + + * stubbed collaborators (Tier-2 pattern, money-integrity.e2e-spec.ts) — PaymentsModule/ + * BookingsModule pull in NotificationsModule → RabbitMQ, which the slim harness avoids. + */ +import { IdDocumentType, PaymentIntentStatus, PaymentMethodType } from "@prisma/client"; +import { validateSync } from "class-validator"; +import { plainToInstance } from "class-transformer"; +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { SeatsService } from "../src/modules/seats/seats.service"; +import { TicketsService } from "../src/modules/tickets/tickets.service"; +import { PaymentsService } from "../src/modules/payments/payments.service"; +import { CurrencyService } from "../src/modules/currency/currency.service"; +import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; +import { SystemConfigService } from "../src/modules/system-config/system-config.service"; +import { BookingsService } from "../src/modules/bookings/bookings.service"; +import { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; +import { ReservationBookingKind, IssueReservationBookingDto } from "../src/modules/bookings/guest-booking.dto"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; + +/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */ +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { + let harness: ServiceHarness; + let schedulesService: SchedulesService; + let seatsService: SeatsService; + let guestBookingService: GuestBookingService; + let bookingsService: BookingsService; + let smsClient: { sendSms: jest.Mock }; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + const currencyService = harness.moduleRef.get(CurrencyService); + const fareEngine = harness.moduleRef.get(FareEngineService); + const systemConfig = new SystemConfigService(harness.prisma as any); + + seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + const ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + const paymentsService = new PaymentsService( + harness.prisma as any, + seatsService, + ticketsService, + { emit: () => true } as any, // eventEmitter + asyncStub(), // paymentClient — never reached: finalizePaymentSuccess doesn't call it + currencyService, + asyncStub(), // auditService + ); + smsClient = { sendSms: jest.fn().mockResolvedValue({ queued: true }) }; + guestBookingService = new GuestBookingService( + harness.prisma as any, + seatsService, + asyncStub(), // verifaydaService — never reached: test passengers use PASSPORT, not NATIONAL_ID + currencyService, + asyncStub(), // passengerAuthService — never reached: no createAccount in this flow + fareEngine, + { emit: () => true } as any, // eventEmitter + paymentsService, + asyncStub(), // auditService + smsClient, + ); + bookingsService = new BookingsService( + harness.prisma as any, + asyncStub(), // dataSource + seatsService, + { emit: () => true } as any, + asyncStub(), // verifaydaService + currencyService, + fareEngine, + asyncStub(), // auditService + ); + }); + + afterAll(async () => { + await harness?.close(); + }); + + beforeEach(() => { + smsClient.sendSms.mockClear(); + }); + + /** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation. */ + async function createTestSchedule(opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }) { + const train = await harness.prisma.train.create({ + data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` }, + }); + const coach = await harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, + }); + const seats = await Promise.all( + ["1A", "1B"].map((seatNumber, i) => + harness.prisma.seat.create({ + data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 }, + }), + ), + ); + const schedule = await schedulesService.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: opts.departureAt.toISOString(), + arrivalAt: opts.arrivalAt.toISOString(), + coachIds: [coach.id], + } as any); + return { schedule, seats }; + } + + function baseDto(overrides: Partial> = {}) { + return { + scheduleId: "", // filled per-test + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + // No seatClassId — the seat's class is resolved server-side from its coach type + + // nationality tier (Djiboutian -> LOCAL, matching seed-core's seatClassLocal). + bookingKind: ReservationBookingKind.STAFF, + passengerName: "Test Traveler", + dateOfBirth: "1990-01-01", + idDocumentType: IdDocumentType.PASSPORT, + passportNumber: "X123456", + nationality: "Djiboutian", + ...overrides, + }; + } + + it("404s when the seat has no active reservation", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-404-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await expect( + guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id }) as any, + null, + ), + ).rejects.toThrow(/not reserved/i); + }); + + it("resolves the seat class (and therefore fare) from nationality — LOCAL for Ethiopian/Djiboutian, INTERNATIONAL for Other", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + + // A->B is 100km (seed-core DISTANCE). LOCAL seatClass is 3.00 ETB/km, INTERNATIONAL is + // 5.00 ETB/km, USD_TO_ETB=100 — same formula pricing-fare-engine.e2e-spec.ts verifies. + // Both use PASSENGER kind so the computed fare survives unwaived (STAFF always zeroes + // totalMinor/fareMinor regardless of the resolved seat class). + const localSchedule = await createTestSchedule({ trainNumber: `RES-FARE-LOCAL-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + await seatsService.blockSeat(localSchedule.seats[0].id, "Reserved", localSchedule.schedule.id); + const localBooking: any = await guestBookingService.issueBookingFromReservation( + localSchedule.seats[0].id, + baseDto({ scheduleId: localSchedule.schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+251911234567", nationality: "Ethiopian" }) as any, + null, + ); + const localSeat = await harness.prisma.bookingSeat.findFirst({ where: { bookingId: localBooking.booking.id } }); + expect(localSeat?.fareMinor).toBe(30_000); + + const intlSchedule = await createTestSchedule({ trainNumber: `RES-FARE-INTL-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + await seatsService.blockSeat(intlSchedule.seats[0].id, "Reserved", intlSchedule.schedule.id); + const intlBooking: any = await guestBookingService.issueBookingFromReservation( + intlSchedule.seats[0].id, + baseDto({ scheduleId: intlSchedule.schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567", nationality: "Other" }) as any, + null, + ); + expect(intlBooking.booking.totalMinor).toBe(50_000); + }); + + it("rejects a nationality outside Ethiopian/Djiboutian/Other at the DTO level", () => { + const dto = plainToInstance(IssueReservationBookingDto, baseDto({ scheduleId: "irrelevant", nationality: "French" })); + const errors = validateSync(dto); + expect(errors.some((e) => e.property === "nationality")).toBe(true); + }); + + it("STAFF + a GLOBAL block: booking is CONFIRMED, fee-waived, ticket issued, and the old global block is replaced (not left stale)", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAFF-GLOBAL-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "VIP hold — mayor's office"); + + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any, + "staff-user-1", + ); + + expect(result.booking.status).toBe("CONFIRMED"); + expect(result.payUrl).toBeUndefined(); + + const intent = await harness.prisma.paymentIntent.findUnique({ where: { bookingId: result.booking.id } }); + expect(intent?.amountMinor).toBe(0); + expect(intent?.status).toBe(PaymentIntentStatus.SUCCEEDED); + expect(intent?.method).toBe(PaymentMethodType.WALLET); + + const ticketCount = await harness.prisma.ticket.count({ where: { bookingId: result.booking.id } }); + expect(ticketCount).toBeGreaterThan(0); + + // TicketsService.generate() itself creates a fresh global SeatBlock ("Booked in + // tickets ...") once a ticket is actually issued — a ticketed seat SHOULD show as + // unavailable. The meaningful assertion is that the ORIGINAL reservation's block is + // gone (proving unblockSeat ran), replaced by exactly this one new booked-marker block — + // not that the seat is left globally BLOCKED under the old reservation's reason forever. + const blocks = await harness.prisma.seatBlock.findMany({ where: { seatId: seats[0].id } }); + expect(blocks).toHaveLength(1); + expect(blocks[0].reason).not.toMatch(/VIP hold/); + expect(blocks[0].reason).toMatch(/Booked in tickets/); + + const seat = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } }); + expect(seat?.status).toBe("BOOKED"); + }); + + it("STAFF + a SCHEDULE-SCOPED block: same release path, no leftover reservation block", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAFF-SCOPED-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved for inspection", schedule.id); + const seatBefore = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } }); + expect(seatBefore?.status).not.toBe("BLOCKED"); // schedule-scoped block never touches Seat.status + + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any, + "staff-user-2", + ); + + expect(result.booking.status).toBe("CONFIRMED"); + // The original schedule-scoped reservation block is gone; only generate()'s own + // booked-marker block remains (see the global-block test above for why). + const originalScopedBlock = await harness.prisma.seatBlock.count({ + where: { seatId: seats[0].id, scheduleId: schedule.id, reason: "Reserved for inspection" }, + }); + expect(originalScopedBlock).toBe(0); + + // Same as the global-block test — generate() marks the now-ticketed seat BOOKED. + const seatAfter = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } }); + expect(seatAfter?.status).toBe("BOOKED"); + }); + + it("PASSENGER path: booking stays PENDING_PAYMENT, payToken is set, no PaymentIntent yet, SMS sent with the pay link", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-PAX-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-3", + ); + + expect(result.booking.status).toBe("PENDING_PAYMENT"); + expect(result.booking.payToken).toBeTruthy(); + expect(result.payUrl).toContain(result.booking.payToken); + + const intent = await harness.prisma.paymentIntent.findUnique({ where: { bookingId: result.booking.id } }); + expect(intent).toBeNull(); + + expect(smsClient.sendSms).toHaveBeenCalledTimes(1); + const smsArgs = smsClient.sendSms.mock.calls[0][0]; + expect(smsArgs.to).toBe("+253771234567"); + expect(smsArgs.message).toContain(result.booking.payToken); + + // BookingsService.getByPayToken — the portal pay-by-link page's data source. + const byToken: any = await bookingsService.getByPayToken(result.booking.payToken); + expect(byToken.id).toBe(result.booking.id); + expect(byToken.status).toBe("PENDING_PAYMENT"); + expect(byToken.schedule.origin.id).toBe(IDS.stationA); + }); + + it("requires a phone number for a PASSENGER booking", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-PAX-NOPHONE-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id); + + await expect( + guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: undefined }) as any, + null, + ), + ).rejects.toThrow(/phone number is required/i); + }); + + it("rejects NATIONAL_ID for a non-Ethiopian nationality", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-NATID-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id); + + await expect( + guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ + scheduleId: schedule.id, + nationality: "Djiboutian", + idDocumentType: IdDocumentType.NATIONAL_ID, + idDocumentNumber: "ET123456789", + }) as any, + null, + ), + ).rejects.toThrow(/national id is only valid for ethiopian/i); + }); + + it("still enforces the check-in cutoff — rejects once the boarding stop is too close to departure", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 5 * 60_000); // 5min out — inside the default 30-min cutoff + const arr = new Date(dep.getTime() + 50 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CUTOFF-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id); + + await expect( + guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, originStationId: IDS.stationA, destinationStationId: IDS.stationB }) as any, + null, + ), + ).rejects.toThrow(/not accepted within/i); + }); + + it("getByPayToken 404s for an unknown token and 400s once expired", async () => { + await resetAndSeedCore(harness.prisma); + await expect(bookingsService.getByPayToken("no-such-token")).rejects.toThrow(/not found/i); + + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-EXPIRED-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id); + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + null, + ); + + await harness.prisma.booking.update({ + where: { id: result.booking.id }, + data: { payTokenExpiresAt: new Date(Date.now() - 60_000) }, + }); + + await expect(bookingsService.getByPayToken(result.booking.payToken)).rejects.toThrow(/expired/i); + }); +}); diff --git a/apps/edr-passenger-api/test/schedule-lifecycle.e2e-spec.ts b/apps/edr-passenger-api/test/schedule-lifecycle.e2e-spec.ts new file mode 100644 index 000000000..d76090ab3 --- /dev/null +++ b/apps/edr-passenger-api/test/schedule-lifecycle.e2e-spec.ts @@ -0,0 +1,206 @@ +/** + * Schedule lifecycle coverage — bulkGenerateSchedules, createSchedule's "must have at least + * one coach assigned" guard (added recently — the exact validation whose ordering broke + * checkin-cutoff.e2e-spec.ts's fixture helper earlier this session), and + * TasksService.syncScheduleStatuses' full schedule-level state machine + * (SCHEDULED -> BOARDING -> EN_ROUTE -> ARRIVED). checkin-cutoff.e2e-spec.ts already covers + * the per-stop CHECKIN_CLOSED/OPEN half of syncScheduleStatuses — not repeated here. + */ +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { TasksService } from "../src/modules/tasks/tasks.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; + +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +describe("Schedule lifecycle — bulk generate, coach guard, status transitions", () => { + let harness: ServiceHarness; + let schedulesService: SchedulesService; + let tasksService: TasksService; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + tasksService = new TasksService(harness.prisma as any, asyncStub(), asyncStub()); + }); + + afterAll(async () => { + await harness?.close(); + }); + + beforeEach(async () => { + await resetAndSeedCore(harness.prisma); + }); + + const future = (mins: number) => new Date(Date.now() + mins * 60_000); + + async function createCoach(trainNumber: string) { + return harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `${trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, + }); + } + + describe("createSchedule coach-assignment guard", () => { + it("rejects a schedule with no coachIds and no route coach template", async () => { + const train = await harness.prisma.train.create({ data: { number: `SCH-NOCOACH-${Date.now()}`, name: "Test" } }); + + await expect( + schedulesService.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: future(180).toISOString(), + arrivalAt: future(280).toISOString(), + } as any), + ).rejects.toThrow(/must have at least one coach assigned/i); + + const orphan = await harness.prisma.trainSchedule.findFirst({ where: { trainId: train.id } }); + expect(orphan).toBeNull(); // no dead schedule left behind + }); + + it("auto-applies the route's coach template when coachIds are omitted", async () => { + const train = await harness.prisma.train.create({ data: { number: `SCH-TEMPLATE-${Date.now()}`, name: "Test" } }); + const coach = await createCoach(train.number); + await harness.prisma.routeCoachTemplate.create({ + data: { routeId: IDS.route, coachId: coach.id, positionNumber: 1 }, + }); + + const schedule = await schedulesService.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: future(180).toISOString(), + arrivalAt: future(280).toISOString(), + } as any); + + const assignments = await harness.prisma.coachAssignment.findMany({ where: { scheduleId: schedule.id } }); + expect(assignments).toHaveLength(1); + expect(assignments[0].coachId).toBe(coach.id); + }); + + it("explicit coachIds override the route's coach template", async () => { + const train = await harness.prisma.train.create({ data: { number: `SCH-OVERRIDE-${Date.now()}`, name: "Test" } }); + const templateCoach = await createCoach(`${train.number}-tmpl`); + const explicitCoach = await createCoach(`${train.number}-explicit`); + await harness.prisma.routeCoachTemplate.create({ + data: { routeId: IDS.route, coachId: templateCoach.id, positionNumber: 1 }, + }); + + const schedule = await schedulesService.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: future(180).toISOString(), + arrivalAt: future(280).toISOString(), + coachIds: [explicitCoach.id], + } as any); + + const assignments = await harness.prisma.coachAssignment.findMany({ where: { scheduleId: schedule.id } }); + expect(assignments).toHaveLength(1); + expect(assignments[0].coachId).toBe(explicitCoach.id); + }); + }); + + describe("bulkGenerateSchedules", () => { + it("generates one schedule per repeat interval across the date range", async () => { + const train = await harness.prisma.train.create({ data: { number: `BULK-${Date.now()}`, name: "Test" } }); + const coach = await createCoach(train.number); + await harness.prisma.routeCoachTemplate.create({ data: { routeId: IDS.route, coachId: coach.id, positionNumber: 1 } }); + + const start = future(2 * 24 * 60); // 2 days out, clear of "today" edge cases + const result = await schedulesService.bulkGenerateSchedules({ + trainId: train.id, + routeId: IDS.route, + startDateTime: start.toISOString(), + forNextDays: 5, + repeatEveryDays: 2, + durationHours: 3, + } as any); + + // while(currentDate < endDate) stepping by 2 days over a 5-day window: day 0, 2, 4. + expect(result.schedulesCreated).toBe(3); + expect(result.errors).toHaveLength(0); + expect(result.scheduleIds).toHaveLength(3); + + const schedules = await harness.prisma.trainSchedule.findMany({ where: { trainId: train.id }, orderBy: { departureAt: "asc" } }); + expect(schedules).toHaveLength(3); + const daysBetween = (a: Date, b: Date) => Math.round((b.getTime() - a.getTime()) / (24 * 60 * 60 * 1000)); + expect(daysBetween(schedules[0].departureAt, schedules[1].departureAt)).toBe(2); + expect(daysBetween(schedules[1].departureAt, schedules[2].departureAt)).toBe(2); + }); + + it("collects per-day errors without aborting the rest of the run", async () => { + const train = await harness.prisma.train.create({ data: { number: `BULK-ERR-${Date.now()}`, name: "Test" } }); + const coach = await createCoach(train.number); + await harness.prisma.routeCoachTemplate.create({ data: { routeId: IDS.route, coachId: coach.id, positionNumber: 1 } }); + + const start = future(2 * 24 * 60); + const dto = { + trainId: train.id, + routeId: IDS.route, + startDateTime: start.toISOString(), + forNextDays: 4, + repeatEveryDays: 1, + durationHours: 3, + } as any; + + const first = await schedulesService.bulkGenerateSchedules(dto); + expect(first.schedulesCreated).toBe(4); + expect(first.errors).toHaveLength(0); + + // Re-running the exact same range collides with EVERY day just created (same + // train+route+date already exists) — proves errors are collected per-day, not thrown, + // and the count/errors array accurately reflect zero successes this time. + const second = await schedulesService.bulkGenerateSchedules(dto); + expect(second.schedulesCreated).toBe(0); + expect(second.errors).toHaveLength(4); + expect(second.errors[0]).toMatch(/already exists/i); + }); + }); + + describe("syncScheduleStatuses — schedule-level state machine", () => { + async function scheduleWithCoach(trainNumber: string, departureAt: Date, arrivalAt: Date) { + const train = await harness.prisma.train.create({ data: { number: trainNumber, name: "Test" } }); + const coach = await createCoach(trainNumber); + return schedulesService.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: departureAt.toISOString(), + arrivalAt: arrivalAt.toISOString(), + coachIds: [coach.id], + } as any); + } + + it("SCHEDULED -> BOARDING once within the 30-min cutoff window", async () => { + const schedule = await scheduleWithCoach(`SYNC-BOARD-${Date.now()}`, future(20), future(80)); + expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("SCHEDULED"); + + await tasksService.syncScheduleStatuses(); + + expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("BOARDING"); + }); + + it("BOARDING -> EN_ROUTE once departure has passed", async () => { + const schedule = await scheduleWithCoach(`SYNC-ENROUTE-${Date.now()}`, future(20), future(80)); + await tasksService.syncScheduleStatuses(); + await harness.prisma.trainSchedule.update({ where: { id: schedule.id }, data: { departureAt: new Date(Date.now() - 60_000) } }); + + await tasksService.syncScheduleStatuses(); + + expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("EN_ROUTE"); + }); + + it("EN_ROUTE -> ARRIVED once arrival has passed", async () => { + const schedule = await scheduleWithCoach(`SYNC-ARRIVED-${Date.now()}`, future(20), future(80)); + await tasksService.syncScheduleStatuses(); + await harness.prisma.trainSchedule.update({ + where: { id: schedule.id }, + data: { departureAt: new Date(Date.now() - 120_000), arrivalAt: new Date(Date.now() - 60_000) }, + }); + + await tasksService.syncScheduleStatuses(); + await tasksService.syncScheduleStatuses(); // BOARDING->EN_ROUTE and EN_ROUTE->ARRIVED are separate updateMany calls in one pass — one call suffices, second call is a no-op idempotency check + + expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("ARRIVED"); + }); + }); +}); diff --git a/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts b/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts index dfcc298fd..7fcce106b 100644 --- a/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts +++ b/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts @@ -95,6 +95,9 @@ describe("Stop-based booking — segment time & cutoff correctness", () => { asyncStub(), // passengerAuthService — never reached: no createAccount in these DTOs fareEngine, { emit: () => true } as any, // eventEmitter + asyncStub(), // paymentsService — never reached: these tests use createGuestBooking, not issueBookingFromReservation + asyncStub(), // auditService + asyncStub(), // smsClient ); }); diff --git a/apps/edr-passenger-api/test/ticketing.e2e-spec.ts b/apps/edr-passenger-api/test/ticketing.e2e-spec.ts new file mode 100644 index 000000000..b8a380577 --- /dev/null +++ b/apps/edr-passenger-api/test/ticketing.e2e-spec.ts @@ -0,0 +1,282 @@ +/** + * Ticketing/boarding coverage — TicketsService.generate()/scanAndBoard()/validate() and the + * smart-seat-reassignment fallback, none of which had any e2e coverage before this suite + * (colocated tickets.service.spec.ts only covers offline batch validation, a different path). + * + * IMPORTANT: scanAndBoard() catches every internal error and returns + * `{ success: false, error, errorCode }` rather than throwing — assertions against it check + * the return value, not `.rejects.toThrow()`. + * + * Same Tier-2 direct-instantiation pattern as reserve-seat-issue-booking.e2e-spec.ts / + * booking-types.e2e-spec.ts. + */ +import { IdDocumentType, PaymentIntentStatus, PaymentMethodType } from "@prisma/client"; +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { SeatsService } from "../src/modules/seats/seats.service"; +import { TicketsService } from "../src/modules/tickets/tickets.service"; +import { CurrencyService } from "../src/modules/currency/currency.service"; +import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; +import { SystemConfigService } from "../src/modules/system-config/system-config.service"; +import { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; + +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +describe("Ticketing — generate / scanAndBoard / validate / smart-reassign", () => { + let harness: ServiceHarness; + let schedulesService: SchedulesService; + let seatsService: SeatsService; + let ticketsService: TicketsService; + let guestBookingService: GuestBookingService; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + const currencyService = harness.moduleRef.get(CurrencyService); + const fareEngine = harness.moduleRef.get(FareEngineService); + const systemConfig = new SystemConfigService(harness.prisma as any); + + seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + guestBookingService = new GuestBookingService( + harness.prisma as any, + seatsService, + asyncStub(), + currencyService, + asyncStub(), + fareEngine, + { emit: () => true } as any, + asyncStub(), // paymentsService — not used; tests drive generate()/scanAndBoard() directly + asyncStub(), + asyncStub(), + ); + }); + + afterAll(async () => { + await harness?.close(); + }); + + beforeEach(async () => { + await resetAndSeedCore(harness.prisma); + }); + + async function createTestSchedule(opts: { trainNumber: string; departureAt: Date; arrivalAt: Date; seatCount?: number }) { + const train = await harness.prisma.train.create({ + data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` }, + }); + const coach = await harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: opts.seatCount ?? 4, sequence: 1, status: "ACTIVE" }, + }); + const seatCount = opts.seatCount ?? 4; + const seatNumbers = Array.from({ length: seatCount }, (_, i) => `1${String.fromCharCode(65 + i)}`); + const seats = await Promise.all( + seatNumbers.map((seatNumber, i) => + harness.prisma.seat.create({ + data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 }, + }), + ), + ); + const schedule = await schedulesService.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: opts.departureAt.toISOString(), + arrivalAt: opts.arrivalAt.toISOString(), + coachIds: [coach.id], + } as any); + return { schedule, seats }; + } + + function passengerDto(seatId: string, overrides: Partial> = {}) { + return { + seatId, + passengerName: "Test Traveler", + dateOfBirth: "1990-01-01", + idDocumentType: IdDocumentType.PASSPORT, + passportNumber: "X123456", + passportCountry: "Djibouti", + nationality: "Djiboutian", + ...overrides, + }; + } + + const future = (mins: number) => new Date(Date.now() + mins * 60_000); + + /** Creates a real PENDING_PAYMENT ONE_WAY booking via the guest flow. */ + async function createOneWayBooking(scheduleId: string, seatId: string, originStationId = IDS.stationA, destinationStationId = IDS.stationB) { + const hold = await seatsService.holdSeats({ + scheduleId, + originStationId, + destinationStationId, + passengers: [{ passengerId: "55555555-5555-4555-8555-500000000001", seatId }], + } as any); + return guestBookingService.createGuestBooking({ + scheduleId, + holdId: (hold as any).holdId, + originStationId, + destinationStationId, + seatClassId: IDS.seatClassLocal, + passengers: [passengerDto(seatId)], + } as any) as Promise; + } + + async function markSucceeded(bookingId: string) { + await harness.prisma.paymentIntent.create({ + data: { bookingId, amountMinor: 30_000, currency: "ETB", method: PaymentMethodType.CARD, status: PaymentIntentStatus.SUCCEEDED, paidAt: new Date() }, + }); + await harness.prisma.booking.update({ where: { id: bookingId }, data: { status: "CONFIRMED" } }); + } + + describe("generate()", () => { + it("issues a ticket for a CONFIRMED booking with a SUCCEEDED PaymentIntent, and marks the seat BOOKED", async () => { + const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-GEN-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) }); + const booking = await createOneWayBooking(schedule.id, seats[0].id); + await markSucceeded(booking.id); + + const result = await ticketsService.generate(booking.id); + expect(result.totalTickets).toBe(1); + + const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } }); + expect(ticket).toBeTruthy(); + expect(ticket?.qrPayload).toBeTruthy(); + expect(ticket?.barcodePayload).toBeTruthy(); + + const seat = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } }); + expect(seat?.status).toBe("BOOKED"); + }); + + it("rejects with 'Payment not completed' when there's no PaymentIntent at all", async () => { + const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-NOPI-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) }); + const booking = await createOneWayBooking(schedule.id, seats[0].id); + + await expect(ticketsService.generate(booking.id)).rejects.toThrow(/Payment not completed/i); + }); + + it("rejects with 'Payment not completed' when the PaymentIntent hasn't succeeded", async () => { + const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-PENDING-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) }); + const booking = await createOneWayBooking(schedule.id, seats[0].id); + await harness.prisma.paymentIntent.create({ + data: { bookingId: booking.id, amountMinor: 30_000, currency: "ETB", method: PaymentMethodType.CARD, status: PaymentIntentStatus.PROCESSING }, + }); + + await expect(ticketsService.generate(booking.id)).rejects.toThrow(/Payment not completed/i); + }); + + it("auto-confirms a PENDING_PAYMENT booking whose PaymentIntent already SUCCEEDED (missed webhook)", async () => { + const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-HEAL-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) }); + const booking = await createOneWayBooking(schedule.id, seats[0].id); + expect(booking.status).toBe("PENDING_PAYMENT"); + await harness.prisma.paymentIntent.create({ + data: { bookingId: booking.id, amountMinor: 30_000, currency: "ETB", method: PaymentMethodType.CARD, status: PaymentIntentStatus.SUCCEEDED, paidAt: new Date() }, + }); + + await ticketsService.generate(booking.id); + + const refreshed = await harness.prisma.booking.findUnique({ where: { id: booking.id } }); + expect(refreshed?.status).toBe("CONFIRMED"); + }); + }); + + describe("smart seat reassignment", () => { + it("generate() throws ConflictException on a genuinely overlapping confirmed seat; smartAssignAndGenerate() recovers onto a free seat", async () => { + const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-CONFLICT-${Date.now()}`, departureAt: future(180), arrivalAt: future(220), seatCount: 3 }); + + // Booking A already CONFIRMED on seats[0], full A->B route (matches the schedule's own + // origin/destination, guaranteeing segment overlap with anything else on seats[0]). + const passengerA = await harness.prisma.passenger.create({ data: {} }); + const bookingA = await harness.prisma.booking.create({ + data: { + bookingRef: `CONFA${Date.now()}`, passengerId: passengerA.id, scheduleId: schedule.id, + originStationId: IDS.stationA, destinationStationId: IDS.stationB, + status: "CONFIRMED", totalMinor: 30_000, displayCurrency: "ETB", + seats: { create: [{ seatId: seats[0].id, scheduleId: schedule.id, passengerName: "Passenger A", passengerCategory: "ADULT", fareMinor: 30_000, displayCurrency: "ETB" }] }, + }, + }); + void bookingA; + + // Booking B independently references the SAME seat (simulating however the conflict + // arose — the point of this test is the recovery path, not the cause). + const bookingB = await createOneWayBooking(schedule.id, seats[0].id); + await markSucceeded(bookingB.id); + + await expect(ticketsService.generate(bookingB.id)).rejects.toThrow(/already confirmed for another booking/i); + + const recovered = await ticketsService.smartAssignAndGenerate(bookingB.id); + expect(recovered.totalTickets).toBe(1); + + const bookingSeat = await harness.prisma.bookingSeat.findFirst({ where: { bookingId: bookingB.id } }); + expect(bookingSeat?.seatId).not.toBe(seats[0].id); // reassigned off the conflicting seat + expect([seats[1].id, seats[2].id]).toContain(bookingSeat?.seatId); + + const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: bookingB.id } }); + expect(ticket?.seatId).toBe(bookingSeat?.seatId); + }); + }); + + describe("scanAndBoard()", () => { + it("boards successfully within the boarding window", async () => { + const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-OK-${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 result = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1"); + expect(result.success).toBe(true); + expect(result.boarding?.seat).toBe(seats[0].seatNumber); + + const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } }); + expect(ticket?.validatedAt).toBeTruthy(); + }); + + it("refuses boarding before the boarding window opens", async () => { + // Default boarding window is 4h (SystemConfigService DEFAULTS) — 6h out is still closed. + const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-EARLY-${Date.now()}`, departureAt: future(360), arrivalAt: future(420) }); + const booking = await createOneWayBooking(schedule.id, seats[0].id); + await markSucceeded(booking.id); + await ticketsService.generate(booking.id); + + const result = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1"); + expect(result.success).toBe(false); + expect(result.error).toMatch(/Boarding opens \d+ hour\(s\) before departure/i); + }); + + it("refuses boarding once departure has passed", async () => { + // Book while departure is comfortably in the future (holdSeats itself refuses a hold + // within 30min of departure), THEN move departure into the past — both the schedule's + // own departureAt AND the origin stop's plannedDepartureAt, since scanAndBoard resolves + // the boarding time via resolveBookingSegment(), which prefers the TripStopTime over + // the raw schedule field. + const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-LATE-${Date.now()}`, departureAt: future(180), arrivalAt: future(240) }); + const booking = await createOneWayBooking(schedule.id, seats[0].id); + await markSucceeded(booking.id); + await ticketsService.generate(booking.id); + + const past = new Date(Date.now() - 60_000); + await harness.prisma.trainSchedule.update({ where: { id: schedule.id }, data: { departureAt: past } }); + await harness.prisma.tripStopTime.updateMany({ where: { scheduleId: schedule.id, stationId: IDS.stationA }, data: { plannedDepartureAt: past } }); + + const result = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1"); + expect(result.success).toBe(false); + expect(result.error).toMatch(/Boarding is closed/i); + }); + }); + + describe("validate()", () => { + it("ONE_WAY: a second validate() call on the same ticket is idempotent, not an error", async () => { + const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-VAL-ONEWAY-${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.validate(booking.bookingRef, "gate-validator-1"); + expect(first.validated).toBe(true); + expect((first as any).alreadyValidated).toBeUndefined(); + + const second = await ticketsService.validate(booking.bookingRef, "gate-validator-1"); + expect(second.validated).toBe(true); + expect((second as any).alreadyValidated).toBe(true); + }); + }); +}); diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index be5449e97..3ce651ac8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -2,11 +2,11 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; +import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api'; import { routesApi } from '@/lib/api/routes'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton' -import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react'; +import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react'; export default function SeatsPage() { const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route'); @@ -24,6 +24,22 @@ export default function SeatsPage() { const [coachToUnblock, setCoachToUnblock] = useState(null); const [showMaintenanceModal, setShowMaintenanceModal] = useState(false); const [maintenanceReason, setMaintenanceReason] = useState(''); + const [showIssueBookingModal, setShowIssueBookingModal] = useState(false); + const [issueBookingCoach, setIssueBookingCoach] = useState(null); + const [issueBookingForm, setIssueBookingForm] = useState({ + bookingKind: 'STAFF' as 'STAFF' | 'PASSENGER', + // No seatClassId — the seat's class is already fixed by the reservation; the backend + // resolves it from the seat's own coach type + nationality tier. + passengerName: '', + dateOfBirth: '', + idDocumentType: 'PASSPORT' as 'NATIONAL_ID' | 'PASSPORT', + idDocumentNumber: '', + passportNumber: '', + nationality: '' as '' | 'Ethiopian' | 'Djiboutian' | 'Other', + phone: '', + email: '', + }); + const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string } | null>(null); const queryClient = useQueryClient(); const { data: schedulesData } = useQuery({ @@ -105,6 +121,21 @@ export default function SeatsPage() { }, }); + const issueBookingMutation = useMutation({ + mutationFn: ({ seatId, data }: { seatId: string; data: any }) => + bookingsApi.issueFromReservation(seatId, data), + onSuccess: (result: any) => { + invalidateSeatData(); + setIssueBookingResult({ payUrl: result?.payUrl }); + if (!result?.payUrl) { + // STAFF booking — nothing further to show the admin, close immediately. + setShowIssueBookingModal(false); + setSelectedSeat(null); + setIssueBookingCoach(null); + } + }, + }); + const removeSeatMutation = useMutation({ mutationFn: (seatId: string) => seatsApi.removeSeat(seatId), onSuccess: () => { @@ -186,11 +217,66 @@ export default function SeatsPage() { }; const handleUnblock = async (seat: any) => { - if (confirm('Are you sure you want to unblock this seat?')) { + if (confirm('Release this reservation and make the seat available to the public?')) { await unblockMutation.mutateAsync(seat.id); } }; + const handleIssueBooking = (seat: any, coach: any) => { + if (activeTab !== 'schedule' || !selectedSchedule) { + alert('Select a specific schedule (Schedule tab) to issue a booking for a reserved seat.'); + return; + } + setSelectedSeat(seat); + setIssueBookingCoach(coach); + setIssueBookingResult(null); + setIssueBookingForm({ + bookingKind: 'STAFF', + passengerName: '', + dateOfBirth: '', + idDocumentType: 'PASSPORT', + idDocumentNumber: '', + passportNumber: '', + nationality: '', + phone: '', + email: '', + }); + setShowIssueBookingModal(true); + }; + + const submitIssueBooking = async () => { + const schedule = schedules.find((s: any) => s.id === selectedSchedule); + if (!schedule?.originStation?.id || !schedule?.destinationStation?.id) { + alert('Could not resolve this schedule\'s origin/destination stations.'); + return; + } + if (!issueBookingForm.passengerName.trim() || !issueBookingForm.dateOfBirth) { + alert('Traveler name and date of birth are required.'); + return; + } + if (!issueBookingForm.nationality) { + alert('Select a nationality.'); + return; + } + if (issueBookingForm.idDocumentType === 'PASSPORT' && !issueBookingForm.passportNumber.trim()) { + alert('Passport number is required.'); + return; + } + if (issueBookingForm.bookingKind === 'PASSENGER' && !issueBookingForm.phone.trim()) { + alert('Phone number is required for a passenger booking (used to send the payment link).'); + return; + } + await issueBookingMutation.mutateAsync({ + seatId: selectedSeat.id, + data: { + scheduleId: selectedSchedule, + originStationId: schedule.originStation.id, + destinationStationId: schedule.destinationStation.id, + ...issueBookingForm, + }, + }); + }; + const handleRemoveSeat = (seat: any) => { setSelectedSeat(seat); setShowRemoveModal(true); @@ -252,7 +338,7 @@ export default function SeatsPage() { const submitBlock = async () => { if (!blockReason.trim()) { - alert('Please provide a reason for blocking'); + alert('Please provide a reason for the reservation'); return; } await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason }); @@ -350,6 +436,7 @@ export default function SeatsPage() { handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} + handleIssueBooking={handleIssueBooking} hideNumber={true} /> ))} @@ -445,6 +532,7 @@ export default function SeatsPage() { handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} + handleIssueBooking={handleIssueBooking} hideNumber={true} /> ))} @@ -467,6 +555,7 @@ export default function SeatsPage() { handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} + handleIssueBooking={handleIssueBooking} hideNumber={true} /> ))} @@ -749,15 +838,15 @@ export default function SeatsPage() { setSelectedSeat(null); setBlockReason(''); }} - title="Block Seat" + title="Reserve Seat" size="md" >

- Block seat {selectedSeat?.seatNumber} in Coach {selectedSeat?.coach?.coachNumber} + Reserve seat {selectedSeat?.seatNumber} in Coach {selectedSeat?.coach?.coachNumber}

- +