import { Test, TestingModule } from "@nestjs/testing"; import { PaymentsService } from "./payments.service"; import { PaymentClientService } from "./payment-client.service"; import { CurrencyService } from "../currency/currency.service"; import { PrismaService } from "../../common/prisma.service"; import { SeatsService } from "../seats/seats.service"; import { TicketsService } from "../tickets/tickets.service"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { PaymentIntentStatus, PaymentMethodType } from "@prisma/client"; import { BadRequestException, NotFoundException } from "@nestjs/common"; import { PaymentIntentSnapshot, PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod, ProviderPaymentStatus, } from "@edr/types"; describe("PaymentsService", () => { let service: PaymentsService; let prisma: PrismaService; let seatsService: SeatsService; let ticketsService: TicketsService; let eventEmitter: EventEmitter2; const mockPrisma: Record = { booking: { findUnique: jest.fn(), update: jest.fn(), }, paymentIntent: { findUnique: jest.fn(), findUniqueOrThrow: jest.fn(), upsert: jest.fn(), update: jest.fn(), create: jest.fn(), }, paymentMethod: { findUnique: jest.fn(), }, walletAccount: { findUnique: jest.fn(), update: jest.fn(), }, walletLedgerEntry: { create: jest.fn(), }, loyaltyAccount: { findUnique: jest.fn(), update: jest.fn(), }, loyaltyLedgerEntry: { create: jest.fn(), }, $transaction: jest.fn((callback: (tx: any) => any) => callback(mockPrisma)), }; const mockSeatsService = { confirmSeats: jest.fn(), releaseSeats: jest.fn(), }; const mockTicketsService = { generate: jest.fn(), }; const mockEventEmitter = { emit: jest.fn(), }; const mockPaymentClient = { initiate: jest.fn(), getIntentByReference: jest.fn(), }; // Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB). const mockCurrencyService = { convertEtbMinorToChargeMajor: jest.fn((minor: number) => Promise.resolve(minor), ), getRateOrThrow: jest.fn(), }; const requiresActionSnapshot = ( provider: ProviderMethod, ): PaymentIntentSnapshot => ({ intentId: "remote-intent-1", service: PaymentServiceEnum.PASSENGER, referenceType: PaymentReferenceType.BOOKING, referenceId: "booking-1", merchantOrderId: "PSG-MERCH-123", provider, status: ProviderPaymentStatus.REQUIRES_ACTION, amountMinor: 50000, currency: "ETB", clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, }); beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ PaymentsService, { provide: PrismaService, useValue: mockPrisma }, { provide: SeatsService, useValue: mockSeatsService }, { provide: TicketsService, useValue: mockTicketsService }, { provide: EventEmitter2, useValue: mockEventEmitter }, { provide: PaymentClientService, useValue: mockPaymentClient }, { provide: CurrencyService, useValue: mockCurrencyService }, ], }).compile(); service = module.get(PaymentsService); prisma = module.get(PrismaService); seatsService = module.get(SeatsService); ticketsService = module.get(TicketsService); eventEmitter = module.get(EventEmitter2); jest.clearAllMocks(); mockPaymentClient.getIntentByReference.mockResolvedValue(null); }); describe("initiatePayment", () => { const mockBooking = { id: "booking-1", bookingRef: "EDR123456", passengerId: "passenger-1", totalMinor: 50000, currency: "ETB", status: "PENDING_PAYMENT", seats: [{ id: "seat-1", seatId: "seat-id-1" }], }; it("should throw NotFoundException if booking not found", async () => { mockPrisma.booking.findUnique.mockResolvedValue(null); await expect( service.initiatePayment({ bookingId: "invalid", method: "TELEBIRR" as any, }), ).rejects.toThrow(NotFoundException); }); it("should throw BadRequestException if booking not payable", async () => { mockPrisma.booking.findUnique.mockResolvedValue({ ...mockBooking, status: "CONFIRMED", }); await expect( service.initiatePayment({ bookingId: "booking-1", method: "TELEBIRR" as any, }), ).rejects.toThrow(BadRequestException); }); it("should initiate a provider payment through the payment microservice", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPaymentClient.initiate.mockResolvedValue( requiresActionSnapshot(ProviderMethod.TELEBIRR), ); mockPrisma.paymentIntent.upsert.mockResolvedValue({ id: "intent-1", status: PaymentIntentStatus.REQUIRES_ACTION, merchantOrderId: "PSG-MERCH-123", clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, }); const result = await service.initiatePayment({ bookingId: "booking-1", method: "TELEBIRR" as any, }); expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION); expect(result.clientAction?.url).toBe("https://provider.example/pay"); expect(mockPaymentClient.initiate).toHaveBeenCalledWith( expect.objectContaining({ service: PaymentServiceEnum.PASSENGER, referenceType: PaymentReferenceType.BOOKING, referenceId: "booking-1", orderRef: "EDR123456", // 50000 minor ETB → 500.00 major, settled in ETB (no FX for Ethiopian methods). amountMinor: 500, currency: "ETB", provider: "TELEBIRR", }), ); // Snapshot mirrored into the local projection. expect(mockPrisma.paymentIntent.upsert).toHaveBeenCalledWith( expect.objectContaining({ where: { bookingId: "booking-1" } }), ); }); it("should finalize the booking when the service reports an already-paid intent", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPaymentClient.initiate.mockResolvedValue({ ...requiresActionSnapshot(ProviderMethod.WAAFI), status: ProviderPaymentStatus.SUCCEEDED, providerTxnId: "TXN-1", paidAt: new Date().toISOString(), }); // Projection clamps SUCCEEDED to PROCESSING; finalizePaymentSuccess flips it. mockPrisma.paymentIntent.upsert.mockResolvedValue({ id: "intent-1", bookingId: "booking-1", status: PaymentIntentStatus.PROCESSING, }); mockPrisma.paymentIntent.findUnique.mockResolvedValue({ id: "intent-1", bookingId: "booking-1", status: PaymentIntentStatus.PROCESSING, }); mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({ id: "intent-1", status: PaymentIntentStatus.SUCCEEDED, merchantOrderId: "PSG-MERCH-123", }); mockPrisma.loyaltyAccount.findUnique.mockResolvedValue(null); const result = await service.initiatePayment({ bookingId: "booking-1", method: "WAAFI" as any, }); expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED); expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1"); }); it("should initiate wallet payment and debit successfully", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); // First call: existing-intent check (none); second call: finalize loads the new intent. mockPrisma.paymentIntent.findUnique .mockResolvedValueOnce(null) .mockResolvedValue({ id: "intent-1", bookingId: "booking-1", status: PaymentIntentStatus.PROCESSING, }); mockPrisma.walletAccount.findUnique.mockResolvedValue({ id: "wallet-1", passengerId: "passenger-1", balanceMinor: 100000, }); mockPrisma.paymentIntent.upsert.mockResolvedValue({ id: "intent-1", status: PaymentIntentStatus.PROCESSING, }); mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({ id: "intent-1", status: PaymentIntentStatus.SUCCEEDED, bookingId: "booking-1", }); mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({ id: "loyalty-1", pointsBalance: 100, }); const result = await service.initiatePayment({ bookingId: "booking-1", method: "WALLET" as any, }); expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED); expect(mockSeatsService.confirmSeats).toHaveBeenCalled(); expect(mockTicketsService.generate).toHaveBeenCalled(); expect(mockPaymentClient.initiate).not.toHaveBeenCalled(); }); it("should fail wallet payment with insufficient balance", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); mockPrisma.walletAccount.findUnique.mockResolvedValue({ id: "wallet-1", passengerId: "passenger-1", balanceMinor: 10000, // Less than booking total }); mockPrisma.paymentIntent.upsert.mockResolvedValue({ id: "intent-1", status: PaymentIntentStatus.FAILED, failureCode: "INSUFFICIENT_BALANCE", }); const result = await service.initiatePayment({ bookingId: "booking-1", method: "WALLET" as any, }); expect(result.status).toBe(PaymentIntentStatus.FAILED); }); }); describe("finalizePaymentSuccess", () => { it("should finalize payment and issue ticket", async () => { const mockIntent = { id: "intent-1", bookingId: "booking-1", status: PaymentIntentStatus.PROCESSING, }; const mockBooking = { id: "booking-1", passengerId: "passenger-1", totalMinor: 50000, seats: [{ seatId: "seat-1" }], }; mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent); mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({ id: "loyalty-1", pointsBalance: 100, }); const result = await service.finalizePaymentSuccess({ intentId: "intent-1", providerTxnId: "TXN-123", }); expect(result.alreadyFinalized).toBe(false); expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(["seat-1"]); expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1"); expect(mockEventEmitter.emit).toHaveBeenCalledWith("payment.succeeded", { booking: mockBooking, }); }); it("should return alreadyFinalized if payment already succeeded", async () => { mockPrisma.paymentIntent.findUnique.mockResolvedValue({ id: "intent-1", status: PaymentIntentStatus.SUCCEEDED, }); const result = await service.finalizePaymentSuccess({ intentId: "intent-1", }); expect(result.alreadyFinalized).toBe(true); }); }); describe("getIntentByBookingId", () => { it("should return the cached local intent when the payment service has none", async () => { const mockIntent = { id: "intent-1", bookingId: "booking-1", status: PaymentIntentStatus.SUCCEEDED, method: PaymentMethodType.TELEBIRR, paidAt: new Date(), merchantOrderId: "MERCH-123", updatedAt: new Date(), }; mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent); mockPaymentClient.getIntentByReference.mockResolvedValue(null); const result = await service.getIntentByBookingId("booking-1"); expect(result.intentId).toBe("intent-1"); expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED); }); it("should mirror a payment-service snapshot into the local projection", async () => { mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); mockPaymentClient.getIntentByReference.mockResolvedValue( requiresActionSnapshot(ProviderMethod.WAAFI), ); mockPrisma.paymentIntent.upsert.mockResolvedValue({ id: "intent-1", bookingId: "booking-1", status: PaymentIntentStatus.REQUIRES_ACTION, merchantOrderId: "PSG-MERCH-123", clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, }); const result = await service.getIntentByBookingId("booking-1"); expect(mockPaymentClient.getIntentByReference).toHaveBeenCalledWith( PaymentReferenceType.BOOKING, "booking-1", ); expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION); expect(result.clientAction?.url).toBe("https://provider.example/pay"); }); it("should throw NotFoundException if intent not found anywhere", async () => { mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); mockPaymentClient.getIntentByReference.mockResolvedValue(null); await expect(service.getIntentByBookingId("invalid")).rejects.toThrow( NotFoundException, ); }); }); });