feat(checkout): verify the approved PayPal order and its capture against the order
verifyApprovedOrder accepts PayPal's order only if it is APPROVED, its first purchase unit's custom_id is the order's uuid, and its amount is the order total in EUR. verifyCompletedCapture accepts the order after the capture only if it and its first capture are COMPLETED for the order total in EUR, and returns the capture id. Both read PayPal's Orders v2 JSON in snake_case, as PayPalApi parses the response body. Money is compared in integer cents: PayPal's "12.34" is parsed strictly, the order total is Math.round(total * 100). For every total up to 3000.00 the amount the checkout sends (total.toFixed(2)) parses to the cents the capture compares. A rejection reason repeats only PayPal's statuses, currency codes and amounts, never the payer's or the shipping data of the response. Refs libretech/mp#71
This commit is contained in:
4 files changed
+577
No files matched your search
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user