376 lines
16 KiB
TypeScript
376 lines
16 KiB
TypeScript
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 }
|
|
}
|
|
}
|
|
]);
|
|
});
|