Checkout: only customer fields on order updates, verified PayPal capture, safe logging #6

Merged
libretech merged 16 commits from fix/paypal-request-logging into main 2026-10-15 10:23:04 +02:00
4 changed files with 577 additions and 0 deletions
Showing only changes of commit 4d77998b51 - Show all commits

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
// Checks PayPal's answers against the shop order before the order is marked paid: the PayPal order the buyer approved
// (GET /v2/checkout/orders/:id) and the order after its capture (POST /v2/checkout/orders/:id/capture, Prefer: return=representation).
// The JSON is PayPal's Orders v2 JSON in snake_case, as PayPalApi parses the raw response body. Money is compared in integer cents.
// A reason says what does not match. It repeats only PayPal's statuses, currency codes and amounts, never a name, an email address
// or an address from the response, because it is logged.
/** The fields of the shop order the checks compare. */
export type VerifiableOrder = {
uuid: string;
total: number | null;
};
export type ApprovedOrderCheck = { ok: true } | { ok: false; reason: string };
export type CompletedCaptureCheck = { ok: true; captureId: string } | { ok: false; reason: string };
const CURRENCY = "EUR";
type Json = Record<string, unknown>;
const isObject = (value: unknown): value is Json => typeof value === "object" && value !== null && !Array.isArray(value);
/** PayPal's money value as integer cents: "12.34" is 1234. Null for anything but a non-negative amount with at most two decimals. */
export const parseCents = (value: unknown): number | null => {
if (typeof value !== "string") return null;
const match = /^(\d{1,10})(?:\.(\d{1,2}))?$/.exec(value);
if (match === null) return null;
return Number(match[1]) * 100 + Number((match[2] ?? "").padEnd(2, "0"));
};
/** The order total in integer cents, or null if the order has no positive total. Strapi returns the decimal column as a number. */
export const orderTotalCents = (order: { total: number | null }): number | null =>
typeof order.total === "number" && Number.isFinite(order.total) && order.total > 0 ? Math.round(order.total * 100) : null;
const formatCents = (cents: number): string => `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
// The values of OrderStatus and CaptureStatus in @paypal/paypal-server-sdk 0.6.1. A reason repeats these and ISO currency codes,
// and no other value from the response.
const ORDER_STATUSES = ["CREATED", "SAVED", "APPROVED", "VOIDED", "COMPLETED", "PAYER_ACTION_REQUIRED"];
const CAPTURE_STATUSES = ["COMPLETED", "DECLINED", "PARTIALLY_REFUNDED", "PENDING", "REFUNDED", "FAILED"];
const shown = (value: unknown, isKnown: (value: string) => boolean): string =>
typeof value === "string" && isKnown(value) ? value : "missing or unexpected";
const orderStatus = (value: unknown): string => shown(value, (status) => ORDER_STATUSES.includes(status));
const captureStatus = (value: unknown): string => shown(value, (status) => CAPTURE_STATUSES.includes(status));
const currency = (value: unknown): string => shown(value, (code) => /^[A-Z]{3}$/.test(code));
const firstPurchaseUnit = (paypalOrder: Json): Json | null => {
const units = paypalOrder.purchase_units;
return Array.isArray(units) && isObject(units[0]) ? units[0] : null;
};
/** Why PayPal's money ({ currency_code, value }) is not the order total in EUR, or null if it is. */
const amountMismatch = (subject: string, money: unknown, order: VerifiableOrder): string | null => {
if (!isObject(money)) return `${subject} has no amount`;
if (money.currency_code !== CURRENCY) return `${subject} currency is ${currency(money.currency_code)}, not ${CURRENCY}`;
const cents = parseCents(money.value);
if (cents === null) return `${subject} amount is not a money value`;
const total = orderTotalCents(order);
if (total === null) return "the order has no total";
if (cents !== total) return `${subject} amount ${formatCents(cents)} is not the order total ${formatCents(total)}`;
return null;
};
/**
* The PayPal order may be captured for this order: the buyer approved it, it was created for this order (its custom_id is the
* order's uuid, see buildPayPalOrderRequest), and its amount is the order total in EUR.
*/
export const verifyApprovedOrder = (order: VerifiableOrder, paypalOrder: unknown): ApprovedOrderCheck => {
if (!isObject(paypalOrder)) return { ok: false, reason: "the PayPal order is not an object" };
if (paypalOrder.status !== "APPROVED") {
return { ok: false, reason: `the PayPal order status is ${orderStatus(paypalOrder.status)}, not APPROVED` };
}
const unit = firstPurchaseUnit(paypalOrder);
if (unit === null) return { ok: false, reason: "the PayPal order has no purchase unit" };
if (typeof order.uuid !== "string" || order.uuid === "" || unit.custom_id !== order.uuid) {
return { ok: false, reason: "the custom_id of the PayPal order is not the order's uuid" };
}
const mismatch = amountMismatch("the PayPal order", unit.amount, order);
return mismatch === null ? { ok: true } : { ok: false, reason: mismatch };
};
/**
* PayPal captured the order total in EUR: the PayPal order and its first capture are COMPLETED. Returns the capture's id.
* A capture PayPal holds for review (PENDING) or declined (DECLINED) is not a payment.
*/
export const verifyCompletedCapture = (order: VerifiableOrder, captured: unknown): CompletedCaptureCheck => {
if (!isObject(captured)) return { ok: false, reason: "the capture response is not an object" };
if (captured.status !== "COMPLETED") return { ok: false, reason: `the PayPal order status is ${orderStatus(captured.status)}, not COMPLETED` };
const unit = firstPurchaseUnit(captured);
const payments = unit !== null && isObject(unit.payments) ? unit.payments : null;
const capture = payments !== null && Array.isArray(payments.captures) && isObject(payments.captures[0]) ? payments.captures[0] : null;
if (capture === null) return { ok: false, reason: "the capture response has no capture" };
if (capture.status !== "COMPLETED") return { ok: false, reason: `the capture status is ${captureStatus(capture.status)}, not COMPLETED` };
const mismatch = amountMismatch("the capture", capture.amount, order);
if (mismatch !== null) return { ok: false, reason: mismatch };
if (typeof capture.id !== "string" || !/^[A-Za-z0-9-]{1,64}$/.test(capture.id)) return { ok: false, reason: "the capture has no valid id" };
return { ok: true, captureId: capture.id };
};
@@ -0,0 +1,88 @@
{
"id": "5O190127TN364715T",
"intent": "CAPTURE",
"status": "APPROVED",
"payment_source": {
"paypal": {
"email_address": "erika.buyer@example.org",
"account_id": "QYR5Z8XDVJNXQ",
"account_status": "VERIFIED",
"name": {
"given_name": "Erika",
"surname": "Mustermann"
},
"address": {
"country_code": "DE"
}
}
},
"purchase_units": [
{
"reference_id": "11111111-2222-4333-8444-555555555555",
"amount": {
"currency_code": "EUR",
"value": "22.85",
"breakdown": {
"item_total": {
"currency_code": "EUR",
"value": "15.04"
},
"shipping": {
"currency_code": "EUR",
"value": "4.95"
},
"tax_total": {
"currency_code": "EUR",
"value": "2.86"
}
}
},
"payee": {
"email_address": "merchant@example.com",
"merchant_id": "7KNGBPH2U58GQ"
},
"custom_id": "11111111-2222-4333-8444-555555555555",
"invoice_id": "R-12-4711",
"shipping": {
"name": {
"full_name": "Erika Mustermann"
},
"address": {
"address_line_1": "Musterstraße 1",
"admin_area_2": "Stuttgart",
"postal_code": "70190",
"country_code": "DE"
}
}
}
],
"payer": {
"name": {
"given_name": "Erika",
"surname": "Mustermann"
},
"email_address": "erika.buyer@example.org",
"payer_id": "QYR5Z8XDVJNXQ",
"address": {
"country_code": "DE"
}
},
"create_time": "2026-10-08T19:12:40Z",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
"rel": "self",
"method": "GET"
},
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
"rel": "update",
"method": "PATCH"
},
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T/capture",
"rel": "capture",
"method": "POST"
}
]
}
@@ -0,0 +1,131 @@
{
"id": "5O190127TN364715T",
"intent": "CAPTURE",
"status": "COMPLETED",
"payment_source": {
"paypal": {
"email_address": "erika.buyer@example.org",
"account_id": "QYR5Z8XDVJNXQ",
"account_status": "VERIFIED",
"name": {
"given_name": "Erika",
"surname": "Mustermann"
},
"address": {
"country_code": "DE"
}
}
},
"purchase_units": [
{
"reference_id": "11111111-2222-4333-8444-555555555555",
"amount": {
"currency_code": "EUR",
"value": "22.85",
"breakdown": {
"item_total": {
"currency_code": "EUR",
"value": "15.04"
},
"shipping": {
"currency_code": "EUR",
"value": "4.95"
},
"tax_total": {
"currency_code": "EUR",
"value": "2.86"
}
}
},
"payee": {
"email_address": "merchant@example.com",
"merchant_id": "7KNGBPH2U58GQ"
},
"custom_id": "11111111-2222-4333-8444-555555555555",
"invoice_id": "R-12-4711",
"shipping": {
"name": {
"full_name": "Erika Mustermann"
},
"address": {
"address_line_1": "Musterstraße 1",
"admin_area_2": "Stuttgart",
"postal_code": "70190",
"country_code": "DE"
}
},
"payments": {
"captures": [
{
"id": "3C679366HH908993F",
"status": "COMPLETED",
"amount": {
"currency_code": "EUR",
"value": "22.85"
},
"final_capture": true,
"seller_protection": {
"status": "ELIGIBLE",
"dispute_categories": ["ITEM_NOT_RECEIVED", "UNAUTHORIZED_TRANSACTION"]
},
"seller_receivable_breakdown": {
"gross_amount": {
"currency_code": "EUR",
"value": "22.85"
},
"paypal_fee": {
"currency_code": "EUR",
"value": "0.92"
},
"net_amount": {
"currency_code": "EUR",
"value": "21.93"
}
},
"custom_id": "11111111-2222-4333-8444-555555555555",
"invoice_id": "R-12-4711",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/3C679366HH908993F",
"rel": "self",
"method": "GET"
},
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/3C679366HH908993F/refund",
"rel": "refund",
"method": "POST"
},
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
"rel": "up",
"method": "GET"
}
],
"create_time": "2026-10-08T19:13:02Z",
"update_time": "2026-10-08T19:13:02Z"
}
]
}
}
],
"payer": {
"name": {
"given_name": "Erika",
"surname": "Mustermann"
},
"email_address": "erika.buyer@example.org",
"payer_id": "QYR5Z8XDVJNXQ",
"address": {
"country_code": "DE"
}
},
"create_time": "2026-10-08T19:12:40Z",
"update_time": "2026-10-08T19:13:02Z",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
"rel": "self",
"method": "GET"
}
]
}
+254
View File
@@ -0,0 +1,254 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { orderTotalCents, parseCents, verifyApprovedOrder, verifyCompletedCapture } from "../../src/checkout/paypal-verify.ts";
import { buildPayPalOrderRequest } from "../../src/checkout/paypal-order.ts";
const UUID = "11111111-2222-4333-8444-555555555555";
// PayPal's Orders v2 JSON as PayPalApi parses the response body: the PayPal order once the buyer approved it
// (GET /v2/checkout/orders/:id), and the PayPal order after its capture (POST /v2/checkout/orders/:id/capture with
// Prefer: return=representation). Both belong to the order below. Every call returns a fresh copy to change.
const fixture = (name: string) => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8"));
const approved = () => fixture("paypal-order-approved.json");
const captured = () => fixture("paypal-order-captured.json");
// The order of paypal-order.test.ts: subtotal 17.90, delivery 4.95, total 22.85.
const order = (overrides = {}) => ({
id: 4711,
uuid: UUID,
email: "erika@example.org",
subtotal: 17.9,
total: 22.85,
VAT: 2.86,
delivery: { id: 1, name: "DHL", price: 4.95 },
invoiceNumber: "R-12-4711",
deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
...overrides
});
// What identifies the buyer or the merchant in the fixtures. A reason is logged, so it repeats none of it.
const PERSONAL_DATA = [
"Erika",
"Mustermann",
"erika",
"QYR5Z8XDVJNXQ",
"Musterstraße",
"70190",
"Stuttgart",
"merchant@example.com",
"7KNGBPH2U58GQ"
];
/** The reason of a rejection, after checking that it repeats no personal data. */
const rejection = (result: { ok: boolean; reason?: string }): string => {
assert.equal(result.ok, false, "expected a rejection");
for (const value of PERSONAL_DATA) assert.ok(!result.reason.includes(value), `the reason repeats ${value}: ${result.reason}`);
return result.reason;
};
// Money
test("parses PayPal's money values into integer cents", () => {
assert.equal(parseCents("22.85"), 2285);
assert.equal(parseCents("0.01"), 1);
assert.equal(parseCents("4.9"), 490);
assert.equal(parseCents("100"), 10000);
assert.equal(parseCents("0.00"), 0);
assert.equal(parseCents("1234567.89"), 123456789);
});
test("parses nothing but a non-negative amount with at most two decimals", () => {
for (const value of ["", "22.850", "-22.85", "22,85", " 22.85", "22.85 ", "2.285e1", "22.", ".85", "0x10", "Infinity", "NaN", "12345678901.00"]) {
assert.equal(parseCents(value), null, JSON.stringify(value));
}
for (const value of [22.85, null, undefined, {}, ["22.85"]]) {
assert.equal(parseCents(value), null, String(value));
}
});
test("turns the order total into integer cents, also where its float is a hair below the cent", () => {
// 22.85 * 100 is 2284.9999999999995 and 0.29 * 100 is 28.999999999999996 in floating point
assert.equal(orderTotalCents({ total: 22.85 }), 2285);
assert.equal(orderTotalCents({ total: 0.29 }), 29);
assert.equal(orderTotalCents({ total: 17.9 + 4.95 }), 2285);
for (const total of [null, undefined, 0, -4.95, NaN, Infinity]) {
assert.equal(orderTotalCents({ total }), null, String(total));
}
});
test("the amount the checkout sends PayPal is the amount the capture accepts, for every total up to 3000.00", () => {
const mismatches: number[] = [];
for (let cents = 1; cents <= 300000; cents++) {
const total = cents / 100;
// buildPayPalOrderRequest sends order.total.toFixed(2); the order service rounds every total to the cent
if (parseCents(total.toFixed(2)) !== cents || orderTotalCents({ total }) !== cents) mismatches.push(total);
}
assert.equal(mismatches.length, 0, `e.g. ${mismatches.slice(0, 5).join(", ")}`);
});
test("the fixtures belong to the PayPal order the checkout requests for the order", () => {
const request = buildPayPalOrderRequest("https://shop.example/checkout/3", order()).purchaseUnits[0];
for (const paypalOrder of [approved(), captured()]) {
const unit = paypalOrder.purchase_units[0];
assert.equal(unit.custom_id, request.customId);
assert.equal(unit.amount.value, request.amount.value);
assert.equal(unit.amount.currency_code, request.amount.currencyCode);
}
});
// The PayPal order the buyer approved
test("accepts the PayPal order the buyer approved for the order", () => {
assert.deepEqual(verifyApprovedOrder(order(), approved()), { ok: true });
});
test("rejects a PayPal order that is not approved", () => {
for (const status of ["CREATED", "SAVED", "PAYER_ACTION_REQUIRED", "VOIDED", "COMPLETED"]) {
assert.equal(rejection(verifyApprovedOrder(order(), { ...approved(), status })), `the PayPal order status is ${status}, not APPROVED`);
}
const { status, ...withoutStatus } = approved();
assert.equal(rejection(verifyApprovedOrder(order(), withoutStatus)), "the PayPal order status is missing or unexpected, not APPROVED");
// a value that is not one of PayPal's statuses is not repeated
assert.equal(
rejection(verifyApprovedOrder(order(), { ...approved(), status: "APPROVED BY ERIKA MUSTERMANN" })),
"the PayPal order status is missing or unexpected, not APPROVED"
);
});
test("rejects the PayPal order of a cheap order captured for an expensive one", () => {
// The buyer pays order A, then calls the capture of order B with A's PayPal order.
const orderB = order({ id: 4712, uuid: "99999999-8888-4777-8666-555555555555", total: 249 });
assert.equal(rejection(verifyApprovedOrder(orderB, approved())), "the custom_id of the PayPal order is not the order's uuid");
});
test("rejects a PayPal order without a custom_id, also for an order without a uuid", () => {
const paypalOrder = approved();
delete paypalOrder.purchase_units[0].custom_id;
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the custom_id of the PayPal order is not the order's uuid");
// undefined is not compared with undefined, nor "" with ""
assert.equal(
rejection(verifyApprovedOrder(order({ uuid: undefined }), paypalOrder)),
"the custom_id of the PayPal order is not the order's uuid"
);
paypalOrder.purchase_units[0].custom_id = "";
assert.equal(rejection(verifyApprovedOrder(order({ uuid: "" }), paypalOrder)), "the custom_id of the PayPal order is not the order's uuid");
});
test("rejects a PayPal order whose amount is not the order total", () => {
const withAmount = (value: unknown) => {
const paypalOrder = approved();
paypalOrder.purchase_units[0].amount.value = value;
return paypalOrder;
};
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("22.84"))), "the PayPal order amount 22.84 is not the order total 22.85");
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("22.86"))), "the PayPal order amount 22.86 is not the order total 22.85");
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("2285"))), "the PayPal order amount 2285.00 is not the order total 22.85");
// the cart changed after the checkout
assert.equal(rejection(verifyApprovedOrder(order({ total: 249 }), approved())), "the PayPal order amount 22.85 is not the order total 249.00");
for (const value of ["22.850", "22,85", "", 22.85, null]) {
assert.equal(rejection(verifyApprovedOrder(order(), withAmount(value))), "the PayPal order amount is not a money value", String(value));
}
});
test("rejects a PayPal order in another currency or without an amount", () => {
const paypalOrder = approved();
paypalOrder.purchase_units[0].amount.currency_code = "USD";
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order currency is USD, not EUR");
paypalOrder.purchase_units[0].amount.currency_code = "Euro";
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order currency is missing or unexpected, not EUR");
delete paypalOrder.purchase_units[0].amount;
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order has no amount");
});
test("rejects every PayPal order for an order without a total", () => {
for (const total of [null, 0]) {
assert.equal(rejection(verifyApprovedOrder(order({ total }), approved())), "the order has no total");
}
});
test("rejects a PayPal order without a purchase unit, and anything that is not a PayPal order", () => {
assert.equal(rejection(verifyApprovedOrder(order(), { ...approved(), purchase_units: [] })), "the PayPal order has no purchase unit");
const { purchase_units, ...withoutUnits } = approved();
assert.equal(rejection(verifyApprovedOrder(order(), withoutUnits)), "the PayPal order has no purchase unit");
// "/result/error" is what PayPalApi.checkPaymentStatus answered for every error
for (const value of [null, undefined, "/result/error", [], 42]) {
assert.equal(rejection(verifyApprovedOrder(order(), value)), "the PayPal order is not an object", String(value));
}
});
// The PayPal order after the capture
test("accepts the completed capture of the order total and returns the capture id", () => {
assert.deepEqual(verifyCompletedCapture(order(), captured()), { ok: true, captureId: "3C679366HH908993F" });
});
test("rejects a capture response whose PayPal order is not completed", () => {
for (const status of ["APPROVED", "PAYER_ACTION_REQUIRED", "VOIDED"]) {
assert.equal(rejection(verifyCompletedCapture(order(), { ...captured(), status })), `the PayPal order status is ${status}, not COMPLETED`);
}
});
test("rejects a capture PayPal holds for review, declined or refunded", () => {
for (const status of ["PENDING", "DECLINED", "FAILED", "REFUNDED", "PARTIALLY_REFUNDED"]) {
const response = captured();
response.purchase_units[0].payments.captures[0].status = status;
if (status === "PENDING") response.purchase_units[0].payments.captures[0].status_details = { reason: "PENDING_REVIEW" };
assert.equal(rejection(verifyCompletedCapture(order(), response)), `the capture status is ${status}, not COMPLETED`);
}
});
test("rejects a capture response without a capture", () => {
// what PayPal answers with Prefer: return=minimal, which the capture used to ask for
const minimal = { id: "5O190127TN364715T", status: "COMPLETED", links: captured().links };
assert.equal(rejection(verifyCompletedCapture(order(), minimal)), "the capture response has no capture");
const withoutPayments = captured();
delete withoutPayments.purchase_units[0].payments;
assert.equal(rejection(verifyCompletedCapture(order(), withoutPayments)), "the capture response has no capture");
const withoutCaptures = captured();
withoutCaptures.purchase_units[0].payments.captures = [];
assert.equal(rejection(verifyCompletedCapture(order(), withoutCaptures)), "the capture response has no capture");
assert.equal(rejection(verifyCompletedCapture(order(), { ...captured(), purchase_units: [] })), "the capture response has no capture");
});
test("rejects a capture of another amount or in another currency", () => {
const lower = captured();
lower.purchase_units[0].payments.captures[0].amount.value = "2.29";
assert.equal(rejection(verifyCompletedCapture(order(), lower)), "the capture amount 2.29 is not the order total 22.85");
// the cart changed during the capture
assert.equal(rejection(verifyCompletedCapture(order({ total: 249 }), captured())), "the capture amount 22.85 is not the order total 249.00");
const usd = captured();
usd.purchase_units[0].payments.captures[0].amount.currency_code = "USD";
assert.equal(rejection(verifyCompletedCapture(order(), usd)), "the capture currency is USD, not EUR");
const withoutAmount = captured();
delete withoutAmount.purchase_units[0].payments.captures[0].amount;
assert.equal(rejection(verifyCompletedCapture(order(), withoutAmount)), "the capture has no amount");
});
test("rejects a capture without a valid id", () => {
for (const id of [undefined, "", 12345, "3C679366HH908993F; Erika Mustermann", "x".repeat(65)]) {
const response = captured();
response.purchase_units[0].payments.captures[0].id = id;
assert.equal(rejection(verifyCompletedCapture(order(), response)), "the capture has no valid id", String(id));
}
});
test("rejects anything that is not a capture response", () => {
for (const value of [null, undefined, "/result/error", [], 42]) {
assert.equal(rejection(verifyCompletedCapture(order(), value)), "the capture response is not an object", String(value));
}
});