import { BadRequestException, NotFoundException } from "@nestjs/common"; import { of, throwError } from "rxjs"; import { AxiosError, AxiosHeaders } from "axios"; import { PaymentReferenceType, ProviderPaymentStatus } from "@edr/types"; import { PaymentClientService } from "./payment-client.service"; import { PaymentService } from "./payment.service"; /** Local intent projection row (the invoice's `paymentId` points at this). */ function localIntent(overrides: Record = {}) { return { id: "intent-1", refId: "booking-1", referenceType: PaymentReferenceType.SHIPMENT, status: "action-required", method: "cac-bank", merchantOrderId: "EDR_INV_1", clientAction: { type: "COLLECT_OTP", providerOrderId: "471583397" }, ...overrides, }; } function makeRepo(rows: Record[]) { const store = [...rows]; return { findOneBy: jest.fn((where: Record) => Promise.resolve( store.find((r) => Object.entries(where).every(([k, v]) => r[k] === v), ) ?? null, ), ), update: jest.fn((where: { id: string }, data: Record) => { const row = store.find((r) => r.id === where.id); if (row) Object.assign(row, data); return Promise.resolve(undefined); }), }; } describe("PaymentService.confirmOtp", () => { const build = ( client: Partial, rows = [localIntent()], ) => { const repo = makeRepo(rows); const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) }; const service = new PaymentService( repo as never, client as never, billing as never, {} as never, ); return { service, repo, billing }; }; it("settles the local intent and tells billing to settle the invoice on SUCCEEDED", async () => { const paidAt = "2026-07-31T10:00:00.000Z"; const { service, repo, billing } = build({ getIntentByReference: jest .fn() .mockResolvedValue({ intentId: "gw-1", status: "REQUIRES_ACTION" }), confirmOtp: jest.fn().mockResolvedValue({ intentId: "gw-1", status: ProviderPaymentStatus.SUCCEEDED, providerTxnId: "11709363209530624", paidAt, }), }); const result = await service.confirmOtp("intent-1", "8280"); expect(repo.update).toHaveBeenCalledWith( { id: "intent-1" }, expect.objectContaining({ status: "success", transactionId: "11709363209530624", }), ); // Billing settles the invoice linked by this intent id. expect(billing.settleByPaymentId).toHaveBeenCalledWith( "intent-1", "11709363209530624", new Date(paidAt), ); expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED); }); it("forwards the OTP against the GATEWAY intent id, not the local one", async () => { const confirmOtp = jest .fn() .mockResolvedValue({ status: ProviderPaymentStatus.REQUIRES_ACTION }); const { service } = build({ getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }), confirmOtp, }); await service.confirmOtp("intent-1", "8280"); expect(confirmOtp).toHaveBeenCalledWith("gw-1", "8280"); }); it("leaves the intent open and does not settle when the OTP is not accepted", async () => { const { service, repo, billing } = build({ getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }), confirmOtp: jest.fn().mockResolvedValue({ status: ProviderPaymentStatus.REQUIRES_ACTION, failureMessage: "OTP confirmation failed", }), }); const result = await service.confirmOtp("intent-1", "0000"); expect(billing.settleByPaymentId).not.toHaveBeenCalled(); expect(repo.update).toHaveBeenCalledWith( { id: "intent-1" }, expect.objectContaining({ status: "action-required" }), ); expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION); }); it("404s when the gateway has no active intent for the reference", async () => { const { service } = build({ getIntentByReference: jest.fn().mockResolvedValue(null), confirmOtp: jest.fn(), }); await expect(service.confirmOtp("intent-1", "8280")).rejects.toBeInstanceOf( NotFoundException, ); }); }); describe("PaymentClientService.confirmOtp", () => { const axiosErr = (status: number, message: string) => new AxiosError( `Request failed with status code ${status}`, undefined, undefined, undefined, { status, statusText: "", data: { message }, headers: new AxiosHeaders(), config: { headers: new AxiosHeaders() }, }, ); const build = (request: jest.Mock) => new PaymentClientService({ request } as never); it("posts the OTP to the payment service intent-confirm route", async () => { const request = jest .fn() .mockReturnValue(of({ data: { intentId: "gw-1", status: "SUCCEEDED" } })); const result = await build(request).confirmOtp("gw-1", "8280"); expect(request).toHaveBeenCalledWith( expect.objectContaining({ method: "POST", url: expect.stringContaining("/payments/intents/gw-1/confirm"), data: { otp: "8280" }, }), ); expect(result.status).toBe("SUCCEEDED"); }); it("maps a rejected OTP (400) to BadRequest so the payer can retry", async () => { const request = jest .fn() .mockReturnValue( throwError(() => axiosErr(400, "OTP confirmation failed")), ); await expect(build(request).confirmOtp("gw-1", "0000")).rejects.toBeInstanceOf( BadRequestException, ); }); it("maps an unknown intent (404) to BadRequest rather than a gateway error", async () => { const request = jest .fn() .mockReturnValue(throwError(() => axiosErr(404, "PaymentIntent not found"))); await expect(build(request).confirmOtp("nope", "8280")).rejects.toBeInstanceOf( BadRequestException, ); }); }); describe("PaymentService.markIntentSucceeded", () => { const build = (rows: Record[]) => { const repo = makeRepo(rows); const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) }; const service = new PaymentService( repo as never, {} as never, billing as never, {} as never, ); return { service, repo, billing }; }; it("re-notifies billing on an already-success intent so a settle that died mid-way converges on redelivery", async () => { const paidAt = new Date("2026-08-01T09:00:00.000Z"); const { service, repo, billing } = build([ localIntent({ status: "success", transactionId: "txn-1", paidAt }), ]); const result = await service.markIntentSucceeded("intent-1", { notify: true, }); expect(result.alreadyFinalized).toBe(true); // No re-write of the intent row… expect(repo.update).not.toHaveBeenCalled(); // …but billing still gets the (idempotent) settle call. expect(billing.settleByPaymentId).toHaveBeenCalledWith( "intent-1", "txn-1", paidAt, ); }); it("does not notify billing when notify is false, even when already success", async () => { const { service, billing } = build([localIntent({ status: "success" })]); await service.markIntentSucceeded("intent-1", { notify: false }); expect(billing.settleByPaymentId).not.toHaveBeenCalled(); }); });