mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
233 lines
7.3 KiB
TypeScript
233 lines
7.3 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("PaymentService.markIntentSucceeded", () => {
|
|
const build = (rows: Record<string, unknown>[]) => {
|
|
const repo = makeRepo(rows);
|
|
const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) };
|
|
const service = new PaymentService(
|
|
repo as never,
|
|
{} as never,
|
|
billing 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();
|
|
});
|
|
});
|