- src/checkout/ (PayPal order request, pricing, VAT) and src/logging/format.ts hold the checkout's pure code, moved out of PayPalApi, the product service and config without a behaviour change. - npm test runs node:test on the TypeScript sources (type stripping), with no dependencies: 21 tests, 13 pin today's output, 8 describe the correct behaviour for the known defects of libretech/mp#67 and are marked todo. - CI: the tests run in the image's base (node:22.14.0-alpine) for every pull request, push to main and release tag. The image is built for pull requests and built and published only for v* tags; main no longer publishes :main and :sha-* images. Refs libretech/mp#71 Co-authored-by: Michael Czechowski <mail@librete.ch>
235 lines
8.6 KiB
TypeScript
235 lines
8.6 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { buildPayPalOrderItems, buildPayPalOrderRequest } 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 }
|
|
});
|
|
};
|
|
|
|
// Both halves in the order createSessionOrThrow runs them: what it would send to PayPal, or what it would throw.
|
|
const buildSession = (order) => {
|
|
buildPayPalOrderItems(order);
|
|
return 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}`);
|
|
}
|
|
};
|
|
|
|
// 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
|
|
}
|
|
});
|
|
});
|
|
|
|
test("rejects an order without a total before computing the items", () => {
|
|
for (const total of [0, null, undefined]) {
|
|
assert.throws(() => buildPayPalOrderItems(validOrder({ total })), { name: "Error", message: "Cart is empty or has no total" });
|
|
}
|
|
});
|
|
|
|
test("computes one item per cart line, which is not sent to PayPal", () => {
|
|
const order = validOrder({
|
|
cart: [
|
|
{ id: 1, count: 1, product: notebook },
|
|
{ id: 2, count: 3, product: { id: 102, name: "Journaling Booklet", cover: { price: 12.5 } } }
|
|
]
|
|
});
|
|
|
|
assert.deepEqual(buildPayPalOrderItems(order), [
|
|
{
|
|
name: "Notizbuch A5 Punktraster",
|
|
unitAmount: { currencyCode: "EUR", value: "15.04" },
|
|
tax: { currencyCode: "EUR", value: "2.86" },
|
|
quantity: "1"
|
|
},
|
|
{
|
|
name: "Journaling Booklet",
|
|
unitAmount: { currencyCode: "EUR", value: "10.50" },
|
|
tax: { currencyCode: "EUR", value: "2.00" },
|
|
quantity: "3"
|
|
}
|
|
]);
|
|
});
|
|
|
|
// Known defects: these assert the correct behaviour and fail today, which the todo marks as expected.
|
|
|
|
test(
|
|
"D1: a missing delivery address is rejected with a clear validation error",
|
|
{ todo: "null deliveryAddress throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
|
|
() => {
|
|
assert.throws(() => buildSession(validOrder({ deliveryAddress: null })), isClearError(/address/i));
|
|
}
|
|
);
|
|
|
|
test(
|
|
"D2: a two-line delivery address does not throw a TypeError",
|
|
{ todo: "a two-line address throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
|
|
() => {
|
|
assertNoTypeError(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })));
|
|
}
|
|
);
|
|
|
|
test(
|
|
"D3: a pickup whose delivery price is null is sent with shipping 0.00",
|
|
{ todo: "delivery price null throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
|
|
() => {
|
|
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",
|
|
{ todo: "a deleted product throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
|
|
() => {
|
|
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));
|
|
}
|
|
);
|
|
|
|
// Not a todo: this holds today 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))}`);
|
|
});
|