Checkout: only customer fields on order updates, verified PayPal capture, safe logging (#6)
build / test (push) Successful in 11s
build / build (push) Successful in 8m31s

Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #6.
This commit is contained in:
libretech authored and Michael Czechowski committed 2026-10-15 10:23:02 +02:00
1 parent 9b0c6af8b7
commit 5002cac469
24 files changed
+2911 -313

No files matched your search

+260
View File
@@ -0,0 +1,260 @@
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",
cart: [
{ id: 1, count: 1, product: { id: 101, name: "Notizbuch A5 Punktraster", cover: { price: 14.9 }, pages: { price: 3 }, ruling: { price: 0 } } }
],
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++) {
// The order service rounds every total to the cent; an order without a delivery method has its subtotal as its total.
const total = cents / 100;
const VAT = Math.round((total / 1.19) * 0.19 * 100) / 100;
const request = buildPayPalOrderRequest("https://shop.example/checkout/3", order({ subtotal: total, total, VAT, delivery: null }));
const { amount } = request.purchaseUnits[0];
if (parseCents(amount.value) !== 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));
}
});