mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
250 lines
12 KiB
TypeScript
250 lines
12 KiB
TypeScript
/**
|
|
* 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<any>;
|
|
}
|
|
|
|
function webhookEvent(booking: any, overrides: Partial<Record<string, any>> = {}) {
|
|
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");
|
|
});
|
|
});
|
|
});
|