Files
cms/tests/unit/paypal-order.test.ts
T
libretech 5002cac469
build / test (push) Successful in 11s
build / build (push) Successful in 8m31s
Checkout: only customer fields on order updates, verified PayPal capture, safe logging (#6)
Co-authored-by: Michael Czechowski <mail@librete.ch>
2026-10-15 10:23:02 +02:00

408 lines
18 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, paymentPrice: number | null = 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) + (paymentPrice ?? 0)) * 100) / 100,
VAT: Math.round((subtotal / vatIncludedDecimal) * vatDecimal * 100) / 100,
delivery: deliveryPrice === null ? null : { id: 1, name: "DHL", price: deliveryPrice },
payment: paymentPrice === null ? null : { id: 1, name: "Vorkasse", price: paymentPrice }
});
};
// 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", () => {
// 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 + surcharge 0"
});
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 + surcharge 0"
});
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("sends a payment surcharge as handling, and no handling for a payment method that costs nothing", () => {
const breakdownOf = (order) => buildSession(order).purchaseUnits[0].amount.breakdown;
assert.deepEqual(breakdownOf(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })), {
itemTotal: { currencyCode: "EUR", value: "15.04" },
taxTotal: { currencyCode: "EUR", value: "2.86" },
shipping: { currencyCode: "EUR", value: "4.95" },
handling: { currencyCode: "EUR", value: "1.50" }
});
for (const payment of [null, undefined, { id: 1, name: "PayPal", price: 0 }, { id: 1, name: "PayPal", price: null }]) {
assert.equal("handling" in breakdownOf(validOrder({ payment })), false);
}
// A surcharge the total lacks, as when the price of the payment method changed after the totals were computed.
assert.throws(() => buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 } })), {
name: "CheckoutNotReadyError",
message: "The order's totals do not add up: total 2285 cents, items 1504 + VAT 286 + shipping 495 + surcharge 150"
});
});
test("adds up to the cent with a surcharge, with a pickup without a price, for every whole-cent subtotal up to 300.00", () => {
const mismatches: string[] = [];
for (let cent = 1; cent <= 30000; cent++) {
for (const [delivery, payment] of [
[4.95, 0.35],
[0, 1.5]
]) {
const order = orderFor([[cent / 100, 1]], delivery, payment);
if (delivery === 0) order.delivery = { id: 2, name: "Abholung", price: null };
const { amount } = buildSession(order).purchaseUnits[0];
if (breakdownCents(amount.breakdown) !== cents(amount) || cents(amount.breakdown.handling) !== Math.round(payment * 100)) {
mismatches.push(`${cent}/${delivery}/${payment}`);
}
}
}
assert.equal(mismatches.length, 0, `${mismatches.length} carts do not add up, e.g. ${mismatches.slice(0, 3).join(", ")}`);
});
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" });
}
});