From 9e94d69e2edbb6592511531959ed15b79d8811ca Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Fri, 9 Oct 2026 01:48:34 +0200 Subject: [PATCH] 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 " " 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 https://git.librete.ch/libretech/mp/issues/71 Refs https://git.librete.ch/libretech/mp/issues/67 --- src/api/order/controllers/order.ts | 21 ++- src/checkout/paypal-order.ts | 288 ++++++++++++++++++++--------- src/services/PayPalApi.ts | 20 +- tests/unit/paypal-order.test.ts | 258 ++++++++++++++++++++------ tests/unit/paypal-verify.test.ts | 10 +- 5 files changed, 434 insertions(+), 163 deletions(-) diff --git a/src/api/order/controllers/order.ts b/src/api/order/controllers/order.ts index d68a0c2..6d45cfe 100644 --- a/src/api/order/controllers/order.ts +++ b/src/api/order/controllers/order.ts @@ -10,6 +10,7 @@ import type { CustomerUpdate } from "../../../checkout/customer-update"; import { isLockedForCustomer, missingCheckoutFields } from "../../../checkout/order-state"; import { capturePayment } 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"; 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. - * 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) => { 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); 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 }; try { session = await paypalApi.createSessionOrThrow(returnUrl as string, order); } catch (error) { - strapi.log.error("app:e:order-controller: Could not create the PayPal order", { order: order.id, error: describeError(error) }); - return ctx.badGateway("Could not create the PayPal order"); + const failure = checkoutFailure(error); + 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 } }); diff --git a/src/checkout/paypal-order.ts b/src/checkout/paypal-order.ts index 7cf509c..304214d 100644 --- a/src/checkout/paypal-order.ts +++ b/src/checkout/paypal-order.ts @@ -1,20 +1,28 @@ -import type { Order } from "../../types"; -import { calculateTotalProductPrice } from "./pricing"; +import type { CustomerStructuredAddress } from "./customer-update"; import { vatIncludedDecimal } from "./vat"; // 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. +// +// 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 = { currencyCode: string; value: string; }; -export type PayPalOrderItem = { - name: string; - unitAmount: PayPalMoney; - tax: PayPalMoney; - quantity: string; +export type PayPalShipping = { + name: { fullName: string }; + address: { addressLine1: string; addressLine2?: string; postalCode: string; adminArea2: string; countryCode: string }; +}; + +export type PayPalBreakdown = { + itemTotal: PayPalMoney; + taxTotal: PayPalMoney; + /** Undefined for an order without a delivery method. */ + shipping: PayPalMoney | undefined; }; export type PayPalOrderRequest = { @@ -22,17 +30,8 @@ export type PayPalOrderRequest = { intent: "CAPTURE"; purchaseUnits: { referenceId: string; - amount: PayPalMoney & { - breakdown: { - itemTotal: PayPalMoney; - taxTotal: PayPalMoney; - shipping: PayPalMoney | undefined; - }; - }; - shipping: { - name: { fullName: string }; - address: { addressLine1: string; postalCode: string; adminArea2: string; countryCode: string }; - }; + amount: PayPalMoney & { breakdown: PayPalBreakdown }; + shipping: PayPalShipping; customId: string; invoiceId: string; }[]; @@ -45,80 +44,195 @@ export type PayPalOrderRequest = { }; /** - * The part of PayPalApi.createSessionOrThrow that runs before its try block, so nothing thrown here is logged there. - * Rejects an order without a total, then computes one item per cart line. The items are not sent to PayPal, but computing - * them throws a TypeError for a cart line without a product. + * The fields of the order the request is built from, as the checkout reads the order (strapi.db.query findOne with + * orderDefaultParams). Each of them may be null. Prices are decimals, which Strapi returns as numbers. */ -export const buildPayPalOrderItems = (order: Order): PayPalOrderItem[] => { - if (!order.total) throw new Error("Cart is empty or has no total"); +export type PayPalOrderSource = { + 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) => { - const totalProductPrice = calculateTotalProductPrice(item.product); - const amount = totalProductPrice / vatIncludedDecimal; - const tax = totalProductPrice - amount; +/** The order lacks something PayPal needs, or its totals do not add up, so the customer has to complete or change it first. */ +export class CheckoutNotReadyError extends Error { + constructor(message: string) { + super(message); + this.name = "CheckoutNotReadyError"; + } +} - return { - name: item.product.name, - unitAmount: { - currencyCode: "EUR", - value: amount.toFixed(2) - }, - tax: { - currencyCode: "EUR", - value: tax.toFixed(2) - }, - quantity: item.count.toString() - }; - }); +/** 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 { + itemTotal: money(itemTotalCents), + taxTotal: money(taxCents), + shipping: shippingCents === undefined ? undefined : money(shippingCents) + }; +}; + +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 " " 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 => ({ - intent: "CAPTURE", - purchaseUnits: [ - { - // items: computed by buildPayPalOrderItems, but not sent - referenceId: order.uuid, - amount: { - currencyCode: "EUR", - 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, - invoiceId: order.invoiceNumber +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" } - ], - applicationContext: { - returnUrl: returnUrl, - cancelUrl: returnUrl, - shippingPreference: "SET_PROVIDED_ADDRESS" - } -}); + }; +}; + +/** + * 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", + purchaseUnits: [ + { + referenceId: order.uuid, + amount: { ...money(totalCents), breakdown: breakdownFor(order, totalCents) }, + shipping: shippingFor(order), + customId: order.uuid, + invoiceId: order.invoiceNumber + } + ], + applicationContext: { + returnUrl: returnUrl, + cancelUrl: returnUrl, + shippingPreference: "SET_PROVIDED_ADDRESS" + } + }; +}; diff --git a/src/services/PayPalApi.ts b/src/services/PayPalApi.ts index c003e00..00c7415 100644 --- a/src/services/PayPalApi.ts +++ b/src/services/PayPalApi.ts @@ -1,7 +1,7 @@ import { Client, Environment, LogLevel, OrdersController, OrderRequest } from "@paypal/paypal-server-sdk"; import { paypalClientId, paypalClientSecret, paypalEnvironment } from "../../config/constants"; -import { Order } from "../../types"; -import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../checkout/paypal-order"; +import { buildPayPalOrderRequest } from "../checkout/paypal-order"; +import type { PayPalOrderSource } from "../checkout/paypal-order"; import { describeError } from "../logging/error"; class PayPalApi { @@ -25,16 +25,16 @@ class PayPalApi { 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 }> { - // Outside the try block, so these throws are not logged here. The items are computed but not sent. - buildPayPalOrderItems(order); + /** + * Creates the PayPal order for the order and returns PayPal's id for it, which the capture accepts, and its links. Throws + * CheckoutNotReadyError or ProductUnavailableError before PayPal is asked, for an order PayPal cannot take. + */ + 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 { - const collect = { - body: buildPayPalOrderRequest(returnUrl, order) as OrderRequest, - prefer: "return=minimal" - }; + const collect = { body: request, prefer: "return=minimal" }; strapi.log.silly("app:d:paypal-api: Creating PayPal order " + JSON.stringify({ collect })); diff --git a/tests/unit/paypal-order.test.ts b/tests/unit/paypal-order.test.ts index 104baea..b659567 100644 --- a/tests/unit/paypal-order.test.ts +++ b/tests/unit/paypal-order.test.ts @@ -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"; @@ -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. -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 +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. 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", () => { - 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", @@ -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", () => { const carts: { lines: [number, number][]; delivery: number | null }[] = [ { 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. 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" }); + } +}); diff --git a/tests/unit/paypal-verify.test.ts b/tests/unit/paypal-verify.test.ts index 10222aa..8ffe2dc 100644 --- a/tests/unit/paypal-verify.test.ts +++ b/tests/unit/paypal-verify.test.ts @@ -23,6 +23,9 @@ const order = (overrides = {}) => ({ VAT: 2.86, delivery: { id: 1, name: "DHL", price: 4.95 }, 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", ...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", () => { const mismatches: number[] = []; 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; - // buildPayPalOrderRequest sends order.total.toFixed(2); the order service rounds every total to the cent - if (parseCents(total.toFixed(2)) !== cents || orderTotalCents({ total }) !== cents) mismatches.push(total); + const VAT = Math.round((total / 1.19) * 0.19 * 100) / 100; + 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(", ")}`); });