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)); } });