buildPayPalOrderRequest split the text delivery address at fixed offsets and called toFixed on the delivery price, and the items that were computed but never sent read the product of every cart line. A missing or two-line delivery address, a delivery method whose price is null, such as a pickup, and a cart line whose product was deleted each threw a TypeError, which the checkout answered 502 (D1 to D4). The request is now built in whole cents, with the shipping address from the first one entered of the structured delivery address, the structured invoice address and the text delivery address: - fullName is givenName and familyName, addressLine1 streetAddress, adminArea2 addressLevel2, countryCode the country upper-cased or DE; a structured address that is entered but incomplete is rejected instead of replaced by another address; - a text address needs three lines: the name, the street, and "<postal code> <city>" split at the first whitespace; lines between the name and the street become addressLine2; - a delivery price that is null counts as 0; - itemTotal is the net of the subtotal, round(subtotal cents * 100 / 119), taxTotal the order's VAT. The breakdown must add up to the total to the cent, as PayPal requires, so totals computed before a price changed are rejected here instead of by PayPal. The items are gone. With items, PayPal requires item_total and tax_total to be the sums of the amounts rounded per unit, which miss the net and the VAT of the subtotal that the invoice states: 3 x 12.50 gives 31.50 and 6.00, the subtotal 31.51 and 5.99. An empty cart, a missing total or VAT, totals that do not add up and a missing or unusable address throw CheckoutNotReadyError, which the checkout answers 400 "Order is not ready for checkout" with the reason. A deleted product throws ProductUnavailableError, answered 409 "A product in the cart is no longer available". Both are thrown before PayPal is asked and are logged as warnings; other errors stay 502. Tests: D1 to D4 are regular tests. The test of an order without a total expects CheckoutNotReadyError instead of a plain Error, and the item test is replaced by one that shows why no items are sent. The order of paypal-verify.test.ts gains the cart line the builder now requires, and its amount test builds the request for every total up to 3000.00 instead of assuming toFixed(2). Refs libretech/mp#71 Refs libretech/mp#67
261 lines
13 KiB
TypeScript
261 lines
13 KiB
TypeScript
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));
|
|
}
|
|
});
|