/** * Currency conversion through a REAL booking — CurrencyService/FareEngineService are only * exercised in isolation elsewhere (pricing-currency.e2e-spec.ts, pricing-fare-engine.e2e-spec.ts); * nothing previously drove an actual GuestBookingService.createGuestBooking() call in a * non-ETB displayCurrency and asserted the converted amount. * * Also covers the consequence of the C2 fix documented in pricing-currency.e2e-spec.ts: * CurrencyService.getExchangeRate() now fails closed (throws) on a missing rate instead of * silently pricing at parity — confirms that failure mode actually propagates out of booking * creation as a real rejection, not a silently wrong charge. */ import { IdDocumentType, Currency } from "@prisma/client"; import { SchedulesService } from "../src/modules/schedules/schedules.service"; import { SeatsService } from "../src/modules/seats/seats.service"; import { CurrencyService } from "../src/modules/currency/currency.service"; import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; import { SystemConfigService } from "../src/modules/system-config/system-config.service"; import { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; import { IDS, resetAndSeedCore, USD_TO_ETB, ETB_TO_DJF } from "./fixtures/seed-core"; function asyncStub(): any { return new Proxy({}, { get: () => async () => undefined }); } describe("Currency conversion through a real booking", () => { let harness: ServiceHarness; let schedulesService: SchedulesService; let seatsService: SeatsService; let guestBookingService: GuestBookingService; beforeAll(async () => { harness = await createServiceHarness(); schedulesService = await harness.moduleRef.resolve(SchedulesService); const currencyService = harness.moduleRef.get(CurrencyService); const fareEngine = harness.moduleRef.get(FareEngineService); const systemConfig = new SystemConfigService(harness.prisma as any); seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); guestBookingService = new GuestBookingService( harness.prisma as any, seatsService, asyncStub(), currencyService, asyncStub(), fareEngine, { emit: () => true } as any, asyncStub(), asyncStub(), asyncStub(), ); }); afterAll(async () => { await harness?.close(); }); beforeEach(async () => { await resetAndSeedCore(harness.prisma); }); const future = (mins: number) => new Date(Date.now() + mins * 60_000); async function createTestSchedule(trainNumber: string) { const train = await harness.prisma.train.create({ data: { number: trainNumber, name: "Test" } }); const coach = await harness.prisma.coach.create({ data: { coachTypeId: IDS.coachType, number: `${trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, }); const seats = await Promise.all( ["1A", "1B", "1C"].map((seatNumber, i) => harness.prisma.seat.create({ data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 } }), ), ); const schedule = await schedulesService.createSchedule({ trainId: train.id, routeId: IDS.route, departureAt: future(180).toISOString(), arrivalAt: future(220).toISOString(), coachIds: [coach.id], } as any); return { schedule, seats }; } async function bookInCurrency(scheduleId: string, seatId: string, displayCurrency: Currency, passengerId: string, nationality: "Djiboutian" | "Ethiopian" = "Djiboutian") { const hold = await seatsService.holdSeats({ scheduleId, originStationId: IDS.stationA, destinationStationId: IDS.stationB, passengers: [{ passengerId, seatId }], } as any); return guestBookingService.createGuestBooking({ scheduleId, holdId: (hold as any).holdId, originStationId: IDS.stationA, destinationStationId: IDS.stationB, seatClassId: IDS.seatClassLocal, displayCurrency, passengers: [{ seatId, passengerName: "Test Traveler", dateOfBirth: "1990-01-01", idDocumentType: IdDocumentType.PASSPORT, passportNumber: "X123456", passportCountry: "Djibouti", nationality, }], } as any) as Promise; } it("charges the SAME ETB amount regardless of displayCurrency, but displays it converted by the seeded rate", async () => { const { schedule, seats } = await createTestSchedule(`CUR-${Date.now()}`); const etbBooking = await bookInCurrency(schedule.id, seats[0].id, Currency.ETB, "66666666-6666-4666-8666-600000000001"); const usdBooking = await bookInCurrency(schedule.id, seats[1].id, Currency.USD, "66666666-6666-4666-8666-600000000002"); const djfBooking = await bookInCurrency(schedule.id, seats[2].id, Currency.DJF, "66666666-6666-4666-8666-600000000003"); // ETB charge basis (totalMinor) is identical across all three — displayCurrency only // affects what's SHOWN to the passenger, never the authoritative ETB amount collected. expect(usdBooking.totalMinor).toBe(etbBooking.totalMinor); expect(djfBooking.totalMinor).toBe(etbBooking.totalMinor); expect(etbBooking.currency).toBe("ETB"); expect(usdBooking.currency).toBe("ETB"); expect(djfBooking.currency).toBe("ETB"); // displayTotalMinor derived from the exact seeded rates (see seed-core.ts). expect(etbBooking.displayTotalMinor).toBe(etbBooking.totalMinor); expect(usdBooking.displayTotalMinor).toBe(etbBooking.totalMinor * (1 / USD_TO_ETB)); expect(djfBooking.displayTotalMinor).toBe(etbBooking.totalMinor * ETB_TO_DJF); expect(etbBooking.displayCurrency).toBe("ETB"); expect(usdBooking.displayCurrency).toBe("USD"); expect(djfBooking.displayCurrency).toBe("DJF"); }); it("rejects booking creation when the requested displayCurrency has no configured FX rate (fails closed, doesn't price at parity)", async () => { const { schedule, seats } = await createTestSchedule(`CUR-NORATE-${Date.now()}`); // Remove BOTH directions of the ETB<->DJF rate. Nationality is Ethiopian (not Djiboutian) // specifically so FareEngineService's OWN internal billing-currency conversion (tied to // nationality, resolves to ETB for an Ethiopian passenger — a same-currency no-op that // never touches the DB) doesn't also need this rate; only the OUTER // GuestBookingService.createGuestOneWayBooking's displayCurrency conversion does. This // isolates the failure to that one call rather than fare calculation itself. await harness.prisma.currencyExchangeRate.deleteMany({ where: { OR: [{ fromCurrency: "ETB", toCurrency: "DJF" }, { fromCurrency: "DJF", toCurrency: "ETB" }] }, }); await expect( bookInCurrency(schedule.id, seats[0].id, Currency.DJF, "66666666-6666-4666-8666-600000000004", "Ethiopian"), ).rejects.toThrow(/No exchange rate configured/i); // No half-created booking left behind by the failed conversion. const orphan = await harness.prisma.booking.findFirst({ where: { scheduleId: schedule.id, displayCurrency: "DJF" } }); expect(orphan).toBeNull(); }); });