fix(checkout): reject an order PayPal cannot take with a typed error

buildPayPalOrderRequest split the text delivery address at fixed
offsets and called toFixed on the delivery price, and the items that
were computed but never sent read the product of every cart line. A
missing or two-line delivery address, a delivery method whose price is
null, such as a pickup, and a cart line whose product was deleted each
threw a TypeError, which the checkout answered 502 (D1 to D4).

The request is now built in whole cents, with the shipping address
from the first one entered of the structured delivery address, the
structured invoice address and the text delivery address:
- fullName is givenName and familyName, addressLine1 streetAddress,
  adminArea2 addressLevel2, countryCode the country upper-cased or DE;
  a structured address that is entered but incomplete is rejected
  instead of replaced by another address;
- a text address needs three lines: the name, the street, and
  "<postal code> <city>" split at the first whitespace; lines between
  the name and the street become addressLine2;
- a delivery price that is null counts as 0;
- itemTotal is the net of the subtotal, round(subtotal cents * 100 /
  119), taxTotal the order's VAT. The breakdown must add up to the
  total to the cent, as PayPal requires, so totals computed before a
  price changed are rejected here instead of by PayPal.

The items are gone. With items, PayPal requires item_total and
tax_total to be the sums of the amounts rounded per unit, which miss
the net and the VAT of the subtotal that the invoice states: 3 x 12.50
gives 31.50 and 6.00, the subtotal 31.51 and 5.99.

An empty cart, a missing total or VAT, totals that do not add up and a
missing or unusable address throw CheckoutNotReadyError, which the
checkout answers 400 "Order is not ready for checkout" with the reason.
A deleted product throws ProductUnavailableError, answered 409 "A
product in the cart is no longer available". Both are thrown before
PayPal is asked and are logged as warnings; other errors stay 502.

Tests: D1 to D4 are regular tests. The test of an order without a total
expects CheckoutNotReadyError instead of a plain Error, and the item
test is replaced by one that shows why no items are sent. The order of
paypal-verify.test.ts gains the cart line the builder now requires, and
its amount test builds the request for every total up to 3000.00
instead of assuming toFixed(2).

Refs libretech/mp#71
Refs libretech/mp#67
This commit is contained in:
libretech committed 2026-10-09 01:48:34 +02:00
1 parent c560de8ec0
commit 9e94d69e2e
5 files changed
+416 -145

No files matched your search

