The order service adds the price of the order's payment method to the total, but the PayPal breakdown left it out, so an order with a surcharge was sent an amount its breakdown did not add up to, which PayPal rejects (D5). Since the breakdown is now checked against the total, such an order was answered 400. A payment price above 0 is now sent as handling, in whole cents, and item total, tax, shipping and handling add up to the amount to the cent. A payment method that costs nothing sends no handling. Every payment method and the pickup cost 0.00 today, and the shop cannot set a payment method (parseCustomerUpdate rejects the field), so this is robustness, not a live defect. The D5 test is a regular test, and a test checks the sum with a surcharge, also with a pickup without a price, for every whole-cent subtotal up to 300.00. Refs libretech/mp#71 Refs libretech/mp#67
408 lines
18 KiB
TypeScript
408 lines
18 KiB
TypeScript
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" });
|
||
}
|
||
});
|