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