mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
147 lines
7.1 KiB
TypeScript
147 lines
7.1 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|