Checkout: only customer fields on order updates, verified PayPal capture, safe logging (#6)
Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #6.
This commit is contained in:
1 parent
9b0c6af8b7
commit
5002cac469
24 files changed
+2911
-313
No files matched your search
+246
-73
@@ -1,6 +1,12 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../../src/checkout/paypal-order.ts";
|
||||
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";
|
||||
|
||||
@@ -27,7 +33,7 @@ const validOrder = (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 orderFor = (lines: [number, number][], deliveryPrice: number | null, paymentPrice: number | null = null) => {
|
||||
const cart = lines.map(([price, count], index) => ({
|
||||
id: index + 1,
|
||||
count,
|
||||
@@ -37,17 +43,15 @@ const orderFor = (lines: [number, number][], deliveryPrice: number | null) => {
|
||||
return validOrder({
|
||||
cart,
|
||||
subtotal,
|
||||
total: Math.round((subtotal + (deliveryPrice ?? 0)) * 100) / 100,
|
||||
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 }
|
||||
delivery: deliveryPrice === null ? null : { id: 1, name: "DHL", price: deliveryPrice },
|
||||
payment: paymentPrice === null ? null : { id: 1, name: "Vorkasse", price: paymentPrice }
|
||||
});
|
||||
};
|
||||
|
||||
// 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);
|
||||
};
|
||||
// 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);
|
||||
|
||||
@@ -71,6 +75,15 @@ const assertNoTypeError = (build: () => unknown) => {
|
||||
}
|
||||
};
|
||||
|
||||
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", () => {
|
||||
@@ -117,85 +130,61 @@ test("sends no shipping amount when the order has no delivery method", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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" });
|
||||
// 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("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 } } }
|
||||
]
|
||||
});
|
||||
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.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"
|
||||
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" }
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Known defects: these assert the correct behaviour and fail today, which the todo marks as expected.
|
||||
// Fixed defects of https://git.librete.ch/libretech/mp/issues/67.
|
||||
|
||||
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("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",
|
||||
{ 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("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",
|
||||
{ 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 });
|
||||
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" });
|
||||
}
|
||||
);
|
||||
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("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];
|
||||
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));
|
||||
}
|
||||
);
|
||||
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.
|
||||
// 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 },
|
||||
@@ -232,3 +221,187 @@ test("D6: the breakdown adds up to the amount to the cent, also for the subtotal
|
||||
// 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" });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user