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