diff --git a/src/checkout/capture-payment.ts b/src/checkout/capture-payment.ts new file mode 100644 index 0000000..46716b3 --- /dev/null +++ b/src/checkout/capture-payment.ts @@ -0,0 +1,155 @@ +import { verifyApprovedOrder, verifyCompletedCapture } from "./paypal-verify"; +import type { VerifiableOrder } from "./paypal-verify"; +import { describeError } from "../logging/error"; + +// POST /orders/:uuid/capture/:paypalOrderId, which the shop calls once the buyer approved the payment in PayPal's window +// (libreshop/shop pages/checkout/3.vue, onApprove). The PayPal order id comes from the browser, so it must be the one the checkout +// stored on the order before PayPal is asked anything, and PayPal's answers are checked against the order before it is marked paid. +// The order controller passes Strapi and PayPalApi as the dependencies; the tests pass fakes. + +/** The fields of the order the capture reads. */ +export type CapturableOrder = VerifiableOrder & { + id: number | string; + paymentAuthorised?: boolean | null; + paypalOrderId?: string | null; +}; + +/** What a verified capture writes to the order. */ +export type CapturedPayment = { + paymentAuthorised: true; + paymentStatus: "captured"; + paypalCaptureId: string; + paymentCapturedAt: string; +}; + +export type CaptureLog = Record<"info" | "warn" | "error", (message: string, fields: Record) => void>; + +export type CapturePaymentDeps = { + /** The order with this uuid, or null. */ + findOrder: (uuid: string) => Promise; + /** PayPal's order as parsed JSON: GET /v2/checkout/orders/:id. */ + getPayPalOrder: (paypalOrderId: string) => Promise; + /** Captures the PayPal order and returns PayPal's order as parsed JSON: POST /v2/checkout/orders/:id/capture. */ + capturePayPalOrder: (paypalOrderId: string, requestId: string) => Promise; + /** Writes data to the order only while the order still carries this PayPal order id: the updated order, or null if it does not. */ + updateOrder: (id: O["id"], paypalOrderId: string, data: CapturedPayment) => Promise; + log: CaptureLog; +}; + +export type CapturePaymentResult = + | { status: 200; alreadyCaptured: false; order: R } + | { status: 200; alreadyCaptured: true } + | { status: 404 | 409 | 500 | 502; message: string }; + +export const NOT_FOUND = "Order not found"; +export const ALREADY_PAID = "Order is already paid"; +export const NOT_THIS_ORDER = "Payment does not belong to this order"; +export const NOT_MATCHING = "Payment does not match this order"; +export const PAYPAL_FAILED = "PayPal could not capture the payment"; +export const NOT_CONFIRMED = "Payment could not be confirmed"; +export const NOT_RECORDED = "Payment could not be recorded"; +export const ORDER_CHANGED = "Order changed during the payment"; + +/** + * The PayPal-Request-Id of the capture. PayPal answers a repeated id with the first capture's response instead of capturing again, + * so a capture that is retried, or sent twice, takes the money once. + */ +export const captureRequestId = (uuid: string, paypalOrderId: string): string => `capture-${uuid}-${paypalOrderId}`; + +/** + * Captures the PayPal order the checkout created for the order and marks the order paid, in this order: + * 1. a missing order is answered 404; + * 2. a paid order is answered alreadyCaptured if the PayPal order is the one it was paid with, else 409, without a write; + * 3. a PayPal order the checkout did not store on the order is answered 409, without asking PayPal; + * 4. a PayPal order that is not APPROVED for this order's uuid, total and currency is answered 409, without a capture; + * 5. the capture must be COMPLETED for the order total in EUR, else the order stays unpaid and 502 is answered; + * 6. the payment is written only while the order still carries this PayPal order id, so a cart or address changed during the + * capture is not marked paid. + * PayPal and network errors are answered 502 and logged with name, message and status code only. + */ +export const capturePayment = async ( + deps: CapturePaymentDeps, + { uuid, paypalOrderId, now }: { uuid: string; paypalOrderId: string; now: Date } +): Promise> => { + const { log } = deps; + + // An empty uuid finds no order. Strapi would drop an undefined condition and find any order. + const order = typeof uuid === "string" && uuid !== "" ? await deps.findOrder(uuid) : null; + if (!order) return { status: 404, message: NOT_FOUND }; + + const isStoredPayPalOrder = typeof order.paypalOrderId === "string" && order.paypalOrderId !== "" && order.paypalOrderId === paypalOrderId; + + if (order.paymentAuthorised) { + if (isStoredPayPalOrder) return { status: 200, alreadyCaptured: true }; + log.warn("Rejected a capture for a paid order with another PayPal order", { order: order.id }); + return { status: 409, message: ALREADY_PAID }; + } + if (!isStoredPayPalOrder) { + log.warn("Rejected a capture with a PayPal order the checkout did not create for the order", { + order: order.id, + hasPayPalOrder: typeof order.paypalOrderId === "string" && order.paypalOrderId !== "" + }); + return { status: 409, message: NOT_THIS_ORDER }; + } + + let paypalOrder: unknown; + try { + paypalOrder = await deps.getPayPalOrder(paypalOrderId); + } catch (error) { + log.error("Could not get the PayPal order", { order: order.id, error: describeError(error) }); + return { status: 502, message: PAYPAL_FAILED }; + } + + const approved = verifyApprovedOrder(order, paypalOrder); + if (approved.ok === false) { + log.warn("Rejected a capture: the PayPal order does not match the order", { order: order.id, reason: approved.reason }); + return { status: 409, message: NOT_MATCHING }; + } + + let captured: unknown; + try { + captured = await deps.capturePayPalOrder(paypalOrderId, captureRequestId(order.uuid, paypalOrderId)); + } catch (error) { + log.error("PayPal did not capture the payment", { order: order.id, error: describeError(error) }); + return { status: 502, message: PAYPAL_FAILED }; + } + + const capture = verifyCompletedCapture(order, captured); + if (capture.ok === false) { + // PayPal answered the capture, so it may have taken the money: the PayPal order needs a manual check, and a refund if it was paid. + log.error("The PayPal capture does not match the order, which stays unpaid: check the PayPal order for a refund", { + order: order.id, + reason: capture.reason + }); + return { status: 502, message: NOT_CONFIRMED }; + } + + const data: CapturedPayment = { + paymentAuthorised: true, + paymentStatus: "captured", + paypalCaptureId: capture.captureId, + paymentCapturedAt: now.toISOString() + }; + + let updated: R | null; + try { + updated = await deps.updateOrder(order.id, paypalOrderId, data); + } catch (error) { + log.error("PayPal captured the payment, but the order could not be marked paid: check the order", { + order: order.id, + capture: capture.captureId, + error: describeError(error) + }); + return { status: 500, message: NOT_RECORDED }; + } + if (updated === null || updated === undefined) { + log.error("PayPal captured the payment, but the order changed during the capture and stays unpaid: check the order for a refund", { + order: order.id, + capture: capture.captureId + }); + return { status: 409, message: ORDER_CHANGED }; + } + + log.info("Captured the payment", { order: order.id, capture: capture.captureId }); + return { status: 200, alreadyCaptured: false, order: updated }; +}; diff --git a/tests/unit/capture-payment.test.ts b/tests/unit/capture-payment.test.ts new file mode 100644 index 0000000..9d8ceb8 --- /dev/null +++ b/tests/unit/capture-payment.test.ts @@ -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 } + } + } + ]); +});