Adding ticket generation and booking for staff employees logic

This commit is contained in:
Muluhabt
2026-07-25 11:20:47 +03:00
parent 7320c359d1
commit 2a0fd9bda5
29 changed files with 2659 additions and 74 deletions

View File

@@ -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");

View File

@@ -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])

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, any> | 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');

View File

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

View File

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

View File

@@ -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<string> {
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<Record<string, any>> = {}) {
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);
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<any>;
}
function webhookEvent(booking: any, overrides: Partial<Record<string, any>> = {}) {
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");
});
});
});

View File

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

View File

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

View File

@@ -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<Record<string, any>> = {}) {
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);
});
});

View File

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

View File

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

View File

@@ -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<Record<string, any>> = {}) {
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<any>;
}
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);
});
});
});

View File

@@ -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<any>(null);
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
const [maintenanceReason, setMaintenanceReason] = useState('');
const [showIssueBookingModal, setShowIssueBookingModal] = useState(false);
const [issueBookingCoach, setIssueBookingCoach] = useState<any>(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"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Block seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
Reserve seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
</p>
<div>
<label className="label">Reason for Blocking *</label>
<label className="label">Reason for Reservation *</label>
<textarea
className="input"
rows={3}
@@ -782,12 +871,184 @@ export default function SeatsPage() {
loading={blockMutation.isPending}
disabled={!blockReason.trim()}
>
Block Seat
Reserve Seat
</ActionButton>
</div>
</div>
</Modal>
<Modal
isOpen={showIssueBookingModal}
onClose={() => {
setShowIssueBookingModal(false);
setSelectedSeat(null);
setIssueBookingCoach(null);
setIssueBookingResult(null);
}}
title="Issue Booking"
size="md"
>
{issueBookingResult?.payUrl ? (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Booking created. A payment link has been sent via SMS to the traveler.
</p>
<div className="input break-all text-xs">{issueBookingResult.payUrl}</div>
<div className="flex justify-end">
<ActionButton
onClick={() => {
setShowIssueBookingModal(false);
setSelectedSeat(null);
setIssueBookingCoach(null);
setIssueBookingResult(null);
}}
>
Done
</ActionButton>
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Issue a booking for seat <strong>{selectedSeat?.seatNumber}</strong> in Coach{' '}
<strong>{issueBookingCoach?.coachNumber}</strong>
</p>
<div>
<label className="label">Booking Type *</label>
<div className="flex gap-2">
<button
type="button"
className={`flex-1 px-3 py-2 rounded border ${issueBookingForm.bookingKind === 'STAFF' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 dark:border-gray-600'}`}
onClick={() => setIssueBookingForm((f) => ({ ...f, bookingKind: 'STAFF' }))}
>
Staff (no fee)
</button>
<button
type="button"
className={`flex-1 px-3 py-2 rounded border ${issueBookingForm.bookingKind === 'PASSENGER' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 dark:border-gray-600'}`}
onClick={() => setIssueBookingForm((f) => ({ ...f, bookingKind: 'PASSENGER' }))}
>
Passenger (pay via link)
</button>
</div>
</div>
<div>
<label className="label">Traveler Name *</label>
<input
className="input"
value={issueBookingForm.passengerName}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, passengerName: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Date of Birth *</label>
<input
type="date"
className="input"
value={issueBookingForm.dateOfBirth}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, dateOfBirth: e.target.value }))}
/>
</div>
<div>
<label className="label">Nationality *</label>
<select
className="input"
value={issueBookingForm.nationality}
onChange={(e) => {
const nationality = e.target.value as 'Ethiopian' | 'Djiboutian' | 'Other';
setIssueBookingForm((f) => ({
...f,
nationality,
// National ID is Ethiopian-only — switch back to Passport for anyone else.
idDocumentType: nationality === 'Ethiopian' ? f.idDocumentType : 'PASSPORT',
}));
}}
>
<option value="">Select nationality...</option>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">Other</option>
</select>
</div>
</div>
<div>
<label className="label">ID Document Type *</label>
<select
className="input"
value={issueBookingForm.idDocumentType}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, idDocumentType: e.target.value as 'NATIONAL_ID' | 'PASSPORT' }))}
>
<option value="PASSPORT">Passport</option>
{issueBookingForm.nationality === 'Ethiopian' && (
<option value="NATIONAL_ID">National ID</option>
)}
</select>
</div>
{issueBookingForm.idDocumentType === 'NATIONAL_ID' ? (
<div>
<label className="label">National ID Number</label>
<input
className="input"
value={issueBookingForm.idDocumentNumber}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, idDocumentNumber: e.target.value }))}
/>
</div>
) : (
<div>
<label className="label">Passport Number *</label>
<input
className="input"
value={issueBookingForm.passportNumber}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, passportNumber: e.target.value }))}
/>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Phone{issueBookingForm.bookingKind === 'PASSENGER' ? ' * (payment link sent here)' : ''}</label>
<input
className="input"
value={issueBookingForm.phone}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, phone: e.target.value }))}
placeholder="+251911234567"
/>
</div>
<div>
<label className="label">Email</label>
<input
className="input"
value={issueBookingForm.email}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, email: e.target.value }))}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<ActionButton
variant="secondary"
onClick={() => {
setShowIssueBookingModal(false);
setSelectedSeat(null);
setIssueBookingCoach(null);
}}
>
Cancel
</ActionButton>
<ActionButton onClick={submitIssueBooking} loading={issueBookingMutation.isPending}>
{issueBookingForm.bookingKind === 'STAFF' ? 'Issue Ticket' : 'Send Payment Link'}
</ActionButton>
</div>
</div>
)}
</Modal>
<Modal
isOpen={showRemoveModal}
onClose={() => {
@@ -973,6 +1234,7 @@ interface SeatIconProps {
handleUndoRemove: (seat: any) => void;
handleSetMaintenance: (seat: any) => void;
handleClearMaintenance: (seat: any) => void;
handleIssueBooking: (seat: any, coach: any) => void;
}
function SeatIcon({
@@ -989,6 +1251,7 @@ function SeatIcon({
handleUndoRemove,
handleSetMaintenance,
handleClearMaintenance,
handleIssueBooking,
}: SeatIconProps) {
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
@@ -1058,7 +1321,7 @@ function SeatIcon({
<button
onClick={() => handleBlock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Block seat"
title="Reserve seat"
>
<Lock className="h-3 w-3 text-gray-700" />
</button>
@@ -1072,13 +1335,22 @@ function SeatIcon({
</>
)}
{canUnblock && (
<button
onClick={() => handleUnblock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Unblock seat"
>
<Unlock className="h-3 w-3 text-gray-700" />
</button>
<>
<button
onClick={() => handleUnblock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Release reservation"
>
<Unlock className="h-3 w-3 text-gray-700" />
</button>
<button
onClick={() => handleIssueBooking(seat, coach)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Issue booking"
>
<TicketIcon className="h-3 w-3 text-gray-700" />
</button>
</>
)}
{canMaintenance && (
<button

View File

@@ -48,6 +48,10 @@ export const bookingsApi = {
apiClient.post<any>(`/payments/${bookingId}/force-confirm`, data),
smartAssign: (bookingId: string) =>
apiClient.post<any>(`/tickets/smart-assign/${bookingId}`, {}),
// Converts a reserved (blocked) seat into a real booking — STAFF (fee-waived, ticket
// issued immediately) or PASSENGER (payment link texted to the traveler's phone).
issueFromReservation: (seatId: string, data: any) =>
apiClient.post<any>(`/bookings/reservations/${seatId}/issue`, data),
};
// Passengers API

View File

@@ -0,0 +1,24 @@
"use client";
import { useParams } from "next/navigation";
import { XCircle } from "lucide-react";
import Link from "next/link";
export default function ReservationPayFailedPage() {
const { token } = useParams<{ token: string }>();
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center px-4">
<div className="max-w-sm w-full text-center space-y-4">
<XCircle className="w-16 h-16 text-red-500 mx-auto" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Payment failed</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Your payment could not be completed. Please try again.
</p>
<Link href={`/reserve/pay/${token}`} className="btn-primary inline-block px-6 py-2.5 font-semibold">
Try again
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
"use client";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import { PaymentMethod } from "@/types";
import {
Loader2,
CreditCard,
Smartphone,
Wallet,
Landmark,
CheckCircle,
AlertCircle,
} from "lucide-react";
const getIconForMethod = (methodId: string) => {
if (methodId.includes("CARD")) return CreditCard;
if (methodId.includes("WALLET")) return Wallet;
if (methodId.includes("CAC")) return Landmark;
return Smartphone;
};
/**
* Standalone pay-by-link page for a booking issued via the backoffice's "reserve seat →
* issue booking (passenger)" flow — no portal login/session required, unlike the normal
* /booking/payment page which depends on client-side booking-store state populated during
* a live search→seats→review session. Cloned from /pay-balance/[token] (same pattern:
* resolve-by-token, method picker, single pay button) but pointed at a booking instead of a
* supplementary charge, and reusing the SAME already-public booking-payment endpoints
* (/payments/initiate, /payments/methods) the normal payment page calls — no new payment
* mechanics, just a session-free entry point.
*/
export default function ReservationPayPage() {
const { token } = useParams<{ token: string }>();
const router = useRouter();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null);
const { data: booking, isLoading: loadingBooking, error: bookingError } = useQuery({
queryKey: ["reservation-booking", token],
queryFn: () => apiClient.get<any>(`/bookings/pay/${token}`),
retry: false,
});
const { data: paymentMethods = [], isLoading: loadingMethods } = useQuery<PaymentMethod[]>({
queryKey: ["paymentMethods"],
queryFn: async () => {
const res = await apiClient.get<PaymentMethod[]>("/payments/methods");
return Array.isArray(res) ? res : [];
},
enabled: !!booking,
});
const payMutation = useMutation({
mutationFn: (method: string) =>
apiClient.post<any>("/payments/initiate", {
bookingId: booking.id,
method,
platform: "web",
}),
onSuccess: (data: any) => {
if (data?.clientAction?.type === "REDIRECT") {
window.location.href = data.clientAction.url;
return;
}
router.push(`/reserve/pay/${token}/success`);
},
onError: (err: any) => {
setPaymentError(
err?.response?.data?.message ?? err?.message ?? "Payment failed. Please try again."
);
setIsProcessing(false);
},
});
const handlePay = () => {
if (!selectedMethod) return;
setIsProcessing(true);
setPaymentError(null);
payMutation.mutate(selectedMethod);
};
if (loadingBooking) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-10 h-10 text-primary animate-spin" />
</div>
);
}
if (bookingError || !booking) {
const msg = (bookingError as any)?.response?.data?.message ?? "This payment link is invalid or has expired.";
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="max-w-sm w-full text-center space-y-4">
<AlertCircle className="w-14 h-14 text-red-500 mx-auto" />
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Link unavailable</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">{msg}</p>
</div>
</div>
);
}
const currency = booking.displayCurrency ?? booking.currency ?? "ETB";
const amountMinor = booking.displayTotalMinor ?? booking.totalMinor;
const amountDisplay = (amountMinor / 100).toFixed(2);
const seat = booking.seats?.[0];
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-start justify-center px-4 py-10">
<div className="w-full max-w-md space-y-4">
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Complete your booking</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Booking <span className="font-semibold text-gray-700 dark:text-gray-300">{booking.bookingRef}</span>
</p>
</div>
<div className="card space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 dark:text-gray-400">Route</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{booking.schedule?.origin?.name} {booking.schedule?.destination?.name}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 dark:text-gray-400">Departure</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{booking.schedule?.departureAt ? new Date(booking.schedule.departureAt).toLocaleString() : "N/A"}
</span>
</div>
{seat && (
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 dark:text-gray-400">Seat</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{seat.seatNumber} {seat.coach ? `(Coach ${seat.coach})` : ""}
</span>
</div>
)}
<div className="border-t border-gray-100 dark:border-gray-800 pt-3 flex justify-between items-center">
<span className="font-bold text-gray-900 dark:text-gray-100">Amount due</span>
<span className="text-2xl font-bold text-primary">{currency} {amountDisplay}</span>
</div>
</div>
<div className="card space-y-3">
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100">Select payment method</h2>
{loadingMethods ? (
<div className="flex items-center justify-center py-6 gap-2">
<Loader2 className="w-5 h-5 text-primary animate-spin" />
<span className="text-sm text-gray-500 dark:text-gray-400">Loading...</span>
</div>
) : (
<div className="space-y-2">
{paymentMethods.filter((m) => m.enabled).map((method) => {
const Icon = getIconForMethod(method.type);
const isSelected = selectedMethod === method.type;
return (
<button
key={method.id}
onClick={() => setSelectedMethod(method.type)}
disabled={isProcessing}
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
isSelected
? "border-primary bg-primary/8 dark:bg-primary/15 shadow-md"
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary/50"
} ${isProcessing ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${isSelected ? "bg-primary" : "bg-gray-100 dark:bg-gray-700"}`}>
<Icon className={`w-5 h-5 ${isSelected ? "text-white" : "text-primary"}`} />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.displayName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{method.region} · {method.currency}</p>
</div>
{isSelected && <CheckCircle className="w-5 h-5 text-primary flex-shrink-0" />}
</div>
</button>
);
})}
</div>
)}
</div>
{paymentError && (
<p className="text-red-600 dark:text-red-400 text-sm text-center"> {paymentError}</p>
)}
<button
onClick={handlePay}
disabled={!selectedMethod || isProcessing}
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : (
`Pay ${currency} ${amountDisplay}`
)}
</button>
<p className="text-xs text-gray-400 dark:text-gray-500 text-center">🔒 Secure & encrypted payment</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import { CheckCircle } from "lucide-react";
import Link from "next/link";
export default function ReservationPaySuccessPage() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center px-4">
<div className="max-w-sm w-full text-center space-y-4">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Payment successful</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Your booking is confirmed. Your ticket has been sent to you.
</p>
<Link href="/" className="btn-primary inline-block px-6 py-2.5 font-semibold">
Back to home
</Link>
</div>
</div>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -24,9 +24,9 @@ test("BC-11 ✅ travelMinutesToStop drives each stop's estimated arrival indepen
name: "E2E Check-in Cutoff Route",
effectiveFrom: "2020-01-01T00:00:00Z",
stops: [
{ stationId: STATIONS.A, sequence: 1 },
{ stationId: STATIONS.B, sequence: 2, travelMinutesToStop: 60 },
{ stationId: STATIONS.C, sequence: 3, travelMinutesToStop: 40 },
{ stationId: STATIONS.A, sequence: 1, distanceKm: 0 },
{ stationId: STATIONS.B, sequence: 2, travelMinutesToStop: 60, distanceKm: 60 },
{ stationId: STATIONS.C, sequence: 3, travelMinutesToStop: 40, distanceKm: 100 },
],
},
});

View File

@@ -71,7 +71,15 @@ test("BC-10 ✅ a schedule with a past departure is rejected by the API (past-da
const arr = new Date(dep.getTime() + 4 * 3600e3);
const okRes = await request.post(`${API_URL}/schedules`, {
headers: auth(),
data: { trainId: UI_IDS.train, routeId: ROUTE_ID, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() },
// ROUTE_ID has no route coach template, so createSchedule needs an explicit coach or it 400s
// on the "zero coaches assigned" guard (see schedules.service.ts).
data: {
trainId: UI_IDS.train,
routeId: ROUTE_ID,
departureAt: dep.toISOString(),
arrivalAt: arr.toISOString(),
coachIds: [UI_IDS.coach],
},
});
expect(okRes.ok()).toBeTruthy();
const sched = (await okRes.json())?.data ?? {};