/** * Tier-2 money-integrity suite β€” services behind the IAM/RabbitMQ wall, instantiated directly with * a real Prisma (test DB) + stubbed collaborators. Confirms critical findings: * F1/F2 πŸ”΄ WalletService.topUp credits any passenger's wallet with no ownership check and no * payment backing (free money). * G4/G5 πŸ”΄ BookingsService.cancel computes an 80% refund but NEVER disburses it β€” no PaymentRefund, * no wallet credit; the cancellation sits at refundStatus PENDING forever. * E1/E2 πŸ”΄ ExcessBaggageService.logCharge picks the OLDEST BaggageAllowance globally, ignoring the * booking's seat class, and computes fee = feePerKgMinor Γ— excessWeightKg. */ import { WalletService } from "../src/modules/wallet/wallet.service"; import { BookingsService } from "../src/modules/bookings/bookings.service"; import { ExcessBaggageService } from "../src/modules/excess-baggage/excess-baggage.service"; import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma"; import { truncateAllPassenger, seedCore, IDS } 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("Money integrity (Tier-2 direct instantiation)", () => { const prisma = getTestPrisma(); beforeEach(async () => { await truncateAllPassenger(prisma); await seedCore(prisma); }); afterAll(async () => { await disconnectTestPrisma(); }); // ── F1 / F2 ──────────────────────────────────────────────────────────────── it("F1/F2 πŸ”΄ topUp credits another passenger's wallet β€” no ownership check, no payment backing", async () => { const victim = await prisma.passenger.create({ data: {} }); await prisma.walletAccount.create({ data: { passengerId: victim.id, balanceMinor: 0 }, }); const wallet = new WalletService(prisma as any); // An attacker-controlled call: just pass the victim's id. Nothing checks caller identity, // and no PaymentIntent/settlement backs the credit. await wallet.topUp(victim.id, 1_000_000, "free money"); const after = await prisma.walletAccount.findUnique({ where: { passengerId: victim.id }, }); expect(after?.balanceMinor).toBe(1_000_000); // The only ledger entry is a bare CREDIT β€” no linked payment. const ledger = await prisma.walletLedgerEntry.findMany({ where: { walletId: after!.id }, }); expect(ledger).toHaveLength(1); expect(ledger[0].type).toBe("CREDIT"); expect(ledger[0].relatedBookingId ?? null).toBeNull(); }); // ── G4 / G5 ──────────────────────────────────────────────────────────────── it("G4/G5 πŸ”΄ cancel() computes floor(total*0.8) refund but never disburses it (stuck PENDING)", async () => { const passenger = await prisma.passenger.create({ data: {} }); // Give the passenger a wallet so we can prove NO refund lands in it. const w = await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 0 }, }); const schedule = await makeSchedule(prisma, passenger.id); const booking = await prisma.booking.create({ data: { bookingRef: "CXL-0001", passengerId: passenger.id, scheduleId: schedule.id, totalMinor: 30_000, displayCurrency: "ETB", status: "CONFIRMED", }, }); const bookings = new BookingsService( 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 asyncStub(), // fareEngine asyncStub(), // auditService ); const result: any = await bookings.cancel(booking.bookingRef, "test"); // Refund is COMPUTED as 80%: expect(result.refundAmount).toBe(Math.floor(30_000 * 0.8) / 100); // 240.00 // …but recorded only as PENDING, and never actually paid out: const cancellation = await prisma.bookingCancellation.findFirst({ where: { bookingId: booking.id }, }); expect(cancellation?.refundStatus).toBe("PENDING"); // No PaymentRefund row was created anywhere (isolated DB) and the wallet was NOT credited. const refundCount = await prisma.paymentRefund.count(); expect(refundCount).toBe(0); const walletAfter = await prisma.walletAccount.findUnique({ where: { id: w.id } }); expect(walletAfter?.balanceMinor).toBe(0); }); it("accepts a booking reference when logging an excess baggage charge", async () => { const passenger = await prisma.passenger.create({ data: {} }); const schedule = await makeSchedule(prisma, passenger.id); const booking = await prisma.booking.create({ data: { bookingRef: "BAG-REF-001", passengerId: passenger.id, scheduleId: schedule.id, totalMinor: 30_000, status: "CONFIRMED", }, }); await prisma.baggageAllowance.create({ data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 80 }, }); const service = new ExcessBaggageService( prisma as any, asyncStub(), // auditService asyncStub(), // currencyService asyncStub(), // paymentClient asyncStub(), // notifications asyncStub(), // smsClient asyncStub(), // emailClient ); const charge: any = await service.logCharge({ bookingReference: booking.bookingRef, excessWeightKg: 2, collectCash: true, } as any); expect(charge.bookingId).toBe(booking.id); expect(charge.feePerKgMinor).toBe(80); expect(charge.totalMinor).toBe(160); }); // ── E1 / E2 ──────────────────────────────────────────────────────────────── it("E1/E2 πŸ”΄ excess-baggage uses the OLDEST allowance globally (ignores seat class); fee = rateΓ—kg", async () => { const passenger = await prisma.passenger.create({ data: {} }); const schedule = await makeSchedule(prisma, passenger.id); const booking = await prisma.booking.create({ data: { bookingRef: "BAG-0001", passengerId: passenger.id, scheduleId: schedule.id, totalMinor: 30_000, status: "CONFIRMED", }, }); // Oldest allowance is for the LOCAL class (rate 50). A later one for INTL (rate 200) should win // for an intl booking β€” but logCharge ignores seat class and takes the oldest row. await prisma.baggageAllowance.create({ data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 50 }, }); await prisma.baggageAllowance.create({ data: { seatClassId: IDS.seatClassIntl, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 200 }, }); const service = new ExcessBaggageService( prisma as any, asyncStub(), // auditService asyncStub(), // currencyService asyncStub(), // paymentClient asyncStub(), // notifications asyncStub(), // smsClient asyncStub(), // emailClient ); const charge: any = await service.logCharge({ bookingId: booking.id, excessWeightKg: 10, collectCash: true, } as any); // Used the oldest (LOCAL, 50) not any seat-class-matched rate; fee = 50 Γ— 10. expect(charge.feePerKgMinor).toBe(50); expect(charge.totalMinor).toBe(50 * 10); }); }); let trainSeq = 0; /** Minimal TrainSchedule (+train) so booking/cancel fixtures satisfy FKs. */ async function makeSchedule(prisma: any, _passengerId: string) { const train = await prisma.train.create({ data: { number: `T-${++trainSeq}`, name: "Test Train" }, }); return prisma.trainSchedule.create({ data: { trainId: train.id, routeId: IDS.route, originStationId: IDS.stationA, destinationStationId: IDS.stationB, departureAt: new Date(Date.now() + 86_400_000), arrivalAt: new Date(Date.now() + 90_000_000), durationMinutes: 60, }, }); }