Checkout: only customer fields on order updates, verified PayPal capture, safe logging #6
No files matched your search
@@ -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 } });
|
||||
|
||||
+201
-87
@@ -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 "<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 => ({
|
||||
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"
|
||||
}
|
||||
};
|
||||
};
|
||||
+10
-10
@@ -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 }));
|
||||
|
||||
|
||||
+198
-60
@@ -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" });
|
||||
}
|
||||
});
|
||||
@@ -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(", ")}`);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user