mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
test(payment): cover the CAC Bank OTP confirm path
The confirm flow settles money on a provider with no webhook, so the two places it can go wrong are worth pinning: that the OTP is forwarded against the GATEWAY intent id (not the local projection id), and that a rejected code leaves the intent open instead of failing the payment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
190
apps/edr-freight-api/src/modules/payment/payment.service.spec.ts
Normal file
190
apps/edr-freight-api/src/modules/payment/payment.service.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
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<string, unknown> = {}) {
|
||||
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<string, unknown>[]) {
|
||||
const store = [...rows];
|
||||
return {
|
||||
findOneBy: jest.fn((where: Record<string, unknown>) =>
|
||||
Promise.resolve(
|
||||
store.find((r) =>
|
||||
Object.entries(where).every(([k, v]) => r[k] === v),
|
||||
) ?? null,
|
||||
),
|
||||
),
|
||||
update: jest.fn((where: { id: string }, data: Record<string, unknown>) => {
|
||||
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<PaymentClientService>,
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user