+17 -4
View File
@@ -10,6 +10,7 @@ import type { CustomerUpdate } from "../../../checkout/customer-update";
import { isLockedForCustomer, missingCheckoutFields } from "../../../checkout/order-state"; import { isLockedForCustomer, missingCheckoutFields } from "../../../checkout/order-state";
import { capturePayment } from "../../../checkout/capture-payment"; import { capturePayment } from "../../../checkout/capture-payment";
import type { CaptureLog } from "../../../checkout/capture-payment"; import type { CaptureLog } from "../../../checkout/capture-payment";
import { CHECKOUT_NOT_READY, checkoutFailure } from "../../../checkout/paypal-order";
import { describeError } from "../../../logging/error"; import { describeError } from "../../../logging/error";
const ORDER_LOCKED = "Order can no longer be changed"; const ORDER_LOCKED = "Order can no longer be changed";
@@ -220,7 +221,8 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
/** /**
* Creates the PayPal order for the order and stores its id on the order: the capture accepts this PayPal order and no other. * Creates the PayPal order for the order and stores its id on the order: the capture accepts this PayPal order and no other.
* Answers PayPal's id and links, which the shop's PayPal button opens. * Answers PayPal's id and links, which the shop's PayPal button opens. An order PayPal cannot take, such as one without a usable
* delivery address, is answered 400 with the reason, a cart with a deleted product 409, and a PayPal or network error 502.
*/ */
checkout: async (ctx) => { checkout: async (ctx) => {
const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
@@ -233,14 +235,25 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED); if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED);
const missing = missingCheckoutFields(order); const missing = missingCheckoutFields(order);
if (missing.length > 0) return ctx.badRequest("Order is not ready for checkout", { missing }); if (missing.length > 0) return ctx.badRequest(CHECKOUT_NOT_READY, { missing });
let session: { id: string; links: unknown }; let session: { id: string; links: unknown };
try { try {
session = await paypalApi.createSessionOrThrow(returnUrl as string, order); session = await paypalApi.createSessionOrThrow(returnUrl as string, order);
} catch (error) { } catch (error) {
strapi.log.error("app:e:order-controller: Could not create the PayPal order", { order: order.id, error: describeError(error) }); const failure = checkoutFailure(error);
return ctx.badGateway("Could not create the PayPal order"); const fields = { order: order.id, error: describeError(error) };
switch (failure.status) {
case 400:
strapi.log.warn("app:w:order-controller: The order cannot be checked out as it is", fields);
return ctx.badRequest(failure.message, { reason: failure.reason });
case 409:
strapi.log.warn("app:w:order-controller: A product in the cart is no longer available", fields);
return ctx.conflict(failure.message);
default:
strapi.log.error("app:e:order-controller: Could not create the PayPal order", fields);
return ctx.badGateway(failure.message);
}
} }
await strapi.db.query("api::order.order").update({ where: { id: order.id }, data: { paypalOrderId: session.id } }); await strapi.db.query("api::order.order").update({ where: { id: order.id }, data: { paypalOrderId: session.id } });
+187 -73
View File
@@ -1,20 +1,28 @@
import type { Order } from "../../types"; import type { CustomerStructuredAddress } from "./customer-update";
import { calculateTotalProductPrice } from "./pricing";
import { vatIncludedDecimal } from "./vat"; import { vatIncludedDecimal } from "./vat";
// The PayPal order that PayPalApi.createSessionOrThrow creates, built without the PayPal SDK so it can be unit tested. // The PayPal order that PayPalApi.createSessionOrThrow creates, built without the PayPal SDK so it can be unit tested.
// String literals stand in for the SDK enums, with the values of @paypal/paypal-server-sdk 0.6.1. // String literals stand in for the SDK enums, with the values of @paypal/paypal-server-sdk 0.6.1.
//
// The request sends no items. With items, PayPal requires item_total to be the sum of unit_amount × quantity and tax_total the
// sum of tax × quantity, and amounts rounded per unit miss the net and the VAT of the subtotal, which the invoice states:
// 3 × 12.50 gives 3 × 10.50 = 31.50 net and 3 × 2.00 = 6.00 tax, the subtotal of 37.50 gives 31.51 net and 5.99 VAT.
export type PayPalMoney = { export type PayPalMoney = {
currencyCode: string; currencyCode: string;
value: string; value: string;
}; };
export type PayPalOrderItem = { export type PayPalShipping = {
name: string; name: { fullName: string };
unitAmount: PayPalMoney; address: { addressLine1: string; addressLine2?: string; postalCode: string; adminArea2: string; countryCode: string };
tax: PayPalMoney; };
quantity: string;
export type PayPalBreakdown = {
itemTotal: PayPalMoney;
taxTotal: PayPalMoney;
/** Undefined for an order without a delivery method. */
shipping: PayPalMoney | undefined;
}; };
export type PayPalOrderRequest = { export type PayPalOrderRequest = {
@@ -22,17 +30,8 @@ export type PayPalOrderRequest = {
intent: "CAPTURE"; intent: "CAPTURE";
purchaseUnits: { purchaseUnits: {
referenceId: string; referenceId: string;
amount: PayPalMoney & { amount: PayPalMoney & { breakdown: PayPalBreakdown };
breakdown: { shipping: PayPalShipping;
itemTotal: PayPalMoney;
taxTotal: PayPalMoney;
shipping: PayPalMoney | undefined;
};
};
shipping: {
name: { fullName: string };
address: { addressLine1: string; postalCode: string; adminArea2: string; countryCode: string };
};
customId: string; customId: string;
invoiceId: string; invoiceId: string;
}[]; }[];
@@ -45,73 +44,187 @@ export type PayPalOrderRequest = {
}; };
/** /**
* The part of PayPalApi.createSessionOrThrow that runs before its try block, so nothing thrown here is logged there. * The fields of the order the request is built from, as the checkout reads the order (strapi.db.query findOne with
* Rejects an order without a total, then computes one item per cart line. The items are not sent to PayPal, but computing * orderDefaultParams). Each of them may be null. Prices are decimals, which Strapi returns as numbers.
* them throws a TypeError for a cart line without a product.
*/ */
export const buildPayPalOrderItems = (order: Order): PayPalOrderItem[] => { export type PayPalOrderSource = {
if (!order.total) throw new Error("Cart is empty or has no total"); uuid: string;
invoiceNumber?: string | null;
subtotal?: number | string | null;
total?: number | string | null;
VAT?: number | string | null;
delivery?: { price?: number | string | null } | null;
cart?: ({ product?: unknown } | null)[] | null;
deliveryAddress?: string | null;
deliveryAddressStructured?: CustomerStructuredAddress | null;
invoiceAddressStructured?: CustomerStructuredAddress | null;
};
return order.cart.map((item) => { /** The order lacks something PayPal needs, or its totals do not add up, so the customer has to complete or change it first. */
const totalProductPrice = calculateTotalProductPrice(item.product); export class CheckoutNotReadyError extends Error {
const amount = totalProductPrice / vatIncludedDecimal; constructor(message: string) {
const tax = totalProductPrice - amount; super(message);
this.name = "CheckoutNotReadyError";
}
}
/** A cart line whose product was deleted. */
export class ProductUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "ProductUnavailableError";
}
}
export const CHECKOUT_NOT_READY = "Order is not ready for checkout";
export const PRODUCT_UNAVAILABLE = "A product in the cart is no longer available";
export const PAYPAL_ORDER_FAILED = "Could not create the PayPal order";
export type CheckoutFailure = { status: 400; message: string; reason: string } | { status: 409 | 502; message: string };
/**
* The checkout's answer to an error of PayPalApi.createSessionOrThrow: 400 with the reason for an order that is not ready, 409 for a
* product that is no longer available. Any other error is PayPal's or the network's: 502.
*/
export const checkoutFailure = (error: unknown): CheckoutFailure => {
if (error instanceof CheckoutNotReadyError) return { status: 400, message: CHECKOUT_NOT_READY, reason: error.message };
if (error instanceof ProductUnavailableError) return { status: 409, message: PRODUCT_UNAVAILABLE };
return { status: 502, message: PAYPAL_ORDER_FAILED };
};
// Money is computed in whole cents.
/** A decimal amount of euros as whole cents, or null for anything that is not a finite number. */
const toCents = (amount: unknown): number | null => {
const value = typeof amount === "string" && amount.trim() !== "" ? Number(amount) : amount;
return typeof value === "number" && Number.isFinite(value) ? Math.round(value * 100) : null;
};
/** Whole, non-negative cents as PayPal money: 1504 is "15.04". */
const money = (cents: number): PayPalMoney => ({
currencyCode: "EUR",
value: `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`
});
// vatIncludedDecimal as a whole percentage (119), so that the net of the subtotal is a quotient of whole numbers.
const VAT_INCLUDED_PERCENT = Math.round(vatIncludedDecimal * 100);
/**
* The breakdown of the order's total: the net of its subtotal, its VAT and the price of its delivery method, 0 when that price
* is not set. PayPal rejects a breakdown that does not add up to the amount, which happens when a price changed after the order
* service computed the totals, so such an order is rejected here.
*/
const breakdownFor = (order: PayPalOrderSource, totalCents: number): PayPalBreakdown => {
const subtotalCents = toCents(order.subtotal);
const taxCents = toCents(order.VAT);
if (subtotalCents === null || taxCents === null) throw new CheckoutNotReadyError("The order has no subtotal or no VAT");
const itemTotalCents = Math.round((subtotalCents * 100) / VAT_INCLUDED_PERCENT);
const shippingCents = order.delivery ? (toCents(order.delivery.price) ?? 0) : undefined;
const parts = [itemTotalCents, taxCents, shippingCents ?? 0];
const sum = parts.reduce((a, b) => a + b, 0);
if (parts.some((cents) => cents < 0) || sum !== totalCents) {
throw new CheckoutNotReadyError(
`The order's totals do not add up: total ${totalCents} cents, items ${itemTotalCents} + VAT ${taxCents} + shipping ${shippingCents ?? 0}`
);
}
return { return {
name: item.product.name, itemTotal: money(itemTotalCents),
unitAmount: { taxTotal: money(taxCents),
currencyCode: "EUR", shipping: shippingCents === undefined ? undefined : money(shippingCents)
value: amount.toFixed(2)
},
tax: {
currencyCode: "EUR",
value: tax.toFixed(2)
},
quantity: item.count.toString()
}; };
}); };
const text = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
const STRUCTURED_COMPONENTS = ["givenName", "familyName", "streetAddress", "postalCode", "addressLevel2"] as const;
/** Whether any component besides the country is set: Strapi stores the country with the default DE. */
const isEntered = (address: CustomerStructuredAddress | null | undefined): boolean =>
typeof address === "object" && address !== null && STRUCTURED_COMPONENTS.some((key) => text(address[key]) !== "");
/** The shipping address from a structured address, which needs a name, a street, a postal code and a city. */
const fromStructured = (field: string, address: CustomerStructuredAddress): PayPalShipping => {
const fullName = [text(address.givenName), text(address.familyName)].filter((part) => part !== "").join(" ");
const addressLine1 = text(address.streetAddress);
const postalCode = text(address.postalCode);
const adminArea2 = text(address.addressLevel2);
const countryCode = (text(address.country) || "DE").toUpperCase();
const lacking = [
[fullName, "givenName and familyName"],
[addressLine1, "streetAddress"],
[postalCode, "postalCode"],
[adminArea2, "addressLevel2"]
]
.filter(([value]) => value === "")
.map(([, component]) => component);
if (lacking.length > 0) throw new CheckoutNotReadyError(`${field} lacks ${lacking.join(", ")}`);
if (!/^[A-Z]{2}$/.test(countryCode)) throw new CheckoutNotReadyError(`${field} has no two-letter country code`);
return { name: { fullName }, address: { addressLine1, postalCode, adminArea2, countryCode } };
}; };
/** /**
* The order request body PayPalApi.createSessionOrThrow sends. It is built inside that try block, so whatever throws here is logged there. * The shipping address from a text address as the shop writes it (libreshop/shop pages/checkout/2.vue, addressToString): the name,
* the street and "<postal code> <city>" on lines of their own. Lines between the name and the street, such as c/o, become
* addressLine2. Blank lines are skipped. The country is DE.
*/ */
export const buildPayPalOrderRequest = (returnUrl: string, order: Order): PayPalOrderRequest => ({ const fromText = (field: string, address: string): PayPalShipping => {
const lines = address
.split(/\r\n|\r|\n/)
.map((line) => line.trim())
.filter((line) => line !== "");
if (lines.length < 3) throw new CheckoutNotReadyError(`${field} needs the name, the street, and the postal code and city on lines of their own`);
const city = /^(\S+)\s+(\S.*)$/.exec(lines[lines.length - 1]);
if (city === null) throw new CheckoutNotReadyError(`The last line of ${field} is not the postal code and the city`);
const addressLine2 = lines.slice(1, -2).join(", ");
return {
name: { fullName: lines[0] },
address: {
addressLine1: lines[lines.length - 2],
...(addressLine2 !== "" ? { addressLine2 } : {}),
postalCode: city[1],
adminArea2: city[2],
countryCode: "DE"
}
};
};
/**
* The address PayPal shows the buyer as the shipping address: the structured delivery address, else the structured invoice address,
* else the text delivery address. The shop sends the invoice address as the delivery address when the buyer gives no other. An
* address that is entered but incomplete is rejected rather than replaced by the next one.
*/
const shippingFor = (order: PayPalOrderSource): PayPalShipping => {
if (isEntered(order.deliveryAddressStructured)) return fromStructured("deliveryAddressStructured", order.deliveryAddressStructured);
if (isEntered(order.invoiceAddressStructured)) return fromStructured("invoiceAddressStructured", order.invoiceAddressStructured);
if (text(order.deliveryAddress) !== "") return fromText("deliveryAddress", order.deliveryAddress);
throw new CheckoutNotReadyError("The order has no delivery address");
};
/**
* The order request body PayPalApi.createSessionOrThrow sends. Throws ProductUnavailableError for a cart line whose product was
* deleted, and CheckoutNotReadyError for an empty cart, a missing total, totals that do not add up or a missing or unusable
* delivery address. The messages name fields and amounts, never an address.
*/
export const buildPayPalOrderRequest = (returnUrl: string, order: PayPalOrderSource): PayPalOrderRequest => {
const cart = Array.isArray(order.cart) ? order.cart : [];
const totalCents = toCents(order.total);
if (cart.length === 0 || totalCents === null || totalCents <= 0) throw new CheckoutNotReadyError("Cart is empty or has no total");
if (cart.some((line) => !line?.product)) throw new ProductUnavailableError(PRODUCT_UNAVAILABLE);
return {
intent: "CAPTURE", intent: "CAPTURE",
purchaseUnits: [ purchaseUnits: [
{ {
// items: computed by buildPayPalOrderItems, but not sent
referenceId: order.uuid, referenceId: order.uuid,
amount: { amount: { ...money(totalCents), breakdown: breakdownFor(order, totalCents) },
currencyCode: "EUR", shipping: shippingFor(order),
value: order.total.toFixed(2),
breakdown: {
itemTotal: {
currencyCode: "EUR",
value: (Math.round((order.subtotal / vatIncludedDecimal) * 100) / 100).toFixed(2)
},
taxTotal: {
currencyCode: "EUR",
value: order.VAT.toFixed(2)
},
shipping: order.delivery
? {
currencyCode: "EUR",
value: order.delivery.price.toFixed(2)
}
: undefined
}
},
shipping: {
name: {
fullName: order.deliveryAddress.split("\n")[0]
},
address: {
addressLine1: order.deliveryAddress.split("\n")[1],
postalCode: order.deliveryAddress.split("\n")[2].slice(0, 5),
adminArea2: order.deliveryAddress.split("\n")[2].slice(6),
countryCode: "DE"
}
},
customId: order.uuid, customId: order.uuid,
invoiceId: order.invoiceNumber invoiceId: order.invoiceNumber
} }
@@ -121,4 +234,5 @@ export const buildPayPalOrderRequest = (returnUrl: string, order: Order): PayPal
cancelUrl: returnUrl, cancelUrl: returnUrl,
shippingPreference: "SET_PROVIDED_ADDRESS" shippingPreference: "SET_PROVIDED_ADDRESS"
} }
}); };
};
+10 -10
View File
@@ -1,7 +1,7 @@
import { Client, Environment, LogLevel, OrdersController, OrderRequest } from "@paypal/paypal-server-sdk"; import { Client, Environment, LogLevel, OrdersController, OrderRequest } from "@paypal/paypal-server-sdk";
import { paypalClientId, paypalClientSecret, paypalEnvironment } from "../../config/constants"; import { paypalClientId, paypalClientSecret, paypalEnvironment } from "../../config/constants";
import { Order } from "../../types"; import { buildPayPalOrderRequest } from "../checkout/paypal-order";
import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../checkout/paypal-order"; import type { PayPalOrderSource } from "../checkout/paypal-order";
import { describeError } from "../logging/error"; import { describeError } from "../logging/error";
class PayPalApi { class PayPalApi {
@@ -25,16 +25,16 @@ class PayPalApi {
this.ordersController = new OrdersController(this.client); this.ordersController = new OrdersController(this.client);
} }
/** Creates the PayPal order for the order and returns PayPal's id for it, which the capture accepts, and its links. */ /**
async createSessionOrThrow(returnUrl: string, order: Order): Promise<{ id: string; links: unknown }> { * Creates the PayPal order for the order and returns PayPal's id for it, which the capture accepts, and its links. Throws
// Outside the try block, so these throws are not logged here. The items are computed but not sent. * CheckoutNotReadyError or ProductUnavailableError before PayPal is asked, for an order PayPal cannot take.
buildPayPalOrderItems(order); */
async createSessionOrThrow(returnUrl: string, order: PayPalOrderSource): Promise<{ id: string; links: unknown }> {
// Outside the try block: an order that is not ready is the customer's to fix, not a PayPal failure.
const request = buildPayPalOrderRequest(returnUrl, order) as OrderRequest;
try { try {
const collect = { const collect = { body: request, prefer: "return=minimal" };
body: buildPayPalOrderRequest(returnUrl, order) as OrderRequest,
prefer: "return=minimal"
};
strapi.log.silly("app:d:paypal-api: Creating PayPal order " + JSON.stringify({ collect })); strapi.log.silly("app:d:paypal-api: Creating PayPal order " + JSON.stringify({ collect }));
+194 -56
View File
@@ -1,6 +1,12 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; 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 { calculateTotalProductPrice } from "../../src/checkout/pricing.ts";
import { vatDecimal, vatIncludedDecimal } from "../../src/checkout/vat.ts"; import { vatDecimal, vatIncludedDecimal } from "../../src/checkout/vat.ts";
@@ -43,11 +49,8 @@ const orderFor = (lines: [number, number][], deliveryPrice: number | null) => {
}); });
}; };
// Both halves in the order createSessionOrThrow runs them: what it would send to PayPal, or what it would throw. // What createSessionOrThrow would send to PayPal, or what it would throw before asking PayPal.
const buildSession = (order) => { const buildSession = (order) => buildPayPalOrderRequest(RETURN_URL, order);
buildPayPalOrderItems(order);
return buildPayPalOrderRequest(RETURN_URL, order);
};
const cents = (money) => (money ? Math.round(Number(money.value) * 100) : 0); const cents = (money) => (money ? Math.round(Number(money.value) * 100) : 0);
@@ -71,6 +74,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. // Characterization: what the builders do today.
test("builds the PayPal order request for a valid order", () => { test("builds the PayPal order request for a valid order", () => {
@@ -117,71 +129,51 @@ test("sends no shipping amount when the order has no delivery method", () => {
}); });
}); });
test("rejects an order without a total before computing the items", () => { // 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".
for (const total of [0, null, undefined]) { // That check now throws CheckoutNotReadyError, which the checkout answers 400 instead of 502.
assert.throws(() => buildPayPalOrderItems(validOrder({ total })), { name: "Error", message: "Cart is empty or has no total" }); 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", () => { test("sends no items: amounts rounded per unit would miss the net and the VAT of the subtotal", () => {
const order = validOrder({ // 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.
cart: [ const unitNetCents = Math.round((1250 * 100) / 119);
{ id: 1, count: 1, product: notebook }, const unit = buildSession(orderFor([[12.5, 3]], 4.95)).purchaseUnits[0];
{ id: 2, count: 3, product: { id: 102, name: "Journaling Booklet", cover: { price: 12.5 } } }
] 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" }
}
}); });
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. // Fixed defects of https://git.librete.ch/libretech/mp/issues/67.
test( test("D1: a missing delivery address is rejected with a clear validation error", () => {
"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)); assert.throws(() => buildSession(validOrder({ deliveryAddress: null })), isClearError(/address/i));
} });
);
test( test("D2: a two-line delivery address does not throw a TypeError", () => {
"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" }))); assertNoTypeError(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })));
} });
);
test( test("D3: a pickup whose delivery price is null is sent with shipping 0.00", () => {
"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 }); 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( test("D4: a cart line whose product was deleted is rejected with a clear error", () => {
"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)); assert.throws(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })), isClearError(/product/i));
} });
);
test( test(
"D5: with a payment surcharge the breakdown still adds up to the amount", "D5: with a payment surcharge the breakdown still adds up to the amount",
@@ -195,7 +187,7 @@ test(
} }
); );
// 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", () => { 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 }[] = [ const carts: { lines: [number, number][]; delivery: number | null }[] = [
{ lines: [[17.9, 1]], delivery: 4.95 }, { lines: [[17.9, 1]], delivery: 4.95 },
@@ -232,3 +224,149 @@ 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. // 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))}`); 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" });
}
});
+8 -2
View File
@@ -23,6 +23,9 @@ const order = (overrides = {}) => ({
VAT: 2.86, VAT: 2.86,
delivery: { id: 1, name: "DHL", price: 4.95 }, delivery: { id: 1, name: "DHL", price: 4.95 },
invoiceNumber: "R-12-4711", invoiceNumber: "R-12-4711",
cart: [
{ id: 1, count: 1, product: { id: 101, name: "Notizbuch A5 Punktraster", cover: { price: 14.9 }, pages: { price: 3 }, ruling: { price: 0 } } }
],
deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart", deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
...overrides ...overrides
}); });
@@ -80,9 +83,12 @@ test("turns the order total into integer cents, also where its float is a hair b
test("the amount the checkout sends PayPal is the amount the capture accepts, for every total up to 3000.00", () => { test("the amount the checkout sends PayPal is the amount the capture accepts, for every total up to 3000.00", () => {
const mismatches: number[] = []; const mismatches: number[] = [];
for (let cents = 1; cents <= 300000; cents++) { for (let cents = 1; cents <= 300000; cents++) {
// The order service rounds every total to the cent; an order without a delivery method has its subtotal as its total.
const total = cents / 100; const total = cents / 100;
// buildPayPalOrderRequest sends order.total.toFixed(2); the order service rounds every total to the cent const VAT = Math.round((total / 1.19) * 0.19 * 100) / 100;
if (parseCents(total.toFixed(2)) !== cents || orderTotalCents({ total }) !== cents) mismatches.push(total); const request = buildPayPalOrderRequest("https://shop.example/checkout/3", order({ subtotal: total, total, VAT, delivery: null }));
const { amount } = request.purchaseUnits[0];
if (parseCents(amount.value) !== cents || orderTotalCents({ total }) !== cents) mismatches.push(total);
} }
assert.equal(mismatches.length, 0, `e.g. ${mismatches.slice(0, 5).join(", ")}`); assert.equal(mismatches.length, 0, `e.g. ${mismatches.slice(0, 5).join(", ")}`);
}); });