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(", ")}`); });