feat(checkout): capture a payment through injected dependencies
capturePayment(deps, { uuid, paypalOrderId, now }) is the capture flow
without Strapi or the PayPal SDK, so it is tested with fakes:
- a missing order is answered 404;
- a paid order is answered alreadyCaptured for the PayPal order it was
paid with, else 409, without a call or a write;
- a PayPal order the checkout did not store on the order is answered
409 before PayPal is asked anything;
- a PayPal order that does not verify as approved for the order is
answered 409 without a capture;
- the capture is sent with the PayPal-Request-Id
capture-<uuid>-<paypalOrderId>, so a retry captures once;
- a capture that does not verify leaves the order unpaid, is logged as
an error with the order id and the reason, and is answered 502: PayPal
may have taken the money, so it needs a manual refund check;
- the payment is written only while the order still carries the PayPal
order id, so an order whose cart or address changed during the
capture stays unpaid (409, logged with the capture id);
- PayPal and network errors are answered 502 and logged by name,
message and status code only.
Refs libretech/mp#71
This commit is contained in:
2 files changed
+530
No files matched your search
@@ -0,0 +1,375 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
ALREADY_PAID,
|
||||
NOT_CONFIRMED,
|
||||
NOT_FOUND,
|
||||
NOT_MATCHING,
|
||||
NOT_RECORDED,
|
||||
NOT_THIS_ORDER,
|
||||
ORDER_CHANGED,
|
||||
PAYPAL_FAILED,
|
||||
capturePayment,
|
||||
captureRequestId
|
||||
} from "../../src/checkout/capture-payment.ts";
|
||||
import { formatLogLine } from "../../src/logging/format.ts";
|
||||
|
||||
const UUID = "11111111-2222-4333-8444-555555555555";
|
||||
const PAYPAL_ORDER_ID = "5O190127TN364715T";
|
||||
const OTHER_PAYPAL_ORDER_ID = "8MC585209K746392H";
|
||||
const CAPTURE_ID = "3C679366HH908993F";
|
||||
// The server's time when the capture request arrives.
|
||||
const NOW = new Date("2026-10-08T19:13:05.000Z");
|
||||
const CREDENTIALS = "RFVNTVlfQ0xJRU5UX0lEOkRVTU1ZX0NMSUVOVF9TRUNSRVQ="; // base64 of the dummy "DUMMY_CLIENT_ID:DUMMY_CLIENT_SECRET"
|
||||
|
||||
// PayPal's Orders v2 JSON for the order below, see paypal-verify.test.ts. Every call returns a fresh copy to change.
|
||||
const fixture = (name: string) => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8"));
|
||||
|
||||
// The row the capture reads with strapi.db.query("api::order.order").findOne({ where: { uuid } }) after a checkout stored the
|
||||
// PayPal order it created: subtotal 17.90, delivery 4.95, total 22.85.
|
||||
const orderRow = (overrides = {}) => ({
|
||||
id: 4711,
|
||||
uuid: UUID,
|
||||
email: "erika@example.org",
|
||||
subtotal: 17.9,
|
||||
total: 22.85,
|
||||
VAT: 2.86,
|
||||
paymentAuthorised: false,
|
||||
paymentStatus: null,
|
||||
invoiceSent: false,
|
||||
paypalOrderId: PAYPAL_ORDER_ID,
|
||||
paypalCaptureId: null,
|
||||
paymentCapturedAt: null,
|
||||
...overrides
|
||||
});
|
||||
|
||||
/**
|
||||
* Fakes of the dependencies over one order row, which record every call and every log line. updateOrder writes like the
|
||||
* controller's update with where { id, paypalOrderId }: only while the row still carries that PayPal order, else it answers null.
|
||||
* duringCapture runs while PayPal captures, as a request that changes the order at that moment would.
|
||||
*/
|
||||
const setup = ({
|
||||
row = orderRow(),
|
||||
paypalOrder = fixture("paypal-order-approved.json"),
|
||||
captured = fixture("paypal-order-captured.json"),
|
||||
getError = undefined,
|
||||
captureError = undefined,
|
||||
updateError = undefined,
|
||||
duringCapture = (row) => {}
|
||||
} = {}) => {
|
||||
const calls = { findOrder: [], getPayPalOrder: [], capturePayPalOrder: [], updateOrder: [] };
|
||||
const logs = [];
|
||||
const deps = {
|
||||
findOrder: async (uuid) => {
|
||||
calls.findOrder.push(uuid);
|
||||
return row !== null && row.uuid === uuid ? { ...row } : null;
|
||||
},
|
||||
getPayPalOrder: async (id) => {
|
||||
calls.getPayPalOrder.push(id);
|
||||
if (getError) throw getError;
|
||||
return paypalOrder;
|
||||
},
|
||||
capturePayPalOrder: async (id, requestId) => {
|
||||
calls.capturePayPalOrder.push({ id, requestId });
|
||||
duringCapture(row);
|
||||
if (captureError) throw captureError;
|
||||
return captured;
|
||||
},
|
||||
updateOrder: async (id, paypalOrderId, data) => {
|
||||
calls.updateOrder.push({ id, paypalOrderId, data });
|
||||
if (updateError) throw updateError;
|
||||
if (row.id !== id || row.paypalOrderId !== paypalOrderId) return null;
|
||||
Object.assign(row, data);
|
||||
return { ...row };
|
||||
},
|
||||
log: {
|
||||
info: (message, fields) => logs.push({ level: "info", message, fields }),
|
||||
warn: (message, fields) => logs.push({ level: "warn", message, fields }),
|
||||
error: (message, fields) => logs.push({ level: "error", message, fields })
|
||||
}
|
||||
};
|
||||
return { deps, calls, logs, row };
|
||||
};
|
||||
|
||||
const capture = (deps, { uuid = UUID, paypalOrderId = PAYPAL_ORDER_ID } = {}) => capturePayment(deps, { uuid, paypalOrderId, now: NOW });
|
||||
|
||||
const paypalCalls = (calls) => calls.getPayPalOrder.length + calls.capturePayPalOrder.length;
|
||||
|
||||
// What must never reach the log: the credentials of a failed request, and the buyer's data in PayPal's responses.
|
||||
const NEVER_LOGGED = [CREDENTIALS, "Authorization", "Erika", "Mustermann", "erika", "Musterstraße", "QYR5Z8XDVJNXQ", "INSTRUMENT_DECLINED"];
|
||||
|
||||
/** Checks the recorded log, and each line as the console shows it (config/logger.ts formats with formatLogLine). */
|
||||
const assertCleanLog = (logs) => {
|
||||
const lines = logs.map(({ level, message, fields }) =>
|
||||
formatLogLine({ timestamp: "2026-10-08 19:13:05.000", level, message: `app:${level[0]}:order-controller: ${message}`, ...fields })
|
||||
);
|
||||
for (const text of [JSON.stringify(logs), ...lines]) {
|
||||
for (const value of NEVER_LOGGED) assert.ok(!text.includes(value), `the log holds ${value}: ${text}`);
|
||||
}
|
||||
};
|
||||
|
||||
// What the PayPal SDK throws for an error response: @apimatic/core's ApiError, here the SDK's CustomError, with the request, whose
|
||||
// Authorization header holds the client credentials (token request) or an access token, and PayPal's error body.
|
||||
const paypalApiError = (statusCode: number, message: string) =>
|
||||
Object.assign(new Error(message), {
|
||||
request: {
|
||||
method: "POST",
|
||||
url: `https://api-m.sandbox.paypal.com/v2/checkout/orders/${PAYPAL_ORDER_ID}/capture`,
|
||||
headers: { Authorization: `Basic ${CREDENTIALS}`, "PayPal-Request-Id": captureRequestId(UUID, PAYPAL_ORDER_ID) }
|
||||
},
|
||||
statusCode,
|
||||
headers: { "paypal-debug-id": "f1a2b3c4d5e6f" },
|
||||
body: JSON.stringify({ name: "UNPROCESSABLE_ENTITY", details: [{ issue: "INSTRUMENT_DECLINED" }], debug_id: "f1a2b3c4d5e6f" })
|
||||
});
|
||||
|
||||
// What axios throws when PayPal cannot be reached: the request config with its headers.
|
||||
const networkError = (message = "getaddrinfo EAI_AGAIN api-m.sandbox.paypal.com") =>
|
||||
Object.assign(new Error(message), {
|
||||
name: "AxiosError",
|
||||
code: "EAI_AGAIN",
|
||||
config: { method: "post", url: "https://api-m.sandbox.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } }
|
||||
});
|
||||
|
||||
// The capture
|
||||
|
||||
test("captures the PayPal order the checkout stored and writes exactly the payment fields", async () => {
|
||||
const { deps, calls, logs } = setup();
|
||||
const result = await capture(deps);
|
||||
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.alreadyCaptured, false);
|
||||
assert.deepEqual(calls.findOrder, [UUID]);
|
||||
assert.deepEqual(calls.getPayPalOrder, [PAYPAL_ORDER_ID]);
|
||||
assert.deepEqual(calls.capturePayPalOrder, [{ id: PAYPAL_ORDER_ID, requestId: `capture-${UUID}-${PAYPAL_ORDER_ID}` }]);
|
||||
assert.deepEqual(calls.updateOrder, [
|
||||
{
|
||||
id: 4711,
|
||||
paypalOrderId: PAYPAL_ORDER_ID,
|
||||
data: { paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID, paymentCapturedAt: "2026-10-08T19:13:05.000Z" }
|
||||
}
|
||||
]);
|
||||
// the controller answers the updated order, sanitized
|
||||
assert.equal(result.order.paymentAuthorised, true);
|
||||
assert.deepEqual(logs, [{ level: "info", message: "Captured the payment", fields: { order: 4711, capture: CAPTURE_ID } }]);
|
||||
assertCleanLog(logs);
|
||||
});
|
||||
|
||||
test("sends the same PayPal-Request-Id on every attempt, so that PayPal captures once", async () => {
|
||||
assert.equal(captureRequestId(UUID, PAYPAL_ORDER_ID), "capture-11111111-2222-4333-8444-555555555555-5O190127TN364715T");
|
||||
// PayPal accepts at most 108 characters
|
||||
assert.ok(captureRequestId(UUID, PAYPAL_ORDER_ID).length <= 108);
|
||||
|
||||
// The first attempt times out after PayPal may have captured. The retry repeats the id, and PayPal answers the first capture.
|
||||
const first = setup({ captureError: Object.assign(networkError("timeout of 30000ms exceeded"), { code: "ECONNABORTED" }) });
|
||||
assert.deepEqual(await capture(first.deps), { status: 502, message: PAYPAL_FAILED });
|
||||
const retry = setup();
|
||||
assert.equal((await capture(retry.deps)).status, 200);
|
||||
|
||||
assert.equal(first.calls.capturePayPalOrder[0].requestId, retry.calls.capturePayPalOrder[0].requestId);
|
||||
});
|
||||
|
||||
// Paying a cheap order, capturing an expensive one
|
||||
|
||||
test("rejects a PayPal order the checkout did not store on the order, without asking PayPal", async () => {
|
||||
// The buyer paid order A with PayPal order OTHER_PAYPAL_ORDER_ID and calls the capture of the expensive order B with it.
|
||||
const { deps, calls, logs, row } = setup({ row: orderRow({ id: 4712, uuid: UUID, total: 249 }) });
|
||||
|
||||
assert.deepEqual(await capture(deps, { paypalOrderId: OTHER_PAYPAL_ORDER_ID }), { status: 409, message: NOT_THIS_ORDER });
|
||||
assert.equal(paypalCalls(calls), 0);
|
||||
assert.equal(calls.updateOrder.length, 0);
|
||||
assert.equal(row.paymentAuthorised, false);
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "warn",
|
||||
message: "Rejected a capture with a PayPal order the checkout did not create for the order",
|
||||
fields: { order: 4712, hasPayPalOrder: true }
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects every PayPal order for an order without a stored one, without asking PayPal", async () => {
|
||||
// no checkout yet, an order from before the checkout stored it, or a cart changed since the checkout
|
||||
for (const paypalOrderId of [null, undefined, ""]) {
|
||||
const { deps, calls, logs } = setup({ row: orderRow({ paypalOrderId }) });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 409, message: NOT_THIS_ORDER });
|
||||
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
|
||||
assert.equal(logs[0].fields.hasPayPalOrder, false);
|
||||
}
|
||||
|
||||
// a request without a PayPal order id
|
||||
const { deps, calls } = setup();
|
||||
assert.deepEqual(await capture(deps, { paypalOrderId: "" }), { status: 409, message: NOT_THIS_ORDER });
|
||||
assert.equal(paypalCalls(calls), 0);
|
||||
});
|
||||
|
||||
test("does not capture a PayPal order whose amount is not the order total", async () => {
|
||||
const paypalOrder = fixture("paypal-order-approved.json");
|
||||
paypalOrder.purchase_units[0].amount.value = "2.29";
|
||||
const { deps, calls, logs } = setup({ paypalOrder });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 409, message: NOT_MATCHING });
|
||||
assert.deepEqual(calls.getPayPalOrder, [PAYPAL_ORDER_ID]);
|
||||
assert.equal(calls.capturePayPalOrder.length, 0);
|
||||
assert.equal(calls.updateOrder.length, 0);
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "warn",
|
||||
message: "Rejected a capture: the PayPal order does not match the order",
|
||||
fields: { order: 4711, reason: "the PayPal order amount 2.29 is not the order total 22.85" }
|
||||
}
|
||||
]);
|
||||
assertCleanLog(logs);
|
||||
});
|
||||
|
||||
test("does not capture a PayPal order created for another order, in another currency or not approved", async () => {
|
||||
const cases: [(paypalOrder) => void, string][] = [
|
||||
[
|
||||
(paypalOrder) => (paypalOrder.purchase_units[0].custom_id = "99999999-8888-4777-8666-555555555555"),
|
||||
"the custom_id of the PayPal order is not the order's uuid"
|
||||
],
|
||||
[(paypalOrder) => (paypalOrder.purchase_units[0].amount.currency_code = "USD"), "the PayPal order currency is USD, not EUR"],
|
||||
[(paypalOrder) => (paypalOrder.status = "CREATED"), "the PayPal order status is CREATED, not APPROVED"]
|
||||
];
|
||||
for (const [change, reason] of cases) {
|
||||
const paypalOrder = fixture("paypal-order-approved.json");
|
||||
change(paypalOrder);
|
||||
const { deps, calls, logs } = setup({ paypalOrder });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 409, message: NOT_MATCHING });
|
||||
assert.equal(calls.capturePayPalOrder.length + calls.updateOrder.length, 0);
|
||||
assert.equal(logs[0].fields.reason, reason);
|
||||
assertCleanLog(logs);
|
||||
}
|
||||
});
|
||||
|
||||
// A paid order
|
||||
|
||||
test("answers alreadyCaptured for a paid order and the PayPal order it was paid with, without a call or a write", async () => {
|
||||
const { deps, calls, logs } = setup({ row: orderRow({ paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID }) });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 200, alreadyCaptured: true });
|
||||
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
|
||||
assert.deepEqual(logs, []);
|
||||
});
|
||||
|
||||
test("rejects another PayPal order for a paid order, without a call or a write", async () => {
|
||||
const paidRows = [
|
||||
orderRow({ paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID }),
|
||||
// paid before the checkout stored the PayPal order
|
||||
orderRow({ paymentAuthorised: true, paymentStatus: "authorised", paypalOrderId: null })
|
||||
];
|
||||
for (const row of paidRows) {
|
||||
const { deps, calls } = setup({ row });
|
||||
|
||||
assert.deepEqual(await capture(deps, { paypalOrderId: OTHER_PAYPAL_ORDER_ID }), { status: 409, message: ALREADY_PAID });
|
||||
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("answers 404 for a missing order, without asking PayPal", async () => {
|
||||
const { deps, calls } = setup({ row: null });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 404, message: NOT_FOUND });
|
||||
assert.equal(paypalCalls(calls), 0);
|
||||
|
||||
// No uuid: no lookup at all, since Strapi drops an undefined where condition and would find any order.
|
||||
for (const uuid of ["", undefined]) {
|
||||
const { deps, calls } = setup();
|
||||
assert.deepEqual(await capturePayment(deps, { uuid, paypalOrderId: PAYPAL_ORDER_ID, now: NOW }), { status: 404, message: NOT_FOUND });
|
||||
assert.deepEqual(calls.findOrder, []);
|
||||
}
|
||||
});
|
||||
|
||||
// PayPal fails
|
||||
|
||||
test("answers 502 when PayPal fails, and logs the error's name, message and status code but not its request", async () => {
|
||||
const failures: [string, Error & { statusCode?: number }][] = [
|
||||
["getError", paypalApiError(401, "Authentication failed due to missing authorization header, or invalid authentication credentials.")],
|
||||
["getError", networkError()],
|
||||
["captureError", paypalApiError(422, "The requested action could not be performed, semantically incorrect, or failed business validation.")],
|
||||
["captureError", networkError()]
|
||||
];
|
||||
for (const [failing, error] of failures) {
|
||||
const { deps, calls, logs, row } = setup({ [failing]: error });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 502, message: PAYPAL_FAILED });
|
||||
assert.equal(calls.capturePayPalOrder.length, failing === "getError" ? 0 : 1);
|
||||
assert.equal(calls.updateOrder.length, 0);
|
||||
assert.equal(row.paymentAuthorised, false);
|
||||
assert.equal(logs.length, 1);
|
||||
assert.equal(logs[0].level, "error");
|
||||
assert.deepEqual(logs[0].fields, { order: 4711, error: { name: error.name, message: error.message, statusCode: error.statusCode } });
|
||||
assertCleanLog(logs);
|
||||
}
|
||||
});
|
||||
|
||||
// After PayPal answered the capture
|
||||
|
||||
test("keeps the order unpaid and answers 502 when the capture does not match, logging the order and the reason only", async () => {
|
||||
const pending = fixture("paypal-order-captured.json");
|
||||
pending.purchase_units[0].payments.captures[0].status = "PENDING";
|
||||
const lower = fixture("paypal-order-captured.json");
|
||||
lower.purchase_units[0].payments.captures[0].amount.value = "2.29";
|
||||
const minimal = { id: PAYPAL_ORDER_ID, status: "COMPLETED", links: [] };
|
||||
|
||||
const cases: [unknown, string][] = [
|
||||
[pending, "the capture status is PENDING, not COMPLETED"],
|
||||
[lower, "the capture amount 2.29 is not the order total 22.85"],
|
||||
[minimal, "the capture response has no capture"]
|
||||
];
|
||||
for (const [captured, reason] of cases) {
|
||||
const { deps, calls, logs, row } = setup({ captured });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 502, message: NOT_CONFIRMED });
|
||||
assert.equal(calls.updateOrder.length, 0);
|
||||
assert.equal(row.paymentAuthorised, false);
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "error",
|
||||
message: "The PayPal capture does not match the order, which stays unpaid: check the PayPal order for a refund",
|
||||
fields: { order: 4711, reason }
|
||||
}
|
||||
]);
|
||||
assertCleanLog(logs);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the order unpaid when its cart or address changed during the capture", async () => {
|
||||
// addProduct, removeProduct and updateByUuid clear paypalOrderId in the write that changes the order
|
||||
const { deps, calls, logs, row } = setup({
|
||||
duringCapture: (row) => {
|
||||
row.total = 249;
|
||||
row.paypalOrderId = null;
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 409, message: ORDER_CHANGED });
|
||||
assert.equal(calls.updateOrder.length, 1);
|
||||
assert.equal(row.paymentAuthorised, false);
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "error",
|
||||
message: "PayPal captured the payment, but the order changed during the capture and stays unpaid: check the order for a refund",
|
||||
fields: { order: 4711, capture: CAPTURE_ID }
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("answers 500 when the order cannot be marked paid after the capture, and logs the capture", async () => {
|
||||
const { deps, logs } = setup({ updateError: new Error("Connection terminated unexpectedly") });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 500, message: NOT_RECORDED });
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "error",
|
||||
message: "PayPal captured the payment, but the order could not be marked paid: check the order",
|
||||
fields: {
|
||||
order: 4711,
|
||||
capture: CAPTURE_ID,
|
||||
error: { name: "Error", message: "Connection terminated unexpectedly", statusCode: undefined }
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
Reference in new issue
Block a user