Files
cms/tests/unit/paypal-order.test.ts
T
libretech 9e94d69e2e fix(checkout): reject an order PayPal cannot take with a typed error
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
2026-10-09 01:48:34 +02:00

373 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
CheckoutNotReadyError,
ProductUnavailableError,
PRODUCT_UNAVAILABLE,
buildPayPalOrderRequest,
checkoutFailure
} from "../../src/checkout/paypal-order.ts";
import { calculateTotalProductPrice } from "../../src/checkout/pricing.ts";
import { vatDecimal, vatIncludedDecimal } from "../../src/checkout/vat.ts";
const RETURN_URL = "https://shop.example/checkout/3";
const UUID = "11111111-2222-4333-8444-555555555555";
const notebook = { id: 101, name: "Notizbuch A5 Punktraster", cover: { price: 14.9 }, pages: { price: 3 }, ruling: { price: 0 } };
// Shaped like the order the checkout controller passes to createSessionOrThrow (strapi.db.query findOne with orderDefaultParams),
// with the totals the order service computes: subtotal 17.90, total 17.90 + 4.95 = 22.85, VAT round(17.90 / 1.19 * 0.19) = 2.86.
const validOrder = (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 },
payment: null,
invoiceNumber: "R-12-4711",
cart: [{ id: 1, count: 1, product: notebook }],
deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
...overrides
});
// A cart of [price, count] lines with the totals the order service computes in update() (src/api/order/services/order.ts:151-173).
const orderFor = (lines: [number, number][], deliveryPrice: number | null) => {
const cart = lines.map(([price, count], index) => ({
id: index + 1,
count,
product: { id: 201 + index, name: `Produkt ${index + 1}`, cover: { price } }
}));
const subtotal = cart.reduce((v, p) => v + (calculateTotalProductPrice(p.product) ?? 0) * p.count, 0);
return validOrder({
cart,
subtotal,
total: Math.round((subtotal + (deliveryPrice ?? 0)) * 100) / 100,
VAT: Math.round((subtotal / vatIncludedDecimal) * vatDecimal * 100) / 100,
delivery: deliveryPrice === null ? null : { id: 1, name: "DHL", price: deliveryPrice }
});
};
// What createSessionOrThrow would send to PayPal, or what it would throw before asking PayPal.
const buildSession = (order) => buildPayPalOrderRequest(RETURN_URL, order);
const cents = (money) => (money ? Math.round(Number(money.value) * 100) : 0);
// PayPal rejects an order unless amount = item_total + tax_total + shipping + handling + insurance - shipping_discount - discount.
const breakdownCents = (breakdown) =>
cents(breakdown.itemTotal) +
cents(breakdown.taxTotal) +
cents(breakdown.shipping) +
cents(breakdown.handling) +
cents(breakdown.insurance) -
cents(breakdown.shippingDiscount) -
cents(breakdown.discount);
const isClearError = (pattern: RegExp) => (error: unknown) => error instanceof Error && !(error instanceof TypeError) && pattern.test(error.message);
const assertNoTypeError = (build: () => unknown) => {
try {
build();
} catch (error) {
assert.ok(!(error instanceof TypeError), `threw ${error}`);
}
};
const thrown = (build: () => unknown): unknown => {
try {
build();
} catch (error) {
return error;
}
assert.fail("did not throw");
};
// Characterization: what the builders do today.
test("builds the PayPal order request for a valid order", () => {
const expected = {
intent: "CAPTURE",
purchaseUnits: [
{
referenceId: UUID,
amount: {
currencyCode: "EUR",
value: "22.85",
breakdown: {
itemTotal: { currencyCode: "EUR", value: "15.04" },
taxTotal: { currencyCode: "EUR", value: "2.86" },
shipping: { currencyCode: "EUR", value: "4.95" }
}
},
shipping: {
name: { fullName: "Erika Mustermann" },
address: { addressLine1: "Musterstraße 1", postalCode: "70190", adminArea2: "Stuttgart", countryCode: "DE" }
},
customId: UUID,
invoiceId: "R-12-4711"
}
],
applicationContext: { returnUrl: RETURN_URL, cancelUrl: RETURN_URL, shippingPreference: "SET_PROVIDED_ADDRESS" }
};
const request = buildPayPalOrderRequest(RETURN_URL, validOrder());
assert.deepEqual(request, expected);
// The key order is visible in the "Creating PayPal order" log line.
assert.equal(JSON.stringify(request), JSON.stringify(expected));
});
test("sends no shipping amount when the order has no delivery method", () => {
assert.deepEqual(buildPayPalOrderRequest(RETURN_URL, validOrder({ delivery: null, total: 17.9 })).purchaseUnits[0].amount, {
currencyCode: "EUR",
value: "17.90",
breakdown: {
itemTotal: { currencyCode: "EUR", value: "15.04" },
taxTotal: { currencyCode: "EUR", value: "2.86" },
shipping: undefined
}
});
});
// The request used to be built after a list of items that was never sent; computing it threw "Cart is empty or has no total".
// That check now throws CheckoutNotReadyError, which the checkout answers 400 instead of 502.
test("rejects an empty cart or an order without a total as not ready for checkout", () => {
for (const overrides of [{ total: 0 }, { total: null }, { total: undefined }, { cart: [] }, { cart: null }]) {
assert.throws(() => buildSession(validOrder(overrides)), { name: "CheckoutNotReadyError", message: "Cart is empty or has no total" });
}
});
test("sends no items: amounts rounded per unit would miss the net and the VAT of the subtotal", () => {
// 3 × 12.50: per unit 10.50 net and 2.00 tax, 31.50 and 6.00 for the three; the subtotal of 37.50 has 31.51 net and 5.99 VAT.
const unitNetCents = Math.round((1250 * 100) / 119);
const unit = buildSession(orderFor([[12.5, 3]], 4.95)).purchaseUnits[0];
assert.equal("items" in unit, false);
assert.deepEqual([3 * unitNetCents, 3 * (1250 - unitNetCents)], [3150, 600]);
assert.deepEqual(unit.amount, {
currencyCode: "EUR",
value: "42.45",
breakdown: {
itemTotal: { currencyCode: "EUR", value: "31.51" },
taxTotal: { currencyCode: "EUR", value: "5.99" },
shipping: { currencyCode: "EUR", value: "4.95" }
}
});
});
// Fixed defects of https://git.librete.ch/libretech/mp/issues/67.
test("D1: a missing delivery address is rejected with a clear validation error", () => {
assert.throws(() => buildSession(validOrder({ deliveryAddress: null })), isClearError(/address/i));
});
test("D2: a two-line delivery address does not throw a TypeError", () => {
assertNoTypeError(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })));
});
test("D3: a pickup whose delivery price is null is sent with shipping 0.00", () => {
const order = validOrder({ delivery: { id: 2, name: "Abholung", price: null }, total: 17.9 });
assert.deepEqual(buildSession(order).purchaseUnits[0].amount.breakdown.shipping, { currencyCode: "EUR", value: "0.00" });
});
test("D4: a cart line whose product was deleted is rejected with a clear error", () => {
assert.throws(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })), isClearError(/product/i));
});
test(
"D5: with a payment surcharge the breakdown still adds up to the amount",
{ todo: "the surcharge is in the amount but not in the breakdown — https://git.librete.ch/libretech/mp/issues/67" },
() => {
// total = 17.90 + 4.95 shipping + 1.50 surcharge
const { amount } = buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })).purchaseUnits[0];
assert.equal(amount.value, "24.35");
assert.equal(breakdownCents(amount.breakdown), cents(amount));
}
);
// This held before the fixes too, for every whole-cent subtotal (Strapi stores prices as decimal(10,2)). It guards the fixes above.
test("D6: the breakdown adds up to the amount to the cent, also for the subtotals whose net is closest to half a cent", () => {
const carts: { lines: [number, number][]; delivery: number | null }[] = [
{ lines: [[17.9, 1]], delivery: 4.95 },
// subtotal 36.10000000000001 in floating point
{
lines: [
[4.15, 7],
[2.35, 3]
],
delivery: 4.95
},
// 29.03: net 24.394958, as close to a half cent as a whole-cent subtotal gets
{
lines: [
[9.99, 2],
[9.05, 1]
],
delivery: 4.95
},
// 41.18: net 34.605042, the same from above; no delivery method
{ lines: [[20.59, 2]], delivery: null },
{ lines: [[5.23, 1]], delivery: 4.95 },
{ lines: [[5.48, 1]], delivery: 4.95 }
];
for (let cent = 1; cent <= 30000; cent++) {
carts.push({ lines: [[cent / 100, 1]], delivery: 4.95 }, { lines: [[cent / 100, 1]], delivery: null });
}
const mismatches = carts.filter(({ lines, delivery }) => {
const { amount } = buildSession(orderFor(lines, delivery)).purchaseUnits[0];
return breakdownCents(amount.breakdown) !== cents(amount);
});
// Compare the count, not the array: diffing tens of thousands of entries against [] takes many GB of memory.
assert.equal(mismatches.length, 0, `${mismatches.length} carts do not add up, e.g. ${JSON.stringify(mismatches.slice(0, 3))}`);
});
// Shipping address: the structured delivery address, else the structured invoice address, else the text delivery address.
const structured = (overrides = {}) => ({
givenName: "Erika",
familyName: "Mustermann",
streetAddress: "Musterstraße 1",
postalCode: "70190",
addressLevel2: "Stuttgart",
country: "DE",
...overrides
});
const berlin = structured({ givenName: "Max", streetAddress: "Hauptstraße 5", postalCode: "10115", addressLevel2: "Berlin" });
const berlinShipping = {
name: { fullName: "Max Mustermann" },
address: { addressLine1: "Hauptstraße 5", postalCode: "10115", adminArea2: "Berlin", countryCode: "DE" }
};
// An address.structured-address component that was saved without components: Strapi fills in the default country.
const emptyStructured = { givenName: null, familyName: null, streetAddress: null, postalCode: null, addressLevel2: null, country: "DE" };
const shippingOf = (order) => buildSession(order).purchaseUnits[0].shipping;
test("takes the shipping address from the structured delivery address before the invoice and the text address", () => {
assert.deepEqual(shippingOf(validOrder({ deliveryAddressStructured: berlin, invoiceAddressStructured: structured() })), berlinShipping);
});
test("falls back to the structured invoice address when no structured delivery address is entered", () => {
for (const deliveryAddressStructured of [null, undefined, emptyStructured, { givenName: " ", country: "DE" }]) {
assert.deepEqual(
shippingOf(validOrder({ deliveryAddressStructured, invoiceAddressStructured: berlin, deliveryAddress: null })),
berlinShipping
);
}
});
test("trims the components, names the buyer by the name parts there are and upper-cases the country, DE by default", () => {
const order = (address) => validOrder({ deliveryAddressStructured: { ...structured(), ...address } });
assert.deepEqual(shippingOf(order({ givenName: " Erika ", familyName: null, streetAddress: " Musterstraße 1 ", country: "at" })), {
name: { fullName: "Erika" },
address: { addressLine1: "Musterstraße 1", postalCode: "70190", adminArea2: "Stuttgart", countryCode: "AT" }
});
for (const country of [null, undefined, ""]) {
assert.equal(shippingOf(order({ country })).address.countryCode, "DE");
}
});
test("rejects a structured address that is entered but incomplete instead of shipping to another address", () => {
const order = validOrder({ deliveryAddressStructured: structured({ streetAddress: "", postalCode: null }), invoiceAddressStructured: berlin });
assert.throws(() => buildSession(order), { name: "CheckoutNotReadyError", message: "deliveryAddressStructured lacks streetAddress, postalCode" });
assert.throws(() => buildSession(validOrder({ invoiceAddressStructured: structured({ givenName: null, familyName: "" }) })), {
name: "CheckoutNotReadyError",
message: "invoiceAddressStructured lacks givenName and familyName"
});
assert.throws(() => buildSession(validOrder({ deliveryAddressStructured: structured({ country: "Deutschland" }) })), {
name: "CheckoutNotReadyError",
message: "deliveryAddressStructured has no two-letter country code"
});
});
test("parses the text delivery address when no structured address is entered", () => {
const shippingFor = (deliveryAddress) => shippingOf(validOrder({ deliveryAddress, deliveryAddressStructured: emptyStructured }));
assert.deepEqual(shippingFor(" Erika Mustermann\r\nMusterstraße 1\r\n\r\n70190 Stuttgart-Bad Cannstatt \n"), {
name: { fullName: "Erika Mustermann" },
address: { addressLine1: "Musterstraße 1", postalCode: "70190", adminArea2: "Stuttgart-Bad Cannstatt", countryCode: "DE" }
});
// The street is the line above the postal code and city, as in a German address; the lines after the name go into addressLine2.
assert.deepEqual(shippingFor("Erika Mustermann\nc/o Muster GmbH\nHinterhaus\nMusterstraße 1\n70190 Stuttgart"), {
name: { fullName: "Erika Mustermann" },
address: {
addressLine1: "Musterstraße 1",
addressLine2: "c/o Muster GmbH, Hinterhaus",
postalCode: "70190",
adminArea2: "Stuttgart",
countryCode: "DE"
}
});
});
test("rejects a text delivery address it cannot parse, and a missing one, with a validation error", () => {
const cases: [unknown, string][] = [
["Erika Mustermann\nMusterstraße 1", "deliveryAddress needs the name, the street, and the postal code and city on lines of their own"],
["Erika Mustermann\n\nMusterstraße 1\n", "deliveryAddress needs the name, the street, and the postal code and city on lines of their own"],
["Erika Mustermann\nMusterstraße 1\n70190", "The last line of deliveryAddress is not the postal code and the city"],
[" \n ", "The order has no delivery address"],
["", "The order has no delivery address"],
[null, "The order has no delivery address"],
[undefined, "The order has no delivery address"]
];
for (const [deliveryAddress, message] of cases) {
assert.throws(() => buildSession(validOrder({ deliveryAddress })), { name: "CheckoutNotReadyError", message });
}
});
// Money in whole cents, and the errors the checkout answers.
test("reads decimals that arrive as strings like numbers", () => {
const order = validOrder({ subtotal: "17.90", total: "22.85", VAT: "2.86", delivery: { id: 1, name: "DHL", price: "4.95" } });
assert.deepEqual(buildSession(order), buildSession(validOrder()));
});
test("rejects totals that do not add up, as after a price change, instead of sending PayPal a breakdown it rejects", () => {
// The delivery method costs 5.95 now; the order's total was computed when it cost 4.95.
assert.throws(() => buildSession(validOrder({ delivery: { id: 1, name: "DHL", price: 5.95 } })), {
name: "CheckoutNotReadyError",
message: "The order's totals do not add up: total 2285 cents, items 1504 + VAT 286 + shipping 595"
});
assert.throws(() => buildSession(validOrder({ delivery: { id: 1, name: "DHL", price: -4.95 }, total: 12.95 })), {
name: "CheckoutNotReadyError",
message: "The order's totals do not add up: total 1295 cents, items 1504 + VAT 286 + shipping -495"
});
for (const overrides of [{ VAT: null }, { subtotal: undefined }, { VAT: "n/a" }]) {
assert.throws(() => buildSession(validOrder(overrides)), { name: "CheckoutNotReadyError", message: "The order has no subtotal or no VAT" });
}
});
test("rejects a deleted product next to available ones as unavailable", () => {
const order = validOrder({ cart: [{ id: 1, count: 1, product: notebook }, { id: 2, count: 1, product: null }, null] });
assert.throws(
() => buildSession(order),
(error) => error instanceof ProductUnavailableError && error.name === "ProductUnavailableError" && error.message === PRODUCT_UNAVAILABLE
);
});
test("answers an order that is not ready 400 with the reason, a deleted product 409 and any other error 502", () => {
assert.deepEqual(checkoutFailure(thrown(() => buildSession(validOrder({ deliveryAddress: null })))), {
status: 400,
message: "Order is not ready for checkout",
reason: "The order has no delivery address"
});
assert.equal(checkoutFailure(thrown(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })))).status, 400);
assert.equal(checkoutFailure(thrown(() => buildSession(validOrder({ total: 0 })))).status, 400);
assert.deepEqual(checkoutFailure(thrown(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })))), {
status: 409,
message: "A product in the cart is no longer available"
});
assert.ok(thrown(() => buildSession(validOrder({ total: 0 }))) instanceof CheckoutNotReadyError);
const sdkError = Object.assign(new Error("OAuth 2 provider says client authentication failed."), { statusCode: 401 });
for (const error of [sdkError, new TypeError("Cannot read properties of null (reading 'split')"), "/result/error", undefined]) {
assert.deepEqual(checkoutFailure(error), { status: 502, message: "Could not create the PayPal order" });
}
});