Compare commits

..
4 Commits
Author SHA1 Message Date
libretech 8bdc2c6b7a ci: run the unit tests, and build images only for pull requests and releases
build / test (pull_request) Successful in 7s
build / build (pull_request) Successful in 6m0s
Every push to main published :main and :sha-* images, which nothing pulls.
The libreshop images had grown to 5.8 GB in the registry, on a Gitea host that
is nearly out of space.

- test: npm test for every pull request, every push to main and every release
  tag. It runs in the image's base (Dockerfile FROM, node:22.14.0-alpine), so
  on the runtime's Node, without network and without dependencies. The source
  is piped into the container, because the job container's paths do not exist
  on the Docker host.
- build: after the tests, for a pull request (build only) and for a v* tag
  (build and, with PUBLISH_ENABLED, publish :vX.Y.Z and :latest).

Refs libretech/mp#71
2026-10-08 22:29:00 +02:00
libretech cbcc54bd75 test(checkout): add dependency-free unit tests that pin the checkout defects
npm test runs node:test on the TypeScript sources with Node's type stripping
(Node 22.14 in the image, Node 24 locally), with no dependencies.

Node, unlike tsc, does not resolve extensionless relative imports. TypeScript
5.2 cannot emit .ts import extensions. tests/unit/support therefore registers
a small resolve hook that appends ".ts" for imports from .ts files.

- pricing, paypal-order, log-format: characterization tests of the current
  output.
- The known defects of
  libretech/mp#67 are tests of the correct
  behaviour, marked todo: null, two-line and price-null addresses and
  deliveries, a deleted product, the payment surcharge missing from the
  breakdown, and in the log line circular JSON, the Basic credentials of an
  axios error, and an Error rendered as {}.
- That the breakdown adds up to the amount holds today, including for the
  subtotals closest to a half cent, so that test is a regular one.

Refs libretech/mp#71
2026-10-08 22:15:57 +02:00
libretech 7fd4aeff17 refactor(logging): move the console line format into src/logging/format.ts
The printf template of config/logger.ts becomes formatLogLine, with the same
logic, so it can be unit tested without winston.

No behaviour change.

Refs libretech/mp#71
2026-10-08 22:10:28 +02:00
libretech 9873c184d6 refactor(checkout): move VAT rates, pricing and the PayPal order into pure modules
src/checkout/ holds code without the PayPal SDK or Strapi at runtime, so it can be
unit tested without node_modules:

- vat.ts: vatIncludedDecimal and vatDecimal. config/constants.ts re-exports them
  under the same names and keeps vatDecimalExcluded.
- pricing.ts: calculateTotalProductPrice, unchanged. The product service
  re-exports it, so every importer keeps working.
- paypal-order.ts: the order request createSessionOrThrow builds, in two halves.
  buildPayPalOrderItems (the total guard and the unsent items) still runs
  before the try block, and buildPayPalOrderRequest inside it. Errors and log
  lines stay as they were, for every input. The SDK enums are replaced by their
  string values (CAPTURE, SET_PROVIDED_ADDRESS).

No behaviour change.

Refs libretech/mp#71
2026-10-08 22:10:28 +02:00
24 changed files with 271 additions and 2869 deletions

No files matched your search

+1 -8
View File
@@ -1,17 +1,10 @@
import { winston } from "@strapi/logger"; import { winston } from "@strapi/logger";
import { formatLogLine } from "../src/logging/format"; import { formatLogLine } from "../src/logging/format";
// Strapi 4.25 creates its logger from @strapi/logger's default configuration with this object assigned over it
// (Strapi.js: createLogger(this.config.get("logger"))). Its default level is "silly", which writes every line, also the debug and
// silly lines with order data such as names and addresses. Production (NODE_ENV=production in mp's compose.yml) writes info and
// above, any other environment debug and above.
const level = process.env.NODE_ENV === "production" ? "info" : "debug";
export default { export default {
level,
transports: [ transports: [
new winston.transports.Console({ new winston.transports.Console({
format: winston.format.combine(winston.format.printf((info) => formatLogLine(info, { stack: level === "debug" }))) format: winston.format.combine(winston.format.printf(formatLogLine))
}) })
] ]
}; };
@@ -83,18 +83,6 @@
"paymentStatus": { "paymentStatus": {
"type": "string" "type": "string"
}, },
"paypalOrderId": {
"type": "string",
"private": true
},
"paypalCaptureId": {
"type": "string",
"private": true
},
"paymentCapturedAt": {
"type": "datetime",
"private": true
},
"emailSent": { "emailSent": {
"type": "boolean", "type": "boolean",
"default": false "default": false
+54 -112
View File
@@ -1,25 +1,9 @@
import { factories } from "@strapi/strapi"; import { factories } from "@strapi/strapi";
import { sanitize } from "@strapi/utils"; import { sanitize } from "@strapi/utils";
import type { ID } from "@strapi/database/dist/types";
import { Order } from "../../../../types"; import { Order } from "../../../../types";
import { paypalApi } from "../../../services/PayPalApi"; import { paypalApi } from "../../../services/PayPalApi";
import { orderDefaultParams } from "../services/order"; import { orderDefaultParams } from "../services/order";
import { calculateTotalProductPrice } from "../../product/services/product"; import { calculateTotalProductPrice } from "../../product/services/product";
import { parseCustomerUpdate } from "../../../checkout/customer-update";
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";
const captureLog: CaptureLog = {
info: (message, fields) => strapi.log.info(`app:i:order-controller: ${message}`, fields),
warn: (message, fields) => strapi.log.warn(`app:w:order-controller: ${message}`, fields),
error: (message, fields) => strapi.log.error(`app:e:order-controller: ${message}`, fields)
};
/** /**
* The order controller is a funnel for the client to interact with the order service. * The order controller is a funnel for the client to interact with the order service.
@@ -84,7 +68,6 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
where: { id }, where: { id },
...orderDefaultParams ...orderDefaultParams
}); });
if (!order) return ctx.notFound("Order not found");
order.cart = order?.cart.map((item) => ({ order.cart = order?.cart.map((item) => ({
...item, ...item,
product: { product: {
@@ -92,6 +75,7 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
totalProductPrice: calculateTotalProductPrice(item.product) totalProductPrice: calculateTotalProductPrice(item.product)
} }
})); }));
if (!order) return ctx.notFound("Order not found");
return await sanitize.contentAPI.output(order, strapi.getModel("api::order.order")); return await sanitize.contentAPI.output(order, strapi.getModel("api::order.order"));
}, },
@@ -101,7 +85,6 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
where: { uuid }, where: { uuid },
...orderDefaultParams ...orderDefaultParams
}); });
if (!order) return ctx.notFound("Order not found");
order.cart = order?.cart.map((item) => ({ order.cart = order?.cart.map((item) => ({
...item, ...item,
product: { product: {
@@ -109,46 +92,35 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
totalProductPrice: calculateTotalProductPrice(item.product) totalProductPrice: calculateTotalProductPrice(item.product)
} }
})); }));
if (!order) return ctx.notFound("Order not found");
return await sanitize.contentAPI.output(order, strapi.getModel("api::order.order")); return await sanitize.contentAPI.output(order, strapi.getModel("api::order.order"));
}, },
/**
* The customer's checkout steps: contact details, terms, addresses and delivery method. Only these fields are written;
* payment state, totals, numbers and documents are written by the payment flow and the server alone.
*/
updateByUuid: async (ctx) => { updateByUuid: async (ctx) => {
const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
const order = await strapi.db.query("api::order.order").findOne({ where: { uuid } }); const order = await strapi.db.query("api::order.order").findOne({ where: { uuid } });
if (!order) return ctx.notFound("Order not found"); if (!order) return ctx.notFound("Order not found");
if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED);
const update = parseCustomerUpdate(ctx.request.body, new Date()); const sanitizedBody = await sanitize.contentAPI.input(ctx.request.body, strapi.getModel("api::order.order"));
if (update.ok === false) {
strapi.log.warn("app:w:order-controller: Rejected order update", { order: order.id, fields: update.fields });
return ctx.badRequest("Invalid order update", { errors: update.errors });
}
// A changed address or delivery method needs a new checkout: the PayPal order created before can no longer be captured.
const data: CustomerUpdate & { customer?: ID; paypalOrderId: null } = { ...update.data, paypalOrderId: null };
if (data.delivery !== undefined) {
const delivery = await strapi.db.query("api::delivery.delivery").findOne({ where: { id: data.delivery }, select: ["id"] });
if (!delivery) {
strapi.log.warn("app:w:order-controller: Rejected order update", { order: order.id, fields: ["data.delivery"] });
return ctx.badRequest("Invalid order update", { errors: ["data.delivery: no delivery method has this id"] });
}
}
// Handle customer creation/update if structured addresses are provided // Handle customer creation/update if structured addresses are provided
if (data.invoiceAddressStructured) { // @ts-expect-error
const address = data.invoiceAddressStructured; if (sanitizedBody?.data?.invoiceAddressStructured) {
const customerAddress = const customerAddress =
data.invoiceAddress || // @ts-expect-error
`${address.givenName} ${address.familyName}\n${address.streetAddress}\n${address.postalCode} ${address.addressLevel2}`; sanitizedBody.data.invoiceAddress ||
data.customer = await strapi.service("api::customer.customer").findOrCreateCustomer(customerAddress, address); // @ts-expect-error
`${sanitizedBody.data.invoiceAddressStructured.givenName} ${sanitizedBody.data.invoiceAddressStructured.familyName}\n${sanitizedBody.data.invoiceAddressStructured.streetAddress}\n${sanitizedBody.data.invoiceAddressStructured.postalCode} ${sanitizedBody.data.invoiceAddressStructured.addressLevel2}`;
// @ts-expect-error
sanitizedBody.data.customer = await strapi
.service("api::customer.customer")
// @ts-expect-error
.findOrCreateCustomer(customerAddress, sanitizedBody.data.invoiceAddressStructured);
} }
const orderUnsafe = await strapi.service("api::order.order").update(order.id, { const orderUnsafe = await strapi.service("api::order.order").update(order.id, {
data, // @ts-expect-error
data: sanitizedBody?.data,
...orderDefaultParams ...orderDefaultParams
}); });
@@ -159,7 +131,6 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
const { uuid, productId } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const { uuid, productId } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
const order = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Order; const order = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Order;
if (!order) return ctx.notFound("Order not found"); if (!order) return ctx.notFound("Order not found");
if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED);
const product = parseInt(productId as string); const product = parseInt(productId as string);
const count = parseInt(ctx.query.count as string) || 1; const count = parseInt(ctx.query.count as string) || 1;
@@ -176,9 +147,8 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
strapi.log.info(`app:v:order-controller: - Adding product to cart ${order.id}`, { totalBefore: order.total }); strapi.log.info(`app:v:order-controller: - Adding product to cart ${order.id}`, { totalBefore: order.total });
// A changed cart needs a new checkout: the PayPal order created before can no longer be captured.
const orderUnsafe = await strapi.service("api::order.order").update(order.id, { const orderUnsafe = await strapi.service("api::order.order").update(order.id, {
data: { cart: cart, paypalOrderId: null }, data: { cart: cart },
...orderDefaultParams ...orderDefaultParams
}); });
strapi.log.info("app:v:order-controller: ✔ Adding product to cart", { totalAfter: orderUnsafe.total }); strapi.log.info("app:v:order-controller: ✔ Adding product to cart", { totalAfter: orderUnsafe.total });
@@ -192,7 +162,6 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
const product = parseInt(productId as string); const product = parseInt(productId as string);
if (!order) return ctx.notFound("Order not found"); if (!order) return ctx.notFound("Order not found");
if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED);
strapi.log.info("app:v:order-controller: - Removing product from cart", { totalBefore: order.total }); strapi.log.info("app:v:order-controller: - Removing product from cart", { totalBefore: order.total });
const updatedCart = [...(order.cart || [])]; const updatedCart = [...(order.cart || [])];
@@ -210,20 +179,14 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
updatedCart[existingIndex].count -= count; updatedCart[existingIndex].count -= count;
} }
// A changed cart needs a new checkout: the PayPal order created before can no longer be captured.
const orderUnsafe = await strapi.service("api::order.order").update(order.id, { const orderUnsafe = await strapi.service("api::order.order").update(order.id, {
data: { cart: updatedCart, paypalOrderId: null }, data: { cart: updatedCart },
...orderDefaultParams ...orderDefaultParams
}); });
strapi.log.info("app:v:order-controller: ✔ Removing product from cart", { totalAfter: order.total }); strapi.log.info("app:v:order-controller: ✔ Removing product from cart", { totalAfter: order.total });
return await sanitize.contentAPI.output(orderUnsafe, strapi.getModel("api::order.order")); return await sanitize.contentAPI.output(orderUnsafe, strapi.getModel("api::order.order"));
}, },
/**
* 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. An order PayPal cannot take, such as one without a usable
* delivery address, is answered 400 with the reason, a cart with a deleted product 409, and a PayPal or network error 502.
*/
checkout: async (ctx) => { checkout: async (ctx) => {
const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
const { returnUrl } = await sanitize.contentAPI.query(ctx.query, strapi.getModel("api::order.order")); const { returnUrl } = await sanitize.contentAPI.query(ctx.query, strapi.getModel("api::order.order"));
@@ -232,68 +195,49 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
...orderDefaultParams ...orderDefaultParams
}); });
if (!order) return ctx.notFound("Order not found"); if (!order) return ctx.notFound("Order not found");
if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED);
const missing = missingCheckoutFields(order); return await paypalApi.createSessionOrThrow(returnUrl as string, order);
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) {
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 } });
return { id: session.id, links: session.links };
}, },
/** Captures the PayPal order the checkout stored and marks the order paid, see src/checkout/capture-payment.ts. */
capturePayment: async (ctx) => { capturePayment: async (ctx) => {
const { uuid, paypalOrderId } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const { uuid, paypalOrderId } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
strapi.log.info(`app:i:order-controller: Capturing payment for order ${uuid} with PayPal order ${paypalOrderId}`);
const result = await capturePayment( const order = await strapi.db.query("api::order.order").findOne({
{ where: { uuid },
findOrder: (orderUuid) => strapi.db.query("api::order.order").findOne({ where: { uuid: orderUuid } }), ...orderDefaultParams
getPayPalOrder: (id) => paypalApi.getOrder(id), });
capturePayPalOrder: (id, requestId) => paypalApi.captureOrder(id, requestId), if (!order) return ctx.notFound("Order not found");
// The where clause makes the write conditional: a cart or address change clears paypalOrderId, so an order changed
// during the capture is not found, and stays unpaid. Strapi's update answers null then and runs no afterUpdate. if (order.paymentAuthorised) {
updateOrder: (id, storedPayPalOrderId, data) => strapi.log.info(`app:i:order-controller: Payment already authorised for order ${uuid}`);
strapi.db.query("api::order.order").update({ where: { id, paypalOrderId: storedPayPalOrderId }, data, ...orderDefaultParams }), return { success: true, alreadyCaptured: true };
log: captureLog
},
{
uuid: typeof uuid === "string" ? uuid : "",
paypalOrderId: typeof paypalOrderId === "string" ? paypalOrderId : "",
now: new Date()
} }
);
switch (result.status) { try {
case 200: // Capture the payment via PayPal server SDK
if (result.alreadyCaptured === true) return { success: true, alreadyCaptured: true }; const captureResult = await paypalApi.checkPaymentStatus({ orderID: paypalOrderId as string });
return await sanitize.contentAPI.output(result.order, strapi.getModel("api::order.order"));
case 404: if (captureResult.status === "COMPLETED") {
return ctx.notFound(result.message); // Update order with payment info
case 409: const updatedOrder = await strapi.entityService.update("api::order.order", order.id, {
return ctx.conflict(result.message); data: {
case 502: paymentAuthorised: true,
return ctx.badGateway(result.message); paymentStatus: "authorised",
default: paypalOrderId: paypalOrderId as string
return ctx.internalServerError(result.message); },
...orderDefaultParams
});
strapi.log.info(`app:i:order-controller: Payment captured successfully for order ${uuid}`);
return await sanitize.contentAPI.output(updatedOrder, strapi.getModel("api::order.order"));
} else {
strapi.log.error(`app:e:order-controller: Payment capture failed for order ${uuid}`, { captureResult });
return ctx.badRequest("Payment capture failed", { status: captureResult.status });
}
} catch (error) {
strapi.log.error(`app:e:order-controller: Error capturing payment for order ${uuid}`, { error });
return ctx.internalServerError("Payment capture failed");
} }
}, },
@@ -301,7 +245,6 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
strapi.log.info(`app:i:order-controller: Started invoice generation`); strapi.log.info(`app:i:order-controller: Started invoice generation`);
const orderUnsafe = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Order; const orderUnsafe = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Order;
if (!orderUnsafe) return ctx.notFound("Order not found");
if (!orderUnsafe.paymentAuthorised) { if (!orderUnsafe.paymentAuthorised) {
return ctx.locked("Payment not authorised, cannot generate invoice"); return ctx.locked("Payment not authorised, cannot generate invoice");
@@ -320,7 +263,6 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
strapi.log.info(`app:i:order-controller: Started delivery note generation`); strapi.log.info(`app:i:order-controller: Started delivery note generation`);
const orderUnsafe = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Order; const orderUnsafe = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Order;
if (!orderUnsafe) return ctx.notFound("Order not found");
if (!orderUnsafe.paymentAuthorised) { if (!orderUnsafe.paymentAuthorised) {
return ctx.locked("Payment not authorised, cannot generate delivery note"); return ctx.locked("Payment not authorised, cannot generate delivery note");
@@ -358,7 +300,7 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
try { try {
return await paypalApi.handleWebhook(ctx.request.body); return await paypalApi.handleWebhook(ctx.request.body);
} catch (error) { } catch (error) {
strapi.log.error("app:e:order-controller: Handling the PayPal webhook failed", { error: describeError(error) }); strapi.log.error(`paypal-api: ${JSON.stringify({ error })}`);
return ctx.internalServerError(); return ctx.internalServerError();
} }
} }
+1 -4
View File
@@ -1,7 +1,7 @@
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import { factories } from "@strapi/strapi"; import { factories } from "@strapi/strapi";
import { errors, sanitize } from "@strapi/utils"; import { sanitize } from "@strapi/utils";
import { ID } from "@strapi/database/dist/types"; import { ID } from "@strapi/database/dist/types";
import { vatDecimal, vatIncludedDecimal, baseUrl, adminEmail, shopName, shopContactName, shopAddress, shopPhone, shopEmail, shopLogoUrl, shopSecondaryLogoUrl } from "../../../../config/constants"; import { vatDecimal, vatIncludedDecimal, baseUrl, adminEmail, shopName, shopContactName, shopAddress, shopPhone, shopEmail, shopLogoUrl, shopSecondaryLogoUrl } from "../../../../config/constants";
import { calculateTotalProductPrice, productDefaultParams } from "../../product/services/product"; import { calculateTotalProductPrice, productDefaultParams } from "../../product/services/product";
@@ -32,7 +32,6 @@ export default factories.createCoreService("api::order.order", ({ strapi }) => (
where: { uuid }, where: { uuid },
...orderDefaultParams ...orderDefaultParams
}); });
if (!orderUnsafe) return null;
orderUnsafe.cart = orderUnsafe?.cart.map((item: CartProduct) => ({ orderUnsafe.cart = orderUnsafe?.cart.map((item: CartProduct) => ({
...item, ...item,
product: { product: {
@@ -81,7 +80,6 @@ export default factories.createCoreService("api::order.order", ({ strapi }) => (
sendInvoice: async (uuid: string, blob?: any) => { sendInvoice: async (uuid: string, blob?: any) => {
strapi.log.info(`app:i:order-service: - Sending invoice for order ${uuid}`); strapi.log.info(`app:i:order-service: - Sending invoice for order ${uuid}`);
const oderUnsafe = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Partial<Order>; const oderUnsafe = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Partial<Order>;
if (!oderUnsafe) throw new errors.NotFoundError("Order not found");
const shopContext = { shopName, shopContactName, shopAddress, shopPhone, shopEmail, shopLogoUrl, shopSecondaryLogoUrl }; const shopContext = { shopName, shopContactName, shopAddress, shopPhone, shopEmail, shopLogoUrl, shopSecondaryLogoUrl };
try { try {
@@ -121,7 +119,6 @@ export default factories.createCoreService("api::order.order", ({ strapi }) => (
sendDeliveryNote: async (uuid: string, blob?: ArrayBuffer) => { sendDeliveryNote: async (uuid: string, blob?: ArrayBuffer) => {
strapi.log.info(`app:i:order-service: - Sending delivery note for order ${uuid}`); strapi.log.info(`app:i:order-service: - Sending delivery note for order ${uuid}`);
const oderUnsafe = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Partial<Order>; const oderUnsafe = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Partial<Order>;
if (!oderUnsafe) throw new errors.NotFoundError("Order not found");
const shopContext = { shopName, shopContactName, shopAddress, shopPhone, shopEmail, shopLogoUrl, shopSecondaryLogoUrl }; const shopContext = { shopName, shopContactName, shopAddress, shopPhone, shopEmail, shopLogoUrl, shopSecondaryLogoUrl };
try { try {
const emailConfig: MessageBody = { const emailConfig: MessageBody = {
-155
View File
@@ -1,155 +0,0 @@
import { verifyApprovedOrder, verifyCompletedCapture } from "./paypal-verify";
import type { VerifiableOrder } from "./paypal-verify";
import { describeError } from "../logging/error";
// POST /orders/:uuid/capture/:paypalOrderId, which the shop calls once the buyer approved the payment in PayPal's window
// (libreshop/shop pages/checkout/3.vue, onApprove). The PayPal order id comes from the browser, so it must be the one the checkout
// stored on the order before PayPal is asked anything, and PayPal's answers are checked against the order before it is marked paid.
// The order controller passes Strapi and PayPalApi as the dependencies; the tests pass fakes.
/** The fields of the order the capture reads. */
export type CapturableOrder = VerifiableOrder & {
id: number | string;
paymentAuthorised?: boolean | null;
paypalOrderId?: string | null;
};
/** What a verified capture writes to the order. */
export type CapturedPayment = {
paymentAuthorised: true;
paymentStatus: "captured";
paypalCaptureId: string;
paymentCapturedAt: string;
};
export type CaptureLog = Record<"info" | "warn" | "error", (message: string, fields: Record<string, unknown>) => void>;
export type CapturePaymentDeps<O extends CapturableOrder, R> = {
/** The order with this uuid, or null. */
findOrder: (uuid: string) => Promise<O | null>;
/** PayPal's order as parsed JSON: GET /v2/checkout/orders/:id. */
getPayPalOrder: (paypalOrderId: string) => Promise<unknown>;
/** Captures the PayPal order and returns PayPal's order as parsed JSON: POST /v2/checkout/orders/:id/capture. */
capturePayPalOrder: (paypalOrderId: string, requestId: string) => Promise<unknown>;
/** Writes data to the order only while the order still carries this PayPal order id: the updated order, or null if it does not. */
updateOrder: (id: O["id"], paypalOrderId: string, data: CapturedPayment) => Promise<R | null>;
log: CaptureLog;
};
export type CapturePaymentResult<R> =
| { status: 200; alreadyCaptured: false; order: R }
| { status: 200; alreadyCaptured: true }
| { status: 404 | 409 | 500 | 502; message: string };
export const NOT_FOUND = "Order not found";
export const ALREADY_PAID = "Order is already paid";
export const NOT_THIS_ORDER = "Payment does not belong to this order";
export const NOT_MATCHING = "Payment does not match this order";
export const PAYPAL_FAILED = "PayPal could not capture the payment";
export const NOT_CONFIRMED = "Payment could not be confirmed";
export const NOT_RECORDED = "Payment could not be recorded";
export const ORDER_CHANGED = "Order changed during the payment";
/**
* The PayPal-Request-Id of the capture. PayPal answers a repeated id with the first capture's response instead of capturing again,
* so a capture that is retried, or sent twice, takes the money once.
*/
export const captureRequestId = (uuid: string, paypalOrderId: string): string => `capture-${uuid}-${paypalOrderId}`;
/**
* Captures the PayPal order the checkout created for the order and marks the order paid, in this order:
* 1. a missing order is answered 404;
* 2. a paid order is answered alreadyCaptured if the PayPal order is the one it was paid with, else 409, without a write;
* 3. a PayPal order the checkout did not store on the order is answered 409, without asking PayPal;
* 4. a PayPal order that is not APPROVED for this order's uuid, total and currency is answered 409, without a capture;
* 5. the capture must be COMPLETED for the order total in EUR, else the order stays unpaid and 502 is answered;
* 6. the payment is written only while the order still carries this PayPal order id, so a cart or address changed during the
* capture is not marked paid.
* PayPal and network errors are answered 502 and logged with name, message and status code only.
*/
export const capturePayment = async <O extends CapturableOrder, R>(
deps: CapturePaymentDeps<O, R>,
{ uuid, paypalOrderId, now }: { uuid: string; paypalOrderId: string; now: Date }
): Promise<CapturePaymentResult<R>> => {
const { log } = deps;
// An empty uuid finds no order. Strapi would drop an undefined condition and find any order.
const order = typeof uuid === "string" && uuid !== "" ? await deps.findOrder(uuid) : null;
if (!order) return { status: 404, message: NOT_FOUND };
const isStoredPayPalOrder = typeof order.paypalOrderId === "string" && order.paypalOrderId !== "" && order.paypalOrderId === paypalOrderId;
if (order.paymentAuthorised) {
if (isStoredPayPalOrder) return { status: 200, alreadyCaptured: true };
log.warn("Rejected a capture for a paid order with another PayPal order", { order: order.id });
return { status: 409, message: ALREADY_PAID };
}
if (!isStoredPayPalOrder) {
log.warn("Rejected a capture with a PayPal order the checkout did not create for the order", {
order: order.id,
hasPayPalOrder: typeof order.paypalOrderId === "string" && order.paypalOrderId !== ""
});
return { status: 409, message: NOT_THIS_ORDER };
}
let paypalOrder: unknown;
try {
paypalOrder = await deps.getPayPalOrder(paypalOrderId);
} catch (error) {
log.error("Could not get the PayPal order", { order: order.id, error: describeError(error) });
return { status: 502, message: PAYPAL_FAILED };
}
const approved = verifyApprovedOrder(order, paypalOrder);
if (approved.ok === false) {
log.warn("Rejected a capture: the PayPal order does not match the order", { order: order.id, reason: approved.reason });
return { status: 409, message: NOT_MATCHING };
}
let captured: unknown;
try {
captured = await deps.capturePayPalOrder(paypalOrderId, captureRequestId(order.uuid, paypalOrderId));
} catch (error) {
log.error("PayPal did not capture the payment", { order: order.id, error: describeError(error) });
return { status: 502, message: PAYPAL_FAILED };
}
const capture = verifyCompletedCapture(order, captured);
if (capture.ok === false) {
// PayPal answered the capture, so it may have taken the money: the PayPal order needs a manual check, and a refund if it was paid.
log.error("The PayPal capture does not match the order, which stays unpaid: check the PayPal order for a refund", {
order: order.id,
reason: capture.reason
});
return { status: 502, message: NOT_CONFIRMED };
}
const data: CapturedPayment = {
paymentAuthorised: true,
paymentStatus: "captured",
paypalCaptureId: capture.captureId,
paymentCapturedAt: now.toISOString()
};
let updated: R | null;
try {
updated = await deps.updateOrder(order.id, paypalOrderId, data);
} catch (error) {
log.error("PayPal captured the payment, but the order could not be marked paid: check the order", {
order: order.id,
capture: capture.captureId,
error: describeError(error)
});
return { status: 500, message: NOT_RECORDED };
}
if (updated === null || updated === undefined) {
log.error("PayPal captured the payment, but the order changed during the capture and stays unpaid: check the order for a refund", {
order: order.id,
capture: capture.captureId
});
return { status: 409, message: ORDER_CHANGED };
}
log.info("Captured the payment", { order: order.id, capture: capture.captureId });
return { status: 200, alreadyCaptured: false, order: updated };
};
-146
View File
@@ -1,146 +0,0 @@
// What a customer may write to an order: the fields of the shop's checkout steps, and nothing else.
// The shop forwards the browser's body unchanged (libreshop/shop server/api/orders/[uuid].put.ts), so the body is untrusted.
// Step 1 (pages/checkout/1.vue) sends { data: { email, acceptedTermsAndConditionsAt } }, step 2 (pages/checkout/2.vue) sends
// { data: { invoiceAddress, deliveryAddress, invoiceAddressStructured, deliveryAddressStructured, delivery } }.
// Payment state, totals, numbers, documents, the cart and the customer are written by the server alone. A body that sends any
// of them is rejected as a whole instead of stripped, so a client that relies on such a write fails visibly.
/** The attributes of the address.structured-address component (src/components/address/structured-address.json). */
const STRUCTURED_ADDRESS_FIELDS = ["givenName", "familyName", "streetAddress", "postalCode", "addressLevel2", "country"] as const;
export type CustomerStructuredAddress = Partial<Record<(typeof STRUCTURED_ADDRESS_FIELDS)[number], string | null>>;
/** The order fields a customer may set, each optional. */
export type CustomerUpdate = {
email?: string;
acceptedTermsAndConditionsAt?: string;
invoiceAddress?: string;
deliveryAddress?: string;
invoiceAddressStructured?: CustomerStructuredAddress;
deliveryAddressStructured?: CustomerStructuredAddress;
delivery?: number;
};
/**
* errors: one message per rejected field, which names the field and never repeats its value.
* fields: the names of the rejected fields alone, for the log.
*/
export type CustomerUpdateResult = { ok: true; data: CustomerUpdate } | { ok: false; errors: string[]; fields: string[] };
// The longest address an SMTP path of 256 octets, angle brackets included, can carry (RFC 5321).
const MAX_EMAIL_LENGTH = 254;
const MAX_ADDRESS_LENGTH = 500;
const MAX_ADDRESS_COMPONENT_LENGTH = 200;
// Strapi's ids are Postgres integer columns: a larger number makes the query fail instead of finding nothing.
const MAX_ID = 2147483647;
// Rejected keys are chosen by the client and end up in the response and the log, so a long one is cut.
const MAX_KEY_LENGTH = 64;
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const keyName = (key: string): string => (key.length > MAX_KEY_LENGTH ? `${key.slice(0, MAX_KEY_LENGTH)}…` : key);
const isStructuredAddressField = (key: string): key is (typeof STRUCTURED_ADDRESS_FIELDS)[number] =>
(STRUCTURED_ADDRESS_FIELDS as readonly string[]).includes(key);
// An "@" with something on both sides, and no whitespace or control character: the invoice is mailed to this address.
const isEmail = (email: string): boolean => {
const at = email.lastIndexOf("@");
return email.length <= MAX_EMAIL_LENGTH && at > 0 && at < email.length - 1 && !/[\s\u0000-\u001f\u007f]/.test(email);
};
const isText = (value: unknown, maxLength: number): value is string => typeof value === "string" && value.length <= maxLength;
// The shop's form sends every component as a string (pages/checkout/2.vue, mapToStructuredAddress), but it fills the form from
// the stored address, whose unset components Strapi returns as null. Every component of the address is optional.
const isAddressComponent = (value: unknown): value is string | null => value === null || isText(value, MAX_ADDRESS_COMPONENT_LENGTH);
/**
* Checks the body of PUT /orders/:uuid/cart. Returns the fields to write, or why the body is rejected.
* `now` is the server's time, recorded as the time the customer accepted the terms.
*/
export const parseCustomerUpdate = (body: unknown, now: Date): CustomerUpdateResult => {
const rejected: { field: string; reason: string }[] = [];
const reject = (field: string, reason: string) => {
rejected.push({ field, reason });
};
const data: CustomerUpdate = {};
const parseStructuredAddress = (field: string, value: unknown): CustomerStructuredAddress | undefined => {
if (!isObject(value)) {
reject(field, "must be an object");
return undefined;
}
const address: CustomerStructuredAddress = {};
let valid = true;
for (const key of Object.keys(value)) {
const component = value[key];
if (!isStructuredAddressField(key)) {
reject(`${field}.${keyName(key)}`, "not a structured address field");
valid = false;
} else if (isAddressComponent(component)) {
address[key] = component;
} else {
reject(`${field}.${key}`, `must be a string of at most ${MAX_ADDRESS_COMPONENT_LENGTH} characters, or null`);
valid = false;
}
}
return valid ? address : undefined;
};
if (!isObject(body)) {
reject("body", "must be an object of the form { data: { … } }");
} else {
for (const key of Object.keys(body)) {
if (key !== "data") reject(keyName(key), "not accepted, the fields belong in data");
}
const fields = body.data;
if (fields === undefined) {
reject("data", "missing");
} else if (!isObject(fields)) {
reject("data", "must be an object");
} else {
for (const key of Object.keys(fields)) {
const value = fields[key];
switch (key) {
case "email": {
const email = typeof value === "string" ? value.trim() : "";
if (isEmail(email)) data.email = email;
else reject("data.email", `must be an email address of at most ${MAX_EMAIL_LENGTH} characters`);
break;
}
case "acceptedTermsAndConditionsAt":
// The invoice is dated by this time: it is the Ausstellungsdatum in the invoice PDF (the order service's
// defaultPdfBody) and in the invoice email (src/templates/emailTemplates.ts). The client's clock and value
// are ignored, so that no client can backdate or postdate an invoice.
if (value) data.acceptedTermsAndConditionsAt = now.toISOString();
else reject("data.acceptedTermsAndConditionsAt", "must be sent only to accept the terms, as true or a timestamp");
break;
case "invoiceAddress":
case "deliveryAddress":
if (isText(value, MAX_ADDRESS_LENGTH)) data[key] = value;
else reject(`data.${key}`, `must be a string of at most ${MAX_ADDRESS_LENGTH} characters`);
break;
case "invoiceAddressStructured":
case "deliveryAddressStructured": {
const address = parseStructuredAddress(`data.${key}`, value);
if (address) data[key] = address;
break;
}
case "delivery":
if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= MAX_ID) data.delivery = value;
else reject("data.delivery", "must be the id of a delivery method, a positive integer");
break;
default:
reject(`data.${keyName(key)}`, "not a field the customer may set");
}
}
}
}
if (rejected.length > 0) {
return { ok: false, errors: rejected.map(({ field, reason }) => `${field}: ${reason}`), fields: rejected.map(({ field }) => field) };
}
return { ok: true, data };
};
-27
View File
@@ -1,27 +0,0 @@
/** The fields of an order that decide whether the customer may still change it. */
export type OrderLockState = {
paymentAuthorised?: boolean | null;
};
/**
* Once its payment is authorised, the order is what the customer paid for and what the invoice is generated from,
* so the customer can no longer change its contact details, addresses, delivery method or cart.
*/
export const isLockedForCustomer = (order: OrderLockState): boolean => Boolean(order.paymentAuthorised);
/** The fields of an order a checkout needs: the contact and address steps of the shop's checkout, and a delivery method. */
export type CheckoutFields = {
email?: string | null;
invoiceAddress?: string | null;
/** The populated delivery method, or null. */
delivery?: unknown;
};
/** The names of the fields the order still lacks for a checkout, in this order: email, invoiceAddress, delivery. [] if none. */
export const missingCheckoutFields = (order: CheckoutFields): string[] => {
const missing: string[] = [];
if (typeof order.email !== "string" || order.email.trim() === "") missing.push("email");
if (typeof order.invoiceAddress !== "string" || order.invoiceAddress.trim() === "") missing.push("invoiceAddress");
if (order.delivery === null || order.delivery === undefined) missing.push("delivery");
return missing;
};
-96
View File
@@ -1,96 +0,0 @@
import type { Configuration, Environment } from "@paypal/paypal-server-sdk";
// The PayPal SDK client behind PayPalApi, created through a factory so that it can be replaced: PayPalApi passes one that creates
// the SDK's Client and OrdersController, the tests a fake.
//
// The SDK keeps the promise of its OAuth token for the lifetime of a Client, also when the token request failed: every later call
// awaits the same rejected promise and fails with the same error without a request, until the process restarts. One failed token
// request, during a DNS outage for example, stopped every checkout until a restart.
/** The SDK's default timeout is 0, which means none: a call to PayPal that hangs would hang the checkout with it. */
export const PAYPAL_TIMEOUT_MS = 15000;
/**
* The configuration of the SDK's Client. The SDK logs method, URL and status of each request to the console, but no bodies, since
* a request body holds the buyer's name and address, and no headers, since they hold the token.
*/
export const paypalClientConfiguration = ({
clientId,
clientSecret,
environment
}: {
clientId: string;
clientSecret: string;
environment: Environment;
}): Partial<Configuration> => ({
clientCredentialsAuthCredentials: {
oAuthClientId: clientId,
oAuthClientSecret: clientSecret
},
timeout: PAYPAL_TIMEOUT_MS,
environment,
logging: {
logRequest: { logBody: false, logHeaders: false },
logResponse: { logBody: false, logHeaders: false }
}
});
const TOKEN_PATH = "/v1/oauth2/token";
/** The URL of the request the error belongs to: an error of the SDK carries the request, an axios error its config. */
const requestUrl = (error: object): string => {
const { request, config } = error as { request?: { url?: unknown }; config?: { url?: unknown } };
const url = request?.url ?? config?.url;
return typeof url === "string" ? url : "";
};
/**
* Whether the error may leave the client unusable, so that the next call needs a new one: any error of the token request, which
* the SDK keeps; an error without an answer from PayPal (network, DNS, timeout); 401, a token PayPal no longer accepts; and 5xx.
* PayPal's other answers, such as 422 for an order it rejects, leave the client as it is.
*/
export const breaksClient = (error: unknown): boolean => {
if (typeof error !== "object" || error === null) return true;
const { statusCode } = error as { statusCode?: unknown };
if (typeof statusCode !== "number") return true;
if (statusCode === 401 || statusCode >= 500) return true;
return requestUrl(error).includes(TOKEN_PATH);
};
export type PayPalClient<C> = {
/** Runs the request on the current controller. An error that breaksClient replaces the controller for the calls after it. */
call<T>(request: (controller: C) => Promise<T>): Promise<T>;
};
/**
* Creates the controller on the first call, and a new one, with a new client and token, on the first call after an error that
* breaksClient. onReplace is called with that error.
*/
export const createPayPalClient = <C>({
configuration,
createController,
onReplace
}: {
configuration: Partial<Configuration>;
createController: (configuration: Partial<Configuration>) => C;
onReplace?: (error: unknown) => void;
}): PayPalClient<C> => {
let current: C | undefined;
return {
async call<T>(request: (controller: C) => Promise<T>): Promise<T> {
current ??= createController(configuration);
const controller = current;
try {
return await request(controller);
} catch (error) {
// A call that failed on a controller which was replaced meanwhile leaves its successor alone.
if (breaksClient(error) && current === controller) {
current = undefined;
onReplace?.(error);
}
throw error;
}
}
};
};
+73 -193
View File
@@ -1,30 +1,20 @@
import type { CustomerStructuredAddress } from "./customer-update"; import type { Order } from "../../types";
import { calculateTotalProductPrice } from "./pricing";
import { vatIncludedDecimal } from "./vat"; import { vatIncludedDecimal } from "./vat";
// The PayPal order that PayPalApi.createSessionOrThrow creates, built without the PayPal SDK so it can be unit tested. // The PayPal order that PayPalApi.createSessionOrThrow creates, built without the PayPal SDK so it can be unit tested.
// String literals stand in for the SDK enums, with the values of @paypal/paypal-server-sdk 0.6.1. // String literals stand in for the SDK enums, with the values of @paypal/paypal-server-sdk 0.6.1.
//
// The request sends no items. With items, PayPal requires item_total to be the sum of unit_amount × quantity and tax_total the
// sum of tax × quantity, and amounts rounded per unit miss the net and the VAT of the subtotal, which the invoice states:
// 3 × 12.50 gives 3 × 10.50 = 31.50 net and 3 × 2.00 = 6.00 tax, the subtotal of 37.50 gives 31.51 net and 5.99 VAT.
export type PayPalMoney = { export type PayPalMoney = {
currencyCode: string; currencyCode: string;
value: string; value: string;
}; };
export type PayPalShipping = { export type PayPalOrderItem = {
name: { fullName: string }; name: string;
address: { addressLine1: string; addressLine2?: string; postalCode: string; adminArea2: string; countryCode: string }; unitAmount: PayPalMoney;
}; tax: PayPalMoney;
quantity: string;
export type PayPalBreakdown = {
itemTotal: PayPalMoney;
taxTotal: PayPalMoney;
/** Undefined for an order without a delivery method. */
shipping: PayPalMoney | undefined;
/** The surcharge of the payment method, only when it is above 0. */
handling?: PayPalMoney;
}; };
export type PayPalOrderRequest = { export type PayPalOrderRequest = {
@@ -32,8 +22,17 @@ export type PayPalOrderRequest = {
intent: "CAPTURE"; intent: "CAPTURE";
purchaseUnits: { purchaseUnits: {
referenceId: string; referenceId: string;
amount: PayPalMoney & { breakdown: PayPalBreakdown }; amount: PayPalMoney & {
shipping: PayPalShipping; breakdown: {
itemTotal: PayPalMoney;
taxTotal: PayPalMoney;
shipping: PayPalMoney | undefined;
};
};
shipping: {
name: { fullName: string };
address: { addressLine1: string; postalCode: string; adminArea2: string; countryCode: string };
};
customId: string; customId: string;
invoiceId: string; invoiceId: string;
}[]; }[];
@@ -46,191 +45,73 @@ export type PayPalOrderRequest = {
}; };
/** /**
* The fields of the order the request is built from, as the checkout reads the order (strapi.db.query findOne with * The part of PayPalApi.createSessionOrThrow that runs before its try block, so nothing thrown here is logged there.
* orderDefaultParams). Each of them may be null. Prices are decimals, which Strapi returns as numbers. * 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.
*/ */
export type PayPalOrderSource = { export const buildPayPalOrderItems = (order: Order): PayPalOrderItem[] => {
uuid: string; if (!order.total) throw new Error("Cart is empty or has no total");
invoiceNumber?: string | null;
subtotal?: number | string | null;
total?: number | string | null;
VAT?: number | string | null;
delivery?: { price?: number | string | null } | null;
payment?: { price?: number | string | null } | null;
cart?: ({ product?: unknown } | null)[] | null;
deliveryAddress?: string | null;
deliveryAddressStructured?: CustomerStructuredAddress | null;
invoiceAddressStructured?: CustomerStructuredAddress | null;
};
/** The order lacks something PayPal needs, or its totals do not add up, so the customer has to complete or change it first. */ return order.cart.map((item) => {
export class CheckoutNotReadyError extends Error { const totalProductPrice = calculateTotalProductPrice(item.product);
constructor(message: string) { const amount = totalProductPrice / vatIncludedDecimal;
super(message); const tax = totalProductPrice - amount;
this.name = "CheckoutNotReadyError";
}
}
/** A cart line whose product was deleted. */ return {
export class ProductUnavailableError extends Error { name: item.product.name,
constructor(message: string) { unitAmount: {
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", currencyCode: "EUR",
value: `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}` value: amount.toFixed(2)
}); },
tax: {
// vatIncludedDecimal as a whole percentage (119), so that the net of the subtotal is a quotient of whole numbers. currencyCode: "EUR",
const VAT_INCLUDED_PERCENT = Math.round(vatIncludedDecimal * 100); value: tax.toFixed(2)
},
/** quantity: item.count.toString()
* The breakdown of the order's total: the net of its subtotal, its VAT, the price of its delivery method, 0 when that price is not
* set, and the surcharge of its payment method as handling, which the order service adds to the total. 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 surchargeCents = toCents(order.payment?.price) ?? 0;
const parts = [itemTotalCents, taxCents, shippingCents ?? 0, surchargeCents];
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} + surcharge ${surchargeCents}`
);
}
return {
itemTotal: money(itemTotalCents),
taxTotal: money(taxCents),
shipping: shippingCents === undefined ? undefined : money(shippingCents),
...(surchargeCents > 0 ? { handling: money(surchargeCents) } : {})
}; };
}; });
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 shipping address from a text address as the shop writes it (libreshop/shop pages/checkout/2.vue, addressToString): the name, * The order request body PayPalApi.createSessionOrThrow sends. It is built inside that try block, so whatever throws here is logged there.
* 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.
*/ */
const fromText = (field: string, address: string): PayPalShipping => { export const buildPayPalOrderRequest = (returnUrl: string, order: Order): PayPalOrderRequest => ({
const lines = address
.split(/\r\n|\r|\n/)
.map((line) => line.trim())
.filter((line) => line !== "");
if (lines.length < 3) throw new CheckoutNotReadyError(`${field} needs the name, the street, and the postal code and city on lines of their own`);
const city = /^(\S+)\s+(\S.*)$/.exec(lines[lines.length - 1]);
if (city === null) throw new CheckoutNotReadyError(`The last line of ${field} is not the postal code and the city`);
const addressLine2 = lines.slice(1, -2).join(", ");
return {
name: { fullName: lines[0] },
address: {
addressLine1: lines[lines.length - 2],
...(addressLine2 !== "" ? { addressLine2 } : {}),
postalCode: city[1],
adminArea2: city[2],
countryCode: "DE"
}
};
};
/**
* The address PayPal shows the buyer as the shipping address: the structured delivery address, else the structured invoice address,
* else the text delivery address. The shop sends the invoice address as the delivery address when the buyer gives no other. An
* address that is entered but incomplete is rejected rather than replaced by the next one.
*/
const shippingFor = (order: PayPalOrderSource): PayPalShipping => {
if (isEntered(order.deliveryAddressStructured)) return fromStructured("deliveryAddressStructured", order.deliveryAddressStructured);
if (isEntered(order.invoiceAddressStructured)) return fromStructured("invoiceAddressStructured", order.invoiceAddressStructured);
if (text(order.deliveryAddress) !== "") return fromText("deliveryAddress", order.deliveryAddress);
throw new CheckoutNotReadyError("The order has no delivery address");
};
/**
* The order request body PayPalApi.createSessionOrThrow sends. Throws ProductUnavailableError for a cart line whose product was
* deleted, and CheckoutNotReadyError for an empty cart, a missing total, totals that do not add up or a missing or unusable
* delivery address. The messages name fields and amounts, never an address.
*/
export const buildPayPalOrderRequest = (returnUrl: string, order: PayPalOrderSource): PayPalOrderRequest => {
const cart = Array.isArray(order.cart) ? order.cart : [];
const totalCents = toCents(order.total);
if (cart.length === 0 || totalCents === null || totalCents <= 0) throw new CheckoutNotReadyError("Cart is empty or has no total");
if (cart.some((line) => !line?.product)) throw new ProductUnavailableError(PRODUCT_UNAVAILABLE);
return {
intent: "CAPTURE", intent: "CAPTURE",
purchaseUnits: [ purchaseUnits: [
{ {
// items: computed by buildPayPalOrderItems, but not sent
referenceId: order.uuid, referenceId: order.uuid,
amount: { ...money(totalCents), breakdown: breakdownFor(order, totalCents) }, amount: {
shipping: shippingFor(order), 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, customId: order.uuid,
invoiceId: order.invoiceNumber invoiceId: order.invoiceNumber
} }
@@ -240,5 +121,4 @@ export const buildPayPalOrderRequest = (returnUrl: string, order: PayPalOrderSou
cancelUrl: returnUrl, cancelUrl: returnUrl,
shippingPreference: "SET_PROVIDED_ADDRESS" shippingPreference: "SET_PROVIDED_ADDRESS"
} }
}; });
};
-104
View File
@@ -1,104 +0,0 @@
// Checks PayPal's answers against the shop order before the order is marked paid: the PayPal order the buyer approved
// (GET /v2/checkout/orders/:id) and the order after its capture (POST /v2/checkout/orders/:id/capture, Prefer: return=representation).
// The JSON is PayPal's Orders v2 JSON in snake_case, as PayPalApi parses the raw response body. Money is compared in integer cents.
// A reason says what does not match. It repeats only PayPal's statuses, currency codes and amounts, never a name, an email address
// or an address from the response, because it is logged.
/** The fields of the shop order the checks compare. */
export type VerifiableOrder = {
uuid: string;
total: number | null;
};
export type ApprovedOrderCheck = { ok: true } | { ok: false; reason: string };
export type CompletedCaptureCheck = { ok: true; captureId: string } | { ok: false; reason: string };
const CURRENCY = "EUR";
type Json = Record<string, unknown>;
const isObject = (value: unknown): value is Json => typeof value === "object" && value !== null && !Array.isArray(value);
/** PayPal's money value as integer cents: "12.34" is 1234. Null for anything but a non-negative amount with at most two decimals. */
export const parseCents = (value: unknown): number | null => {
if (typeof value !== "string") return null;
const match = /^(\d{1,10})(?:\.(\d{1,2}))?$/.exec(value);
if (match === null) return null;
return Number(match[1]) * 100 + Number((match[2] ?? "").padEnd(2, "0"));
};
/** The order total in integer cents, or null if the order has no positive total. Strapi returns the decimal column as a number. */
export const orderTotalCents = (order: { total: number | null }): number | null =>
typeof order.total === "number" && Number.isFinite(order.total) && order.total > 0 ? Math.round(order.total * 100) : null;
const formatCents = (cents: number): string => `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
// The values of OrderStatus and CaptureStatus in @paypal/paypal-server-sdk 0.6.1. A reason repeats these and ISO currency codes,
// and no other value from the response.
const ORDER_STATUSES = ["CREATED", "SAVED", "APPROVED", "VOIDED", "COMPLETED", "PAYER_ACTION_REQUIRED"];
const CAPTURE_STATUSES = ["COMPLETED", "DECLINED", "PARTIALLY_REFUNDED", "PENDING", "REFUNDED", "FAILED"];
const shown = (value: unknown, isKnown: (value: string) => boolean): string =>
typeof value === "string" && isKnown(value) ? value : "missing or unexpected";
const orderStatus = (value: unknown): string => shown(value, (status) => ORDER_STATUSES.includes(status));
const captureStatus = (value: unknown): string => shown(value, (status) => CAPTURE_STATUSES.includes(status));
const currency = (value: unknown): string => shown(value, (code) => /^[A-Z]{3}$/.test(code));
const firstPurchaseUnit = (paypalOrder: Json): Json | null => {
const units = paypalOrder.purchase_units;
return Array.isArray(units) && isObject(units[0]) ? units[0] : null;
};
/** Why PayPal's money ({ currency_code, value }) is not the order total in EUR, or null if it is. */
const amountMismatch = (subject: string, money: unknown, order: VerifiableOrder): string | null => {
if (!isObject(money)) return `${subject} has no amount`;
if (money.currency_code !== CURRENCY) return `${subject} currency is ${currency(money.currency_code)}, not ${CURRENCY}`;
const cents = parseCents(money.value);
if (cents === null) return `${subject} amount is not a money value`;
const total = orderTotalCents(order);
if (total === null) return "the order has no total";
if (cents !== total) return `${subject} amount ${formatCents(cents)} is not the order total ${formatCents(total)}`;
return null;
};
/**
* The PayPal order may be captured for this order: the buyer approved it, it was created for this order (its custom_id is the
* order's uuid, see buildPayPalOrderRequest), and its amount is the order total in EUR.
*/
export const verifyApprovedOrder = (order: VerifiableOrder, paypalOrder: unknown): ApprovedOrderCheck => {
if (!isObject(paypalOrder)) return { ok: false, reason: "the PayPal order is not an object" };
if (paypalOrder.status !== "APPROVED") {
return { ok: false, reason: `the PayPal order status is ${orderStatus(paypalOrder.status)}, not APPROVED` };
}
const unit = firstPurchaseUnit(paypalOrder);
if (unit === null) return { ok: false, reason: "the PayPal order has no purchase unit" };
if (typeof order.uuid !== "string" || order.uuid === "" || unit.custom_id !== order.uuid) {
return { ok: false, reason: "the custom_id of the PayPal order is not the order's uuid" };
}
const mismatch = amountMismatch("the PayPal order", unit.amount, order);
return mismatch === null ? { ok: true } : { ok: false, reason: mismatch };
};
/**
* PayPal captured the order total in EUR: the PayPal order and its first capture are COMPLETED. Returns the capture's id.
* A capture PayPal holds for review (PENDING) or declined (DECLINED) is not a payment.
*/
export const verifyCompletedCapture = (order: VerifiableOrder, captured: unknown): CompletedCaptureCheck => {
if (!isObject(captured)) return { ok: false, reason: "the capture response is not an object" };
if (captured.status !== "COMPLETED") return { ok: false, reason: `the PayPal order status is ${orderStatus(captured.status)}, not COMPLETED` };
const unit = firstPurchaseUnit(captured);
const payments = unit !== null && isObject(unit.payments) ? unit.payments : null;
const capture = payments !== null && Array.isArray(payments.captures) && isObject(payments.captures[0]) ? payments.captures[0] : null;
if (capture === null) return { ok: false, reason: "the capture response has no capture" };
if (capture.status !== "COMPLETED") return { ok: false, reason: `the capture status is ${captureStatus(capture.status)}, not COMPLETED` };
const mismatch = amountMismatch("the capture", capture.amount, order);
if (mismatch !== null) return { ok: false, reason: mismatch };
if (typeof capture.id !== "string" || !/^[A-Za-z0-9-]{1,64}$/.test(capture.id)) return { ok: false, reason: "the capture has no valid id" };
return { ok: true, captureId: capture.id };
};
-30
View File
@@ -1,30 +0,0 @@
/** What is logged of an error. */
export type ErrorSummary = {
name: string;
message: string;
statusCode: number | undefined;
};
// A message is cut to its first line and to this length: the PayPal SDK's ResponseValidationError quotes the response values it
// rejected on the lines after its first.
const MAX_LENGTH = 200;
const cut = (text: string): string => {
const line = text.split("\n")[0];
return line.length > MAX_LENGTH ? `${line.slice(0, MAX_LENGTH)}…` : line;
};
/**
* The name, the first line of the message and the HTTP status code of an error, and nothing else. An error of the PayPal SDK
* (ApiError) or of axios carries its request, whose Authorization header holds the PayPal client credentials or an access token,
* so the error object itself is never logged.
*/
export const describeError = (error: unknown): ErrorSummary => {
if (typeof error !== "object" || error === null) return { name: typeof error, message: "", statusCode: undefined };
const { name, message, statusCode } = error as { name?: unknown; message?: unknown; statusCode?: unknown };
return {
name: typeof name === "string" ? cut(name) : "object",
message: typeof message === "string" ? cut(message) : "",
statusCode: typeof statusCode === "number" ? statusCode : undefined
};
};
+4 -72
View File
@@ -1,5 +1,3 @@
import { describeError } from "./error";
/** What winston hands a printf template (logform's TransformableInfo); Strapi's default format has added `timestamp`. */ /** What winston hands a printf template (logform's TransformableInfo); Strapi's default format has added `timestamp`. */
export type LogInfo = { export type LogInfo = {
level: string; level: string;
@@ -7,76 +5,10 @@ export type LogInfo = {
[key: string | symbol]: unknown; [key: string | symbol]: unknown;
}; };
export type LogFormatOptions = { /** One console line: `<timestamp>Z <level>: <message> <all other fields as indented JSON, if any>`. */
/** Writes the stack of an error value too. config/logger.ts sets it at the debug level, the local one. */ export const formatLogLine = ({ timestamp, level, message, ...rest }: LogInfo): string => {
stack?: boolean; let restString = JSON.stringify(rest, undefined, 2);
};
// A field whose name matches holds a credential or a session, at any depth: an axios error carries the request headers in
// config.headers, among them "Authorization: Basic <base64 of the PayPal client id and secret>".
const SENSITIVE_KEY = /authorization|cookie|password|secret|token|api[-_]?key/i;
const REDACTED = "[redacted]";
/**
* A copy of the value that JSON.stringify writes without throwing and without credentials:
* - a field whose name matches SENSITIVE_KEY becomes "[redacted]";
* - an Error becomes { name, message } as describeError has them, the first line of the message only, plus the stack if asked;
* - an object that contains itself is written once, the reference to it as "[Circular]";
* - an instance of a class other than Object and Array is written as its toJSON() if it has one, else as "[ClassName]": a request
* or socket object keeps the request headers, and with them the credentials, in fields such as _header;
* - a bigint, which JSON.stringify throws on, becomes its digits, and a value that cannot be read becomes "[unreadable]".
*/
const toLoggable = (value: unknown, options: LogFormatOptions, ancestors: object[]): unknown => {
if (typeof value === "bigint") return value.toString();
if (typeof value !== "object" || value === null) return value;
if (ancestors.includes(value)) return "[Circular]";
ancestors.push(value);
try {
if (value instanceof Error) {
const { name, message } = describeError(value);
return options.stack && typeof value.stack === "string" ? { name, message, stack: value.stack } : { name, message };
}
if (Array.isArray(value)) return value.map((item) => toLoggable(item, options, ancestors));
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
const { toJSON } = value as { toJSON?: unknown };
if (typeof toJSON === "function") return toLoggable(toJSON.call(value), options, ancestors);
const name = (value as { constructor?: { name?: unknown } }).constructor?.name;
return `[${typeof name === "string" && name !== "" ? name : "object"}]`;
}
const copy: Record<string, unknown> = {};
for (const key of Object.keys(value)) {
const field = (value as Record<string, unknown>)[key];
// Dropped as JSON.stringify drops them; a function named toJSON would also be called on the copy.
if (typeof field === "function") continue;
copy[key] = SENSITIVE_KEY.test(key) ? REDACTED : toLoggable(field, options, ancestors);
}
return copy;
} catch {
return "[unreadable]";
} finally {
ancestors.pop();
}
};
const asText = (value: unknown): string => {
try {
return String(value);
} catch {
return "[unreadable]";
}
};
/**
* One console line: `<timestamp>Z <level>: <message> <all other fields as indented JSON, if any>`. Never throws: a log call that
* throws loses the line, and in Strapi's error middleware the error it was logging.
*/
export const formatLogLine = ({ timestamp, level, message, ...rest }: LogInfo, options: LogFormatOptions = {}): string => {
let restString = JSON.stringify(toLoggable(rest, options, []), undefined, 2);
restString = restString === "{}" ? "" : restString; restString = restString === "{}" ? "" : restString;
return `${asText(timestamp)}Z ${asText(level)}: ${asText(message)} ${restString}`; return `${timestamp}Z ${level}: ${message} ${restString}`;
}; };
+55 -59
View File
@@ -1,80 +1,76 @@
import { Client, OrdersController } from "@paypal/paypal-server-sdk"; import { Client, Environment, LogLevel, OrdersController, OrderRequest } from "@paypal/paypal-server-sdk";
import type { Environment, OrderRequest } from "@paypal/paypal-server-sdk";
import { paypalClientId, paypalClientSecret, paypalEnvironment } from "../../config/constants"; import { paypalClientId, paypalClientSecret, paypalEnvironment } from "../../config/constants";
import { createPayPalClient, paypalClientConfiguration } from "../checkout/paypal-client"; import { Order } from "../../types";
import type { PayPalClient } from "../checkout/paypal-client"; 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 { class PayPalApi {
// A new SDK Client and OrdersController after a failed token request, a network error, 401 or 5xx: see src/checkout/paypal-client.ts. private readonly client: Client;
private readonly orders: PayPalClient<OrdersController>; private readonly ordersController: OrdersController;
constructor(clientId: string, clientSecret: string, environment: Environment) { constructor(clientId: string, clientSecret: string, environment: Environment) {
this.orders = createPayPalClient({ this.client = new Client({
configuration: paypalClientConfiguration({ clientId, clientSecret, environment }), clientCredentialsAuthCredentials: {
createController: (configuration) => new OrdersController(new Client(configuration)), oAuthClientId: clientId,
onReplace: (error) => oAuthClientSecret: clientSecret
strapi.log.warn("app:w:paypal-api: Replacing the PayPal client after a failed request", { error: describeError(error) }) },
timeout: 0,
environment,
logging: {
logLevel: LogLevel.Info,
logRequest: { logBody: true },
logResponse: { logHeaders: true }
}
}); });
this.ordersController = new OrdersController(this.client);
} }
/** async createSessionOrThrow(returnUrl: string, order: Order) {
* Creates the PayPal order for the order and returns PayPal's id for it, which the capture accepts, and its links. Throws // Outside the try block, so these throws are not logged here. The items are computed but not sent.
* CheckoutNotReadyError or ProductUnavailableError before PayPal is asked, for an order PayPal cannot take. buildPayPalOrderItems(order);
*/
async createSessionOrThrow(returnUrl: string, order: PayPalOrderSource & { id?: number | string }): Promise<{ id: string; links: unknown }> {
// Outside the try block: an order that is not ready is the customer's to fix, not a PayPal failure.
const request = buildPayPalOrderRequest(returnUrl, order) as OrderRequest;
try { try {
const { body } = await this.orders.call((orders) => orders.ordersCreate({ body: request, prefer: "return=minimal" })); const collect = {
const session = JSON.parse(body as string); body: buildPayPalOrderRequest(returnUrl, order) as OrderRequest,
if (typeof session?.id !== "string" || session.id === "") throw new Error("PayPal answered without an order id"); prefer: "return=minimal"
};
// The PayPal order's id and status only: the request and PayPal's order hold the buyer's name and address. strapi.log.silly("app:d:paypal-api: Creating PayPal order " + JSON.stringify({ collect }));
strapi.log.info("app:i:paypal-api: Created the PayPal order", {
order: order.id, const { body, ...httpResponse } = await this.ordersController.ordersCreate(collect);
paypalOrderId: session.id, const sessionData = JSON.parse(body as string);
status: typeof session.status === "string" ? session.status : undefined
}); strapi.log.info("app:i:paypal-api: " + JSON.stringify({ session: sessionData }));
return { id: session.id, links: session.links };
return {
id: sessionData.id,
links: sessionData.links
};
} catch (error) { } catch (error) {
// Never the error object: an SDK or axios error carries the request with its Authorization header. strapi.log.error("app:e:paypal-api: " + JSON.stringify({ error }));
strapi.log.error("app:e:paypal-api: Creating the PayPal order failed", { order: order.id, error: describeError(error) });
throw error; throw error;
} }
} }
/** PayPal's order as parsed JSON in PayPal's snake_case (GET /v2/checkout/orders/:id). Throws PayPal's and network errors. */ async checkPaymentStatus(redirect: { orderID: string }) {
async getOrder(id: string): Promise<unknown> { try {
const { body } = await this.orders.call((orders) => orders.ordersGet({ id })); const collect = {
return JSON.parse(body as string); id: redirect.orderID,
prefer: "return=minimal"
};
const { body } = await this.ordersController.ordersCapture(collect);
const captureResult = JSON.parse(body as string);
strapi.log.info("app:i:paypal-api: " + JSON.stringify({ captureResult }));
return captureResult;
} catch (error) {
return "/result/error";
}
} }
/** async handleWebhook(req: any) {
* Captures the PayPal order and returns the whole order after the capture as parsed JSON in PayPal's snake_case // Implement PayPal webhook validation and processing
* (POST /v2/checkout/orders/:id/capture, Prefer: return=representation). PayPal answers a repeated requestId with the strapi.log.verbose("app:i:paypal-api: " + JSON.stringify({ req }));
* response of the first capture instead of capturing again. Throws PayPal's and network errors.
*/
async captureOrder(id: string, requestId: string): Promise<unknown> {
const { body } = await this.orders.call((orders) =>
orders.ordersCapture({ id, paypalRequestId: requestId, prefer: "return=representation" })
);
return JSON.parse(body as string);
}
async handleWebhook(req: unknown) {
// Implement PayPal webhook validation and processing. The event's resource holds the buyer's name and address, so only the
// event type and the resource's id and status are logged.
const event = (typeof req === "object" && req !== null ? req : {}) as { event_type?: unknown; resource?: { id?: unknown; status?: unknown } };
const textOf = (value: unknown) => (typeof value === "string" ? value : undefined);
strapi.log.verbose("app:v:paypal-api: Webhook received", {
eventType: textOf(event.event_type),
resource: textOf(event.resource?.id),
status: textOf(event.resource?.status)
});
// Note: PayPal webhook handling requires specific implementation // Note: PayPal webhook handling requires specific implementation
// This is a placeholder and should be replaced with actual PayPal webhook validation // This is a placeholder and should be replaced with actual PayPal webhook validation
-375
View File
@@ -1,375 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
ALREADY_PAID,
NOT_CONFIRMED,
NOT_FOUND,
NOT_MATCHING,
NOT_RECORDED,
NOT_THIS_ORDER,
ORDER_CHANGED,
PAYPAL_FAILED,
capturePayment,
captureRequestId
} from "../../src/checkout/capture-payment.ts";
import { formatLogLine } from "../../src/logging/format.ts";
const UUID = "11111111-2222-4333-8444-555555555555";
const PAYPAL_ORDER_ID = "5O190127TN364715T";
const OTHER_PAYPAL_ORDER_ID = "8MC585209K746392H";
const CAPTURE_ID = "3C679366HH908993F";
// The server's time when the capture request arrives.
const NOW = new Date("2026-10-08T19:13:05.000Z");
const CREDENTIALS = "RFVNTVlfQ0xJRU5UX0lEOkRVTU1ZX0NMSUVOVF9TRUNSRVQ="; // base64 of the dummy "DUMMY_CLIENT_ID:DUMMY_CLIENT_SECRET"
// PayPal's Orders v2 JSON for the order below, see paypal-verify.test.ts. Every call returns a fresh copy to change.
const fixture = (name: string) => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8"));
// The row the capture reads with strapi.db.query("api::order.order").findOne({ where: { uuid } }) after a checkout stored the
// PayPal order it created: subtotal 17.90, delivery 4.95, total 22.85.
const orderRow = (overrides = {}) => ({
id: 4711,
uuid: UUID,
email: "erika@example.org",
subtotal: 17.9,
total: 22.85,
VAT: 2.86,
paymentAuthorised: false,
paymentStatus: null,
invoiceSent: false,
paypalOrderId: PAYPAL_ORDER_ID,
paypalCaptureId: null,
paymentCapturedAt: null,
...overrides
});
/**
* Fakes of the dependencies over one order row, which record every call and every log line. updateOrder writes like the
* controller's update with where { id, paypalOrderId }: only while the row still carries that PayPal order, else it answers null.
* duringCapture runs while PayPal captures, as a request that changes the order at that moment would.
*/
const setup = ({
row = orderRow(),
paypalOrder = fixture("paypal-order-approved.json"),
captured = fixture("paypal-order-captured.json"),
getError = undefined,
captureError = undefined,
updateError = undefined,
duringCapture = (row) => {}
} = {}) => {
const calls = { findOrder: [], getPayPalOrder: [], capturePayPalOrder: [], updateOrder: [] };
const logs = [];
const deps = {
findOrder: async (uuid) => {
calls.findOrder.push(uuid);
return row !== null && row.uuid === uuid ? { ...row } : null;
},
getPayPalOrder: async (id) => {
calls.getPayPalOrder.push(id);
if (getError) throw getError;
return paypalOrder;
},
capturePayPalOrder: async (id, requestId) => {
calls.capturePayPalOrder.push({ id, requestId });
duringCapture(row);
if (captureError) throw captureError;
return captured;
},
updateOrder: async (id, paypalOrderId, data) => {
calls.updateOrder.push({ id, paypalOrderId, data });
if (updateError) throw updateError;
if (row.id !== id || row.paypalOrderId !== paypalOrderId) return null;
Object.assign(row, data);
return { ...row };
},
log: {
info: (message, fields) => logs.push({ level: "info", message, fields }),
warn: (message, fields) => logs.push({ level: "warn", message, fields }),
error: (message, fields) => logs.push({ level: "error", message, fields })
}
};
return { deps, calls, logs, row };
};
const capture = (deps, { uuid = UUID, paypalOrderId = PAYPAL_ORDER_ID } = {}) => capturePayment(deps, { uuid, paypalOrderId, now: NOW });
const paypalCalls = (calls) => calls.getPayPalOrder.length + calls.capturePayPalOrder.length;
// What must never reach the log: the credentials of a failed request, and the buyer's data in PayPal's responses.
const NEVER_LOGGED = [CREDENTIALS, "Authorization", "Erika", "Mustermann", "erika", "Musterstraße", "QYR5Z8XDVJNXQ", "INSTRUMENT_DECLINED"];
/** Checks the recorded log, and each line as the console shows it (config/logger.ts formats with formatLogLine). */
const assertCleanLog = (logs) => {
const lines = logs.map(({ level, message, fields }) =>
formatLogLine({ timestamp: "2026-10-08 19:13:05.000", level, message: `app:${level[0]}:order-controller: ${message}`, ...fields })
);
for (const text of [JSON.stringify(logs), ...lines]) {
for (const value of NEVER_LOGGED) assert.ok(!text.includes(value), `the log holds ${value}: ${text}`);
}
};
// What the PayPal SDK throws for an error response: @apimatic/core's ApiError, here the SDK's CustomError, with the request, whose
// Authorization header holds the client credentials (token request) or an access token, and PayPal's error body.
const paypalApiError = (statusCode: number, message: string) =>
Object.assign(new Error(message), {
request: {
method: "POST",
url: `https://api-m.sandbox.paypal.com/v2/checkout/orders/${PAYPAL_ORDER_ID}/capture`,
headers: { Authorization: `Basic ${CREDENTIALS}`, "PayPal-Request-Id": captureRequestId(UUID, PAYPAL_ORDER_ID) }
},
statusCode,
headers: { "paypal-debug-id": "f1a2b3c4d5e6f" },
body: JSON.stringify({ name: "UNPROCESSABLE_ENTITY", details: [{ issue: "INSTRUMENT_DECLINED" }], debug_id: "f1a2b3c4d5e6f" })
});
// What axios throws when PayPal cannot be reached: the request config with its headers.
const networkError = (message = "getaddrinfo EAI_AGAIN api-m.sandbox.paypal.com") =>
Object.assign(new Error(message), {
name: "AxiosError",
code: "EAI_AGAIN",
config: { method: "post", url: "https://api-m.sandbox.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } }
});
// The capture
test("captures the PayPal order the checkout stored and writes exactly the payment fields", async () => {
const { deps, calls, logs } = setup();
const result = await capture(deps);
assert.equal(result.status, 200);
assert.equal(result.alreadyCaptured, false);
assert.deepEqual(calls.findOrder, [UUID]);
assert.deepEqual(calls.getPayPalOrder, [PAYPAL_ORDER_ID]);
assert.deepEqual(calls.capturePayPalOrder, [{ id: PAYPAL_ORDER_ID, requestId: `capture-${UUID}-${PAYPAL_ORDER_ID}` }]);
assert.deepEqual(calls.updateOrder, [
{
id: 4711,
paypalOrderId: PAYPAL_ORDER_ID,
data: { paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID, paymentCapturedAt: "2026-10-08T19:13:05.000Z" }
}
]);
// the controller answers the updated order, sanitized
assert.equal(result.order.paymentAuthorised, true);
assert.deepEqual(logs, [{ level: "info", message: "Captured the payment", fields: { order: 4711, capture: CAPTURE_ID } }]);
assertCleanLog(logs);
});
test("sends the same PayPal-Request-Id on every attempt, so that PayPal captures once", async () => {
assert.equal(captureRequestId(UUID, PAYPAL_ORDER_ID), "capture-11111111-2222-4333-8444-555555555555-5O190127TN364715T");
// PayPal accepts at most 108 characters
assert.ok(captureRequestId(UUID, PAYPAL_ORDER_ID).length <= 108);
// The first attempt times out after PayPal may have captured. The retry repeats the id, and PayPal answers the first capture.
const first = setup({ captureError: Object.assign(networkError("timeout of 30000ms exceeded"), { code: "ECONNABORTED" }) });
assert.deepEqual(await capture(first.deps), { status: 502, message: PAYPAL_FAILED });
const retry = setup();
assert.equal((await capture(retry.deps)).status, 200);
assert.equal(first.calls.capturePayPalOrder[0].requestId, retry.calls.capturePayPalOrder[0].requestId);
});
// Paying a cheap order, capturing an expensive one
test("rejects a PayPal order the checkout did not store on the order, without asking PayPal", async () => {
// The buyer paid order A with PayPal order OTHER_PAYPAL_ORDER_ID and calls the capture of the expensive order B with it.
const { deps, calls, logs, row } = setup({ row: orderRow({ id: 4712, uuid: UUID, total: 249 }) });
assert.deepEqual(await capture(deps, { paypalOrderId: OTHER_PAYPAL_ORDER_ID }), { status: 409, message: NOT_THIS_ORDER });
assert.equal(paypalCalls(calls), 0);
assert.equal(calls.updateOrder.length, 0);
assert.equal(row.paymentAuthorised, false);
assert.deepEqual(logs, [
{
level: "warn",
message: "Rejected a capture with a PayPal order the checkout did not create for the order",
fields: { order: 4712, hasPayPalOrder: true }
}
]);
});
test("rejects every PayPal order for an order without a stored one, without asking PayPal", async () => {
// no checkout yet, an order from before the checkout stored it, or a cart changed since the checkout
for (const paypalOrderId of [null, undefined, ""]) {
const { deps, calls, logs } = setup({ row: orderRow({ paypalOrderId }) });
assert.deepEqual(await capture(deps), { status: 409, message: NOT_THIS_ORDER });
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
assert.equal(logs[0].fields.hasPayPalOrder, false);
}
// a request without a PayPal order id
const { deps, calls } = setup();
assert.deepEqual(await capture(deps, { paypalOrderId: "" }), { status: 409, message: NOT_THIS_ORDER });
assert.equal(paypalCalls(calls), 0);
});
test("does not capture a PayPal order whose amount is not the order total", async () => {
const paypalOrder = fixture("paypal-order-approved.json");
paypalOrder.purchase_units[0].amount.value = "2.29";
const { deps, calls, logs } = setup({ paypalOrder });
assert.deepEqual(await capture(deps), { status: 409, message: NOT_MATCHING });
assert.deepEqual(calls.getPayPalOrder, [PAYPAL_ORDER_ID]);
assert.equal(calls.capturePayPalOrder.length, 0);
assert.equal(calls.updateOrder.length, 0);
assert.deepEqual(logs, [
{
level: "warn",
message: "Rejected a capture: the PayPal order does not match the order",
fields: { order: 4711, reason: "the PayPal order amount 2.29 is not the order total 22.85" }
}
]);
assertCleanLog(logs);
});
test("does not capture a PayPal order created for another order, in another currency or not approved", async () => {
const cases: [(paypalOrder) => void, string][] = [
[
(paypalOrder) => (paypalOrder.purchase_units[0].custom_id = "99999999-8888-4777-8666-555555555555"),
"the custom_id of the PayPal order is not the order's uuid"
],
[(paypalOrder) => (paypalOrder.purchase_units[0].amount.currency_code = "USD"), "the PayPal order currency is USD, not EUR"],
[(paypalOrder) => (paypalOrder.status = "CREATED"), "the PayPal order status is CREATED, not APPROVED"]
];
for (const [change, reason] of cases) {
const paypalOrder = fixture("paypal-order-approved.json");
change(paypalOrder);
const { deps, calls, logs } = setup({ paypalOrder });
assert.deepEqual(await capture(deps), { status: 409, message: NOT_MATCHING });
assert.equal(calls.capturePayPalOrder.length + calls.updateOrder.length, 0);
assert.equal(logs[0].fields.reason, reason);
assertCleanLog(logs);
}
});
// A paid order
test("answers alreadyCaptured for a paid order and the PayPal order it was paid with, without a call or a write", async () => {
const { deps, calls, logs } = setup({ row: orderRow({ paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID }) });
assert.deepEqual(await capture(deps), { status: 200, alreadyCaptured: true });
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
assert.deepEqual(logs, []);
});
test("rejects another PayPal order for a paid order, without a call or a write", async () => {
const paidRows = [
orderRow({ paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID }),
// paid before the checkout stored the PayPal order
orderRow({ paymentAuthorised: true, paymentStatus: "authorised", paypalOrderId: null })
];
for (const row of paidRows) {
const { deps, calls } = setup({ row });
assert.deepEqual(await capture(deps, { paypalOrderId: OTHER_PAYPAL_ORDER_ID }), { status: 409, message: ALREADY_PAID });
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
}
});
test("answers 404 for a missing order, without asking PayPal", async () => {
const { deps, calls } = setup({ row: null });
assert.deepEqual(await capture(deps), { status: 404, message: NOT_FOUND });
assert.equal(paypalCalls(calls), 0);
// No uuid: no lookup at all, since Strapi drops an undefined where condition and would find any order.
for (const uuid of ["", undefined]) {
const { deps, calls } = setup();
assert.deepEqual(await capturePayment(deps, { uuid, paypalOrderId: PAYPAL_ORDER_ID, now: NOW }), { status: 404, message: NOT_FOUND });
assert.deepEqual(calls.findOrder, []);
}
});
// PayPal fails
test("answers 502 when PayPal fails, and logs the error's name, message and status code but not its request", async () => {
const failures: [string, Error & { statusCode?: number }][] = [
["getError", paypalApiError(401, "Authentication failed due to missing authorization header, or invalid authentication credentials.")],
["getError", networkError()],
["captureError", paypalApiError(422, "The requested action could not be performed, semantically incorrect, or failed business validation.")],
["captureError", networkError()]
];
for (const [failing, error] of failures) {
const { deps, calls, logs, row } = setup({ [failing]: error });
assert.deepEqual(await capture(deps), { status: 502, message: PAYPAL_FAILED });
assert.equal(calls.capturePayPalOrder.length, failing === "getError" ? 0 : 1);
assert.equal(calls.updateOrder.length, 0);
assert.equal(row.paymentAuthorised, false);
assert.equal(logs.length, 1);
assert.equal(logs[0].level, "error");
assert.deepEqual(logs[0].fields, { order: 4711, error: { name: error.name, message: error.message, statusCode: error.statusCode } });
assertCleanLog(logs);
}
});
// After PayPal answered the capture
test("keeps the order unpaid and answers 502 when the capture does not match, logging the order and the reason only", async () => {
const pending = fixture("paypal-order-captured.json");
pending.purchase_units[0].payments.captures[0].status = "PENDING";
const lower = fixture("paypal-order-captured.json");
lower.purchase_units[0].payments.captures[0].amount.value = "2.29";
const minimal = { id: PAYPAL_ORDER_ID, status: "COMPLETED", links: [] };
const cases: [unknown, string][] = [
[pending, "the capture status is PENDING, not COMPLETED"],
[lower, "the capture amount 2.29 is not the order total 22.85"],
[minimal, "the capture response has no capture"]
];
for (const [captured, reason] of cases) {
const { deps, calls, logs, row } = setup({ captured });
assert.deepEqual(await capture(deps), { status: 502, message: NOT_CONFIRMED });
assert.equal(calls.updateOrder.length, 0);
assert.equal(row.paymentAuthorised, false);
assert.deepEqual(logs, [
{
level: "error",
message: "The PayPal capture does not match the order, which stays unpaid: check the PayPal order for a refund",
fields: { order: 4711, reason }
}
]);
assertCleanLog(logs);
}
});
test("keeps the order unpaid when its cart or address changed during the capture", async () => {
// addProduct, removeProduct and updateByUuid clear paypalOrderId in the write that changes the order
const { deps, calls, logs, row } = setup({
duringCapture: (row) => {
row.total = 249;
row.paypalOrderId = null;
}
});
assert.deepEqual(await capture(deps), { status: 409, message: ORDER_CHANGED });
assert.equal(calls.updateOrder.length, 1);
assert.equal(row.paymentAuthorised, false);
assert.deepEqual(logs, [
{
level: "error",
message: "PayPal captured the payment, but the order changed during the capture and stays unpaid: check the order for a refund",
fields: { order: 4711, capture: CAPTURE_ID }
}
]);
});
test("answers 500 when the order cannot be marked paid after the capture, and logs the capture", async () => {
const { deps, logs } = setup({ updateError: new Error("Connection terminated unexpectedly") });
assert.deepEqual(await capture(deps), { status: 500, message: NOT_RECORDED });
assert.deepEqual(logs, [
{
level: "error",
message: "PayPal captured the payment, but the order could not be marked paid: check the order",
fields: {
order: 4711,
capture: CAPTURE_ID,
error: { name: "Error", message: "Connection terminated unexpectedly", statusCode: undefined }
}
}
]);
});
-351
View File
@@ -1,351 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseCustomerUpdate } from "../../src/checkout/customer-update.ts";
// The server's time when the update arrives.
const NOW = new Date("2026-10-08T19:12:05.000Z");
const parse = (body: unknown) => parseCustomerUpdate(body, NOW);
// The names of the fields a body is rejected for, or [] if it is accepted.
const rejectedFields = (body: unknown) => {
const result = parse(body);
return result.ok ? [] : result.fields;
};
// What the controller receives: the JSON the shop sent, parsed by Strapi's body parser.
const wire = (body: unknown) => JSON.parse(JSON.stringify(body));
// The shop's checkout, libreshop/shop: useCart().update(data) sends { data } (composables/useShopApi.ts, updateOrder), and its
// server route forwards that body unchanged.
// Step 1, pages/checkout/1.vue: the email and the time the browser thinks the terms were accepted.
const STEP_1 = { data: { email: "erika@example.org", acceptedTermsAndConditionsAt: "2026-10-08T19:12:03.512Z" } };
// Step 2, pages/checkout/2.vue: addressToString and mapToStructuredAddress of the form, and the id of the chosen delivery method.
// Without a different delivery address, the delivery address is the invoice address.
const ERIKA = {
givenName: "Erika",
familyName: "Mustermann",
streetAddress: "Musterstraße 1",
postalCode: "70190",
addressLevel2: "Stuttgart",
country: "DE"
};
const ERIKA_TEXT = "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart";
const STEP_2 = {
data: { invoiceAddress: ERIKA_TEXT, deliveryAddress: ERIKA_TEXT, invoiceAddressStructured: ERIKA, deliveryAddressStructured: ERIKA, delivery: 1 }
};
const MAX = {
givenName: "Max",
familyName: "Mustermann",
streetAddress: "Hauptstraße 5",
postalCode: "10115",
addressLevel2: "Berlin",
country: "DE"
};
const MAX_TEXT = "Max Mustermann\nHauptstraße 5\n10115 Berlin";
// Every attribute of the order that only the server writes (src/api/order/content-types/order/schema.json), among them the PayPal
// order the checkout stores and the capture it verified, and the fields Strapi manages; with values a client would send to get a
// paid order for less.
const SERVER_FIELDS = {
paymentAuthorised: true,
paymentStatus: "captured",
paypalOrderId: "5O190127TN364715T",
paypalCaptureId: "3C679366HH908993F",
paymentCapturedAt: "2026-01-01T00:00:00.000Z",
total: 0.01,
subtotal: 0.01,
VAT: 0,
invoiceNumber: "R-1-1",
invoice: 1,
invoiceSent: false,
deliveryNote: 1,
deliveryNoteNumber: "L-1-1",
deliveryNoteSent: true,
deliveryTrackingNumber: "00340434161094042557",
emailSent: true,
cart: [{ product: 101, count: 100 }],
customer: 1,
payment: 1,
uuid: "11111111-2222-4333-8444-555555555555",
hash: "0",
date: "2026-01-01",
id: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
publishedAt: "2026-01-01T00:00:00.000Z",
createdBy: 1,
updatedBy: 1
};
// What the shop sends
test("accepts the shop's step 1 payload: the email, and the terms accepted at the server's time", (t) => {
const result = parse(wire(STEP_1));
t.diagnostic(`output: ${JSON.stringify(result)}`);
assert.deepEqual(result, { ok: true, data: { email: "erika@example.org", acceptedTermsAndConditionsAt: "2026-10-08T19:12:05.000Z" } });
});
test("accepts the shop's step 2 payload: both addresses as text and structured, and the delivery method", (t) => {
const result = parse(wire(STEP_2));
t.diagnostic(`output: ${JSON.stringify(result)}`);
assert.deepEqual(result, {
ok: true,
data: {
invoiceAddress: ERIKA_TEXT,
deliveryAddress: ERIKA_TEXT,
invoiceAddressStructured: ERIKA,
deliveryAddressStructured: ERIKA,
delivery: 1
}
});
});
test("accepts the shop's step 2 payload with a different delivery address", () => {
const body = { data: { ...STEP_2.data, deliveryAddress: MAX_TEXT, deliveryAddressStructured: MAX, delivery: 2 } };
assert.deepEqual(parse(wire(body)), { ok: true, data: body.data });
});
// What only the server writes
for (const [field, value] of Object.entries(SERVER_FIELDS)) {
test(`rejects ${field}, which only the server writes`, () => {
assert.deepEqual(parse({ data: { [field]: value } }), {
ok: false,
errors: [`data.${field}: not a field the customer may set`],
fields: [`data.${field}`]
});
});
}
test("rejects the whole update, not just the server-only fields, when they come with the shop's fields", () => {
assert.deepEqual(parse({ data: { ...STEP_1.data, paymentAuthorised: true, paymentStatus: "authorised" } }), {
ok: false,
errors: ["data.paymentAuthorised: not a field the customer may set", "data.paymentStatus: not a field the customer may set"],
fields: ["data.paymentAuthorised", "data.paymentStatus"]
});
});
test("names the rejected fields but never repeats their values", () => {
const result = parse({
data: {
email: "SECRET erika@example.org",
invoiceAddress: { SECRET: true },
invoiceAddressStructured: { ...ERIKA, givenName: "SECRET".repeat(40) },
invoiceNumber: "R-SECRET",
delivery: "SECRET"
}
});
assert.equal(result.ok, false);
assert.deepEqual(result.ok ? [] : result.fields, [
"data.email",
"data.invoiceAddress",
"data.invoiceAddressStructured.givenName",
"data.invoiceNumber",
"data.delivery"
]);
assert.ok(!JSON.stringify(result).includes("SECRET"), JSON.stringify(result));
});
// The terms
test("records the server's time as the time the terms were accepted, whatever the client sends", () => {
for (const sent of ["2026-10-08T19:12:03.512Z", "1999-12-31T23:00:00.000Z", "2030-01-01T00:00:00.000Z", "yes", true, 1]) {
assert.deepEqual(parse({ data: { acceptedTermsAndConditionsAt: sent } }), {
ok: true,
data: { acceptedTermsAndConditionsAt: "2026-10-08T19:12:05.000Z" }
});
}
assert.deepEqual(parseCustomerUpdate({ data: { acceptedTermsAndConditionsAt: true } }, new Date("2026-10-09T06:30:00.000Z")), {
ok: true,
data: { acceptedTermsAndConditionsAt: "2026-10-09T06:30:00.000Z" }
});
});
test("rejects a terms acceptance without a true value", () => {
for (const sent of [false, null, "", 0]) {
assert.deepEqual(parse({ data: { acceptedTermsAndConditionsAt: sent } }), {
ok: false,
errors: ["data.acceptedTermsAndConditionsAt: must be sent only to accept the terms, as true or a timestamp"],
fields: ["data.acceptedTermsAndConditionsAt"]
});
}
});
// The email
test("accepts an email address trimmed, up to 254 characters", () => {
const longest = `${"e".repeat(242)}@example.org`;
assert.equal(longest.length, 254);
assert.deepEqual(parse({ data: { email: " erika@example.org\n" } }), { ok: true, data: { email: "erika@example.org" } });
assert.deepEqual(parse({ data: { email: longest } }), { ok: true, data: { email: longest } });
});
test("rejects an email that is not an address of at most 254 characters", () => {
const emails = [
"",
" ",
"erika",
"erika.example.org",
"@example.org",
"erika@",
`${"e".repeat(243)}@example.org`,
"erika mustermann@example.org",
"erika@example.org\r\nBcc: everyone@example.org",
"erika@exa\u0000mple.org",
null,
42,
true,
["erika@example.org"],
{ address: "erika@example.org" }
];
for (const email of emails) {
assert.deepEqual(
parse({ data: { email } }),
{ ok: false, errors: ["data.email: must be an email address of at most 254 characters"], fields: ["data.email"] },
JSON.stringify(email)
);
}
});
// The addresses
test("accepts an invoice or delivery address of up to 500 characters", () => {
const longest = `${ERIKA_TEXT}\n${"c/o ".repeat(113)}`;
assert.equal(longest.length, 500);
assert.deepEqual(parse({ data: { invoiceAddress: longest, deliveryAddress: longest } }), {
ok: true,
data: { invoiceAddress: longest, deliveryAddress: longest }
});
});
test("rejects an invoice or delivery address that is not a string of at most 500 characters", () => {
for (const field of ["invoiceAddress", "deliveryAddress"]) {
for (const value of ["a".repeat(501), null, 42, true, [ERIKA_TEXT], { street: "Musterstraße 1" }]) {
assert.deepEqual(parse({ data: { [field]: value } }), {
ok: false,
errors: [`data.${field}: must be a string of at most 500 characters`],
fields: [`data.${field}`]
});
}
}
});
test("accepts a structured address with only some components, null for a component without a value, and 200 characters", () => {
const partial = { givenName: "Erika", familyName: null, postalCode: "70190", country: "DE" };
const longest = { ...ERIKA, streetAddress: "s".repeat(200) };
assert.deepEqual(parse({ data: { invoiceAddressStructured: partial } }), { ok: true, data: { invoiceAddressStructured: partial } });
assert.deepEqual(parse({ data: { deliveryAddressStructured: longest } }), { ok: true, data: { deliveryAddressStructured: longest } });
});
test("rejects a structured address with keys that are not its components, and names them", () => {
assert.deepEqual(parse({ data: { invoiceAddressStructured: { ...ERIKA, id: 7, street: "Musterstraße 1" } } }), {
ok: false,
errors: [
"data.invoiceAddressStructured.id: not a structured address field",
"data.invoiceAddressStructured.street: not a structured address field"
],
fields: ["data.invoiceAddressStructured.id", "data.invoiceAddressStructured.street"]
});
});
test("rejects a structured address that is not an object", () => {
for (const field of ["invoiceAddressStructured", "deliveryAddressStructured"]) {
for (const value of [null, ERIKA_TEXT, 42, true, [ERIKA]]) {
assert.deepEqual(parse({ data: { [field]: value } }), {
ok: false,
errors: [`data.${field}: must be an object`],
fields: [`data.${field}`]
});
}
}
});
test("rejects a structured address component that is not a string of at most 200 characters, or null", () => {
for (const value of ["s".repeat(201), 42, true, ["Erika"], { name: "Erika" }]) {
assert.deepEqual(parse({ data: { deliveryAddressStructured: { ...ERIKA, givenName: value } } }), {
ok: false,
errors: ["data.deliveryAddressStructured.givenName: must be a string of at most 200 characters, or null"],
fields: ["data.deliveryAddressStructured.givenName"]
});
}
});
// The delivery method
test("accepts the id of a delivery method", () => {
for (const delivery of [1, 2, 2147483647]) {
assert.deepEqual(parse({ data: { delivery } }), { ok: true, data: { delivery } });
}
});
test("rejects a delivery that is not a positive integer", () => {
// NaN and Infinity cannot come as JSON, but the parser does not rely on that.
for (const delivery of [0, -1, 1.5, "1", null, true, [1], { id: 1 }, 2147483648, Number.NaN, Number.POSITIVE_INFINITY]) {
assert.deepEqual(parse({ data: { delivery } }), {
ok: false,
errors: ["data.delivery: must be the id of a delivery method, a positive integer"],
fields: ["data.delivery"]
});
}
});
// The body
test("rejects a body that is not an object", () => {
for (const body of [undefined, null, "", "data", 42, true, [], [STEP_1]]) {
assert.deepEqual(parse(body), { ok: false, errors: ["body: must be an object of the form { data: { … } }"], fields: ["body"] });
}
});
test("rejects a body without data, and names the fields sent outside it", () => {
assert.deepEqual(parse({}), { ok: false, errors: ["data: missing"], fields: ["data"] });
assert.deepEqual(parse(STEP_1.data), {
ok: false,
errors: [
"email: not accepted, the fields belong in data",
"acceptedTermsAndConditionsAt: not accepted, the fields belong in data",
"data: missing"
],
fields: ["email", "acceptedTermsAndConditionsAt", "data"]
});
});
test("rejects data that is not an object", () => {
for (const data of [null, "email=erika@example.org", 42, true, [], [STEP_1.data]]) {
assert.deepEqual(parse({ data }), { ok: false, errors: ["data: must be an object"], fields: ["data"] });
}
});
test("rejects keys next to data", () => {
assert.deepEqual(rejectedFields({ ...STEP_1, meta: {}, paymentAuthorised: true }), ["meta", "paymentAuthorised"]);
});
test("rejects __proto__ and constructor like any other key", () => {
assert.deepEqual(rejectedFields(JSON.parse('{"__proto__": {"paymentAuthorised": true}, "data": {}}')), ["__proto__"]);
assert.deepEqual(rejectedFields(JSON.parse('{"data": {"__proto__": {"paymentAuthorised": true}, "constructor": {}}}')), [
"data.__proto__",
"data.constructor"
]);
assert.deepEqual(rejectedFields(JSON.parse('{"data": {"invoiceAddressStructured": {"__proto__": {"givenName": "Erika"}}}}')), [
"data.invoiceAddressStructured.__proto__"
]);
});
test("cuts a rejected key to 64 characters", () => {
assert.deepEqual(rejectedFields({ data: { ["k".repeat(10000)]: 1 } }), [`data.${"k".repeat(64)}…`]);
});
test("accepts an update without fields", () => {
assert.deepEqual(parse({ data: {} }), { ok: true, data: {} });
});
@@ -1,88 +0,0 @@
{
"id": "5O190127TN364715T",
"intent": "CAPTURE",
"status": "APPROVED",
"payment_source": {
"paypal": {
"email_address": "erika.buyer@example.org",
"account_id": "QYR5Z8XDVJNXQ",
"account_status": "VERIFIED",
"name": {
"given_name": "Erika",
"surname": "Mustermann"
},
"address": {
"country_code": "DE"
}
}
},
"purchase_units": [
{
"reference_id": "11111111-2222-4333-8444-555555555555",
"amount": {
"currency_code": "EUR",
"value": "22.85",
"breakdown": {
"item_total": {
"currency_code": "EUR",
"value": "15.04"
},
"shipping": {
"currency_code": "EUR",
"value": "4.95"
},
"tax_total": {
"currency_code": "EUR",
"value": "2.86"
}
}
},
"payee": {
"email_address": "merchant@example.com",
"merchant_id": "7KNGBPH2U58GQ"
},
"custom_id": "11111111-2222-4333-8444-555555555555",
"invoice_id": "R-12-4711",
"shipping": {
"name": {
"full_name": "Erika Mustermann"
},
"address": {
"address_line_1": "Musterstraße 1",
"admin_area_2": "Stuttgart",
"postal_code": "70190",
"country_code": "DE"
}
}
}
],
"payer": {
"name": {
"given_name": "Erika",
"surname": "Mustermann"
},
"email_address": "erika.buyer@example.org",
"payer_id": "QYR5Z8XDVJNXQ",
"address": {
"country_code": "DE"
}
},
"create_time": "2026-10-08T19:12:40Z",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
"rel": "self",
"method": "GET"
},
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
"rel": "update",
"method": "PATCH"
},
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T/capture",
"rel": "capture",
"method": "POST"
}
]
}
@@ -1,131 +0,0 @@
{
"id": "5O190127TN364715T",
"intent": "CAPTURE",
"status": "COMPLETED",
"payment_source": {
"paypal": {
"email_address": "erika.buyer@example.org",
"account_id": "QYR5Z8XDVJNXQ",
"account_status": "VERIFIED",
"name": {
"given_name": "Erika",
"surname": "Mustermann"
},
"address": {
"country_code": "DE"
}
}
},
"purchase_units": [
{
"reference_id": "11111111-2222-4333-8444-555555555555",
"amount": {
"currency_code": "EUR",
"value": "22.85",
"breakdown": {
"item_total": {
"currency_code": "EUR",
"value": "15.04"
},
"shipping": {
"currency_code": "EUR",
"value": "4.95"
},
"tax_total": {
"currency_code": "EUR",
"value": "2.86"
}
}
},
"payee": {
"email_address": "merchant@example.com",
"merchant_id": "7KNGBPH2U58GQ"
},
"custom_id": "11111111-2222-4333-8444-555555555555",
"invoice_id": "R-12-4711",
"shipping": {
"name": {
"full_name": "Erika Mustermann"
},
"address": {
"address_line_1": "Musterstraße 1",
"admin_area_2": "Stuttgart",
"postal_code": "70190",
"country_code": "DE"
}
},
"payments": {
"captures": [
{
"id": "3C679366HH908993F",
"status": "COMPLETED",
"amount": {
"currency_code": "EUR",
"value": "22.85"
},
"final_capture": true,
"seller_protection": {
"status": "ELIGIBLE",
"dispute_categories": ["ITEM_NOT_RECEIVED", "UNAUTHORIZED_TRANSACTION"]
},
"seller_receivable_breakdown": {
"gross_amount": {
"currency_code": "EUR",
"value": "22.85"
},
"paypal_fee": {
"currency_code": "EUR",
"value": "0.92"
},
"net_amount": {
"currency_code": "EUR",
"value": "21.93"
}
},
"custom_id": "11111111-2222-4333-8444-555555555555",
"invoice_id": "R-12-4711",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/3C679366HH908993F",
"rel": "self",
"method": "GET"
},
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/3C679366HH908993F/refund",
"rel": "refund",
"method": "POST"
},
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
"rel": "up",
"method": "GET"
}
],
"create_time": "2026-10-08T19:13:02Z",
"update_time": "2026-10-08T19:13:02Z"
}
]
}
}
],
"payer": {
"name": {
"given_name": "Erika",
"surname": "Mustermann"
},
"email_address": "erika.buyer@example.org",
"payer_id": "QYR5Z8XDVJNXQ",
"address": {
"country_code": "DE"
}
},
"create_time": "2026-10-08T19:12:40Z",
"update_time": "2026-10-08T19:13:02Z",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
"rel": "self",
"method": "GET"
}
]
}
-67
View File
@@ -1,67 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { describeError } from "../../src/logging/error.ts";
import { formatLogLine } from "../../src/logging/format.ts";
const TIMESTAMP = "2026-10-08 21:29:40.426";
const CREDENTIALS = "RFVNTVlfQ0xJRU5UX0lEOkRVTU1ZX0NMSUVOVF9TRUNSRVQ="; // base64 of the dummy "DUMMY_CLIENT_ID:DUMMY_CLIENT_SECRET"
// What axios throws when PayPal cannot be reached: the request config with its headers.
const networkError = () =>
Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), {
name: "AxiosError",
code: "EAI_AGAIN",
config: { method: "post", url: "https://api-m.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } }
});
test("keeps the name, message and status code of a PayPal SDK error, not its request, headers or body", () => {
// @apimatic/core's ApiError, which the SDK's CustomError and OAuthProviderError extend
const error = Object.assign(new Error("OAuth 2 Authorization endpoint returned error."), {
request: { method: "POST", url: "https://api-m.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } },
statusCode: 401,
headers: { "paypal-debug-id": "f1a2b3c4d5e6f" },
body: '{"error":"invalid_client","error_description":"Client Authentication failed"}'
});
assert.deepEqual(describeError(error), { name: "Error", message: "OAuth 2 Authorization endpoint returned error.", statusCode: 401 });
});
test("keeps the name and message of an axios network error, not its config", () => {
assert.deepEqual(describeError(networkError()), { name: "AxiosError", message: "getaddrinfo EAI_AGAIN api-m.paypal.com", statusCode: undefined });
});
test("keeps the first line of a message: the SDK's response validation error quotes the response after it", () => {
const error = new Error(
'The response did not match the response schema.\n\n> Issue #1\n\nExpected value to be of type \'string\'.\n\nGiven value: {"email_address":"erika.buyer@example.org"}'
);
assert.deepEqual(describeError(error), { name: "Error", message: "The response did not match the response schema.", statusCode: undefined });
});
test("cuts a long message", () => {
assert.equal(describeError(new Error("x".repeat(500))).message, `${"x".repeat(200)}…`);
});
test("describes a thrown value that is not an error by its type alone", () => {
assert.deepEqual(describeError("/result/error"), { name: "string", message: "", statusCode: undefined });
assert.deepEqual(describeError(undefined), { name: "undefined", message: "", statusCode: undefined });
assert.deepEqual(describeError(null), { name: "object", message: "", statusCode: undefined });
assert.deepEqual(describeError({ statusCode: "500" }), { name: "object", message: "", statusCode: undefined });
});
test("the log line of the description holds no credentials", () => {
// The formatter now also keeps them out of the line of the error object itself, see log-format.test.ts.
const line = formatLogLine({
timestamp: TIMESTAMP,
level: "error",
message: "app:e:order-controller: PayPal did not capture the payment",
order: 4711,
error: describeError(networkError())
});
assert.equal(
line,
'2026-10-08 21:29:40.426Z error: app:e:order-controller: PayPal did not capture the payment {\n "order": 4711,\n "error": {\n "name": "AxiosError",\n "message": "getaddrinfo EAI_AGAIN api-m.paypal.com"\n }\n}'
);
assert.ok(!line.includes(CREDENTIALS));
});
+18 -111
View File
@@ -33,16 +33,23 @@ test("ignores the symbol keys winston adds", () => {
assert.equal(formatLogLine(info), "2026-10-08 21:29:40.426Z info: hello "); assert.equal(formatLogLine(info), "2026-10-08 21:29:40.426Z info: hello ");
}); });
// Fixed defects of https://git.librete.ch/libretech/mp/issues/67. // Known defects: these assert the correct behaviour and fail today, which the todo marks as expected.
test("does not throw on a circular reference", () => { test(
"does not throw on a circular reference",
{ todo: "JSON.stringify throws on a circular reference — https://git.librete.ch/libretech/mp/issues/67" },
() => {
const request: Record<string, unknown> = { method: "POST", path: "/v1/oauth2/token" }; const request: Record<string, unknown> = { method: "POST", path: "/v1/oauth2/token" };
request.self = request; request.self = request;
assert.doesNotThrow(() => formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "app:e:paypal-api", request })); assert.doesNotThrow(() => formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "app:e:paypal-api", request }));
}); }
);
test("does not write the Authorization header of an axios-like error into the line", () => { test(
"does not write the Authorization header of an axios-like error into the line",
{ todo: "the PayPal client credentials are logged — https://git.librete.ch/libretech/mp/issues/67" },
() => {
const error = Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), { const error = Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), {
name: "AxiosError", name: "AxiosError",
code: "EAI_AGAIN", code: "EAI_AGAIN",
@@ -52,9 +59,13 @@ test("does not write the Authorization header of an axios-like error into the li
const line = formatLogLine({ ...error, message: `${error.message}\n${error.stack}`, level: "error", timestamp: TIMESTAMP }); const line = formatLogLine({ ...error, message: `${error.message}\n${error.stack}`, level: "error", timestamp: TIMESTAMP });
assert.ok(!line.includes(CREDENTIALS), line); assert.ok(!line.includes(CREDENTIALS), line);
}); }
);
test("renders an Error with its message instead of {}", () => { test(
"renders an Error with its message instead of {}",
{ todo: "an Error is rendered as {} — https://git.librete.ch/libretech/mp/issues/67" },
() => {
const error = new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"); const error = new Error("getaddrinfo EAI_AGAIN api-m.paypal.com");
// strapi.log.error("…", { error }), as in the order controller's capturePayment // strapi.log.error("…", { error }), as in the order controller's capturePayment
const line = formatLogLine({ const line = formatLogLine({
@@ -65,109 +76,5 @@ test("renders an Error with its message instead of {}", () => {
}); });
assert.ok(line.includes("getaddrinfo EAI_AGAIN api-m.paypal.com"), line); assert.ok(line.includes("getaddrinfo EAI_AGAIN api-m.paypal.com"), line);
});
// Safe serialisation.
const fieldsOf = (line: string) => JSON.parse(line.slice(line.indexOf("{")));
test("redacts the fields named like a credential at any depth, whatever their case", () => {
const line = formatLogLine({
timestamp: TIMESTAMP,
level: "debug",
message: "app:d:test",
token: "A21AAF…",
request: {
method: "POST",
headers: { Authorization: `Basic ${CREDENTIALS}`, Cookie: "koa.sess=abc", "X-API-Key": "k1", "Content-Type": "application/json" },
body: { grant_type: "client_credentials", client_secret: "s3cr3t", user: { password: "hunter2", api_key: "k2", apikey: "k3" } },
responses: [{ headers: { "set-cookie": ["koa.sess=def"] }, data: { access_token: "A21AAG…", expires_in: 32400 } }]
} }
}); );
assert.deepEqual(fieldsOf(line), {
token: "[redacted]",
request: {
method: "POST",
headers: { Authorization: "[redacted]", Cookie: "[redacted]", "X-API-Key": "[redacted]", "Content-Type": "application/json" },
body: {
grant_type: "client_credentials",
client_secret: "[redacted]",
user: { password: "[redacted]", api_key: "[redacted]", apikey: "[redacted]" }
},
responses: [{ headers: { "set-cookie": "[redacted]" }, data: { access_token: "[redacted]", expires_in: 32400 } }]
}
});
});
test("writes the other fields of an object that contains itself, and the reference to it as [Circular]", () => {
const request: Record<string, unknown> = { method: "POST", path: "/v1/oauth2/token" };
request.socket = { request, remoteAddress: "151.101.1.21" };
const line = formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "app:e:paypal-api", request, list: [request] });
assert.deepEqual(fieldsOf(line), {
request: { method: "POST", path: "/v1/oauth2/token", socket: { request: "[Circular]", remoteAddress: "151.101.1.21" } },
list: [{ method: "POST", path: "/v1/oauth2/token", socket: { request: "[Circular]", remoteAddress: "151.101.1.21" } }]
});
});
test("writes an object that is referenced twice, but not by itself, both times", () => {
const delivery = { id: 1, price: 4.95 };
assert.deepEqual(fieldsOf(formatLogLine({ timestamp: TIMESTAMP, level: "info", message: "m", before: delivery, after: delivery })), {
before: { id: 1, price: 4.95 },
after: { id: 1, price: 4.95 }
});
});
test("writes an Error as its name and the first line of its message, and its stack only when asked", () => {
const error = Object.assign(new Error('The response did not match the response schema.\n\nGiven value: {"email_address":"erika@example.org"}'), {
request: { url: "https://api-m.paypal.com/v2/checkout/orders", headers: { Authorization: "Bearer A21AAF…" } },
statusCode: 201
});
const info = { timestamp: TIMESTAMP, level: "error", message: "app:e:paypal-api", error };
assert.deepEqual(fieldsOf(formatLogLine(info)), { error: { name: "Error", message: "The response did not match the response schema." } });
const withStack = fieldsOf(formatLogLine(info, { stack: true }));
assert.equal(withStack.error.stack, error.stack);
assert.deepEqual(Object.keys(withStack.error), ["name", "message", "stack"]);
});
test("writes an instance of a class without toJSON as its class name, so the headers a request object keeps stay out", () => {
class ClientRequest {
_header = `POST /v1/oauth2/token HTTP/1.1\r\nAuthorization: Basic ${CREDENTIALS}\r\n\r\n`;
method = "POST";
}
const line = formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "m", request: new ClientRequest(), seen: new Set([1]) });
assert.deepEqual(fieldsOf(line), { request: "[ClientRequest]", seen: "[Set]" });
assert.ok(!line.includes(CREDENTIALS), line);
});
test("writes an instance of a class with toJSON as what toJSON returns, redacted", () => {
// Like axios's AxiosHeaders, which an axios error carries in config.headers.
class AxiosHeaders {
toJSON() {
return { Accept: "application/json", Authorization: `Basic ${CREDENTIALS}` };
}
}
const at = new Date("2026-10-08T19:13:05.000Z");
const line = formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "m", config: { headers: new AxiosHeaders() }, at });
assert.deepEqual(fieldsOf(line), {
config: { headers: { Accept: "application/json", Authorization: "[redacted]" } },
at: "2026-10-08T19:13:05.000Z"
});
});
test("writes a bigint as its digits and a field that cannot be read as [unreadable]", () => {
const unreadable = {
get value() {
throw new Error("no");
}
};
// The PayPal SDK keeps an OAuth token's expiry as a bigint, which JSON.stringify throws on.
const line = formatLogLine({ timestamp: TIMESTAMP, level: "debug", message: "m", expiry: 1791587585n, unreadable });
assert.deepEqual(fieldsOf(line), { expiry: "1791587585", unreadable: "[unreadable]" });
});
-56
View File
@@ -1,56 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { isLockedForCustomer, missingCheckoutFields } from "../../src/checkout/order-state.ts";
// Shaped like the row PUT /orders/:uuid/cart reads with strapi.db.query("api::order.order").findOne before it updates.
const order = (overrides = {}) => ({
id: 4711,
uuid: "11111111-2222-4333-8444-555555555555",
email: "erika@example.org",
paymentAuthorised: false,
paymentStatus: null,
invoiceSent: false,
...overrides
});
test("an order is open for the customer until its payment is authorised", () => {
assert.equal(isLockedForCustomer(order()), false);
// false is the schema's default, but a row can hold null
assert.equal(isLockedForCustomer(order({ paymentAuthorised: null })), false);
assert.equal(isLockedForCustomer({}), false);
});
test("an order is locked once its payment is authorised", () => {
// what capturePayment writes once the capture is verified (src/checkout/capture-payment.ts)
assert.equal(isLockedForCustomer(order({ paymentAuthorised: true, paymentStatus: "captured" })), true);
// what it wrote before
assert.equal(isLockedForCustomer(order({ paymentAuthorised: true, paymentStatus: "authorised" })), true);
});
// Shaped like the row POST /orders/:uuid/checkout reads (strapi.db.query findOne with orderDefaultParams), after both checkout steps.
const readyForCheckout = (overrides = {}) =>
order({
invoiceAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
delivery: { id: 1, name: "DHL", price: 4.95 },
...overrides
});
test("an order that went through both checkout steps lacks nothing for the checkout", () => {
assert.deepEqual(missingCheckoutFields(readyForCheckout()), []);
});
test("names the email, invoice address and delivery method an order still lacks for the checkout", () => {
// a new order: POST /orders creates it empty
assert.deepEqual(missingCheckoutFields({ email: null, invoiceAddress: null, delivery: null }), ["email", "invoiceAddress", "delivery"]);
assert.deepEqual(missingCheckoutFields({}), ["email", "invoiceAddress", "delivery"]);
// after step 1 alone
assert.deepEqual(missingCheckoutFields(readyForCheckout({ invoiceAddress: null, deliveryAddress: null, delivery: null })), [
"invoiceAddress",
"delivery"
]);
assert.deepEqual(missingCheckoutFields(readyForCheckout({ email: " " })), ["email"]);
assert.deepEqual(missingCheckoutFields(readyForCheckout({ invoiceAddress: "" })), ["invoiceAddress"]);
// a delivery method that was deleted is populated as null
assert.deepEqual(missingCheckoutFields(readyForCheckout({ delivery: null })), ["delivery"]);
});
-171
View File
@@ -1,171 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { PAYPAL_TIMEOUT_MS, breaksClient, createPayPalClient, paypalClientConfiguration } from "../../src/checkout/paypal-client.ts";
const TOKEN_URL = "https://api-m.paypal.com/v1/oauth2/token";
const ORDERS_URL = "https://api-m.paypal.com/v2/checkout/orders";
const PAYPAL_ORDER = '{"id":"5O190127TN364715T","status":"CREATED"}';
// What axios throws when PayPal cannot be reached: no status code, the request in config.
const networkError = (url = TOKEN_URL) =>
Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), { name: "AxiosError", code: "EAI_AGAIN", config: { method: "post", url } });
// What the SDK throws when PayPal answers with an error status (@apimatic/core's ApiError): the status code and the request.
const apiError = (statusCode: number, url = ORDERS_URL) =>
Object.assign(new Error(`PayPal answered ${statusCode}`), { statusCode, request: { method: "POST", url }, headers: {}, body: "{}" });
/**
* A fake of the SDK's OrdersController over a Client. Like the SDK, every client requests its OAuth token on its first call and
* keeps the promise for its lifetime, also when the token request failed: every later call fails with that same error and
* requests nothing. The token request fails while dns.up is false. Each construction and token request is counted.
*/
const fakeSdk = () => {
const sdk = { dnsUp: false, constructions: 0, tokenRequests: 0, configurations: [] as unknown[] };
const createController = (configuration: unknown) => {
sdk.constructions += 1;
sdk.configurations.push(configuration);
let token: Promise<string> | undefined;
const authenticate = () => {
if (token === undefined) {
sdk.tokenRequests += 1;
token = sdk.dnsUp ? Promise.resolve("A21AAF…") : Promise.reject(networkError());
token.catch(() => {});
}
return token;
};
return {
ordersCreate: async () => {
await authenticate();
return { statusCode: 201, body: PAYPAL_ORDER };
}
};
};
return { sdk, createController };
};
test("a client of the SDK keeps a failed token request: every later call fails without a request", async () => {
// What the replacement below works around, as the harness's scenario S7 observed with the real SDK.
const { sdk, createController } = fakeSdk();
const orders = createController({});
const first = await orders.ordersCreate().catch((error) => error);
sdk.dnsUp = true;
const second = await orders.ordersCreate().catch((error) => error);
assert.equal(second, first);
assert.equal(sdk.tokenRequests, 1);
});
test("replaces the client after a failed token request, so that the next call requests a new token", async () => {
const { sdk, createController } = fakeSdk();
const replaced: unknown[] = [];
const client = createPayPalClient({ configuration: {}, createController, onReplace: (error) => replaced.push(error) });
await assert.rejects(
client.call((orders) => orders.ordersCreate()),
{ name: "AxiosError", message: "getaddrinfo EAI_AGAIN api-m.paypal.com" }
);
assert.deepEqual([sdk.constructions, sdk.tokenRequests, replaced.length], [1, 1, 1]);
sdk.dnsUp = true;
assert.deepEqual(await client.call((orders) => orders.ordersCreate()), { statusCode: 201, body: PAYPAL_ORDER });
assert.deepEqual([sdk.constructions, sdk.tokenRequests], [2, 2]);
// The new client keeps its token.
await client.call((orders) => orders.ordersCreate());
assert.deepEqual([sdk.constructions, sdk.tokenRequests, replaced.length], [2, 2, 1]);
});
test("keeps the client after an answer of PayPal that leaves it usable", async () => {
let constructions = 0;
const client = createPayPalClient({
configuration: {},
createController: () => {
constructions += 1;
return { ordersCreate: async () => Promise.reject(apiError(422)) };
}
});
for (let call = 0; call < 3; call++) {
const error = await client.call((orders) => orders.ordersCreate()).catch((rejection) => rejection);
assert.equal(error.statusCode, 422);
}
assert.equal(constructions, 1);
});
test("a call that fails on a replaced client does not replace its successor", async () => {
let constructions = 0;
const pending: ((error: unknown) => void)[] = [];
const replaced: unknown[] = [];
const client = createPayPalClient({
configuration: {},
createController: () => {
constructions += 1;
return { ordersGet: () => new Promise<never>((_, reject) => pending.push(reject)) };
},
onReplace: (error) => replaced.push(error)
});
// Two calls run on the first client. The first one fails, which replaces the client, and a third call starts on the second.
const first = client.call((orders) => orders.ordersGet());
const second = client.call((orders) => orders.ordersGet());
pending[0](networkError(ORDERS_URL));
await assert.rejects(first);
const third = client.call((orders) => orders.ordersGet());
assert.equal(constructions, 2);
// The second call fails on the first client only now: the second client stays.
pending[1](networkError(ORDERS_URL));
await assert.rejects(second);
const fourth = client.call((orders) => orders.ordersGet());
assert.deepEqual([constructions, replaced.length], [2, 1]);
pending[2](apiError(404));
pending[3](apiError(404));
await Promise.all([assert.rejects(third), assert.rejects(fourth)]);
});
test("breaks the client with an error of the token request, without an answer, with 401 and with 5xx", () => {
for (const error of [
networkError(TOKEN_URL),
networkError(ORDERS_URL),
Object.assign(new Error("timeout of 15000ms exceeded"), { name: "AxiosError", code: "ECONNABORTED", config: { url: ORDERS_URL } }),
apiError(400, TOKEN_URL),
apiError(429, TOKEN_URL),
apiError(401, TOKEN_URL),
apiError(401, ORDERS_URL),
apiError(500, ORDERS_URL),
apiError(503, ORDERS_URL),
// The SDK's ResponseValidationError carries the status of the answer it could not read.
Object.assign(new Error("The response did not match the response schema."), { statusCode: 200, request: { url: TOKEN_URL } }),
new Error("The HTTP call was aborted."),
"a string",
undefined
]) {
assert.equal(breaksClient(error), true, String(error?.message ?? error));
}
for (const error of [apiError(400), apiError(404), apiError(409), apiError(422), apiError(429)]) {
assert.equal(breaksClient(error), false, error.message);
}
});
test("configures the SDK client with a timeout of 15 s and without logging bodies or headers", async () => {
const configuration = paypalClientConfiguration({ clientId: "DUMMY_CLIENT_ID", clientSecret: "DUMMY_CLIENT_SECRET", environment: "Production" });
assert.equal(PAYPAL_TIMEOUT_MS, 15000);
assert.deepEqual(configuration, {
clientCredentialsAuthCredentials: { oAuthClientId: "DUMMY_CLIENT_ID", oAuthClientSecret: "DUMMY_CLIENT_SECRET" },
timeout: 15000,
environment: "Production",
logging: { logRequest: { logBody: false, logHeaders: false }, logResponse: { logBody: false, logHeaders: false } }
});
// Every client the factory creates, also after a replacement, gets this configuration.
const { sdk, createController } = fakeSdk();
const client = createPayPalClient({ configuration, createController });
await client.call((orders) => orders.ordersCreate()).catch(() => {});
sdk.dnsUp = true;
await client.call((orders) => orders.ordersCreate());
assert.equal(sdk.constructions, 2);
for (const passed of sdk.configurations) assert.equal((passed as { timeout: number }).timeout, 15000);
});
+65 -238
View File
@@ -1,12 +1,6 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../../src/checkout/paypal-order.ts";
CheckoutNotReadyError,
ProductUnavailableError,
PRODUCT_UNAVAILABLE,
buildPayPalOrderRequest,
checkoutFailure
} from "../../src/checkout/paypal-order.ts";
import { calculateTotalProductPrice } from "../../src/checkout/pricing.ts"; import { calculateTotalProductPrice } from "../../src/checkout/pricing.ts";
import { vatDecimal, vatIncludedDecimal } from "../../src/checkout/vat.ts"; import { vatDecimal, vatIncludedDecimal } from "../../src/checkout/vat.ts";
@@ -33,7 +27,7 @@ const validOrder = (overrides = {}) => ({
}); });
// A cart of [price, count] lines with the totals the order service computes in update() (src/api/order/services/order.ts:151-173). // A cart of [price, count] lines with the totals the order service computes in update() (src/api/order/services/order.ts:151-173).
const orderFor = (lines: [number, number][], deliveryPrice: number | null, paymentPrice: number | null = null) => { const orderFor = (lines: [number, number][], deliveryPrice: number | null) => {
const cart = lines.map(([price, count], index) => ({ const cart = lines.map(([price, count], index) => ({
id: index + 1, id: index + 1,
count, count,
@@ -43,15 +37,17 @@ const orderFor = (lines: [number, number][], deliveryPrice: number | null, payme
return validOrder({ return validOrder({
cart, cart,
subtotal, subtotal,
total: Math.round((subtotal + (deliveryPrice ?? 0) + (paymentPrice ?? 0)) * 100) / 100, total: Math.round((subtotal + (deliveryPrice ?? 0)) * 100) / 100,
VAT: Math.round((subtotal / vatIncludedDecimal) * vatDecimal * 100) / 100, VAT: Math.round((subtotal / vatIncludedDecimal) * vatDecimal * 100) / 100,
delivery: deliveryPrice === null ? null : { id: 1, name: "DHL", price: deliveryPrice }, delivery: deliveryPrice === null ? null : { id: 1, name: "DHL", price: deliveryPrice }
payment: paymentPrice === null ? null : { id: 1, name: "Vorkasse", price: paymentPrice }
}); });
}; };
// What createSessionOrThrow would send to PayPal, or what it would throw before asking PayPal. // Both halves in the order createSessionOrThrow runs them: what it would send to PayPal, or what it would throw.
const buildSession = (order) => buildPayPalOrderRequest(RETURN_URL, order); const buildSession = (order) => {
buildPayPalOrderItems(order);
return buildPayPalOrderRequest(RETURN_URL, order);
};
const cents = (money) => (money ? Math.round(Number(money.value) * 100) : 0); const cents = (money) => (money ? Math.round(Number(money.value) * 100) : 0);
@@ -75,15 +71,6 @@ const assertNoTypeError = (build: () => unknown) => {
} }
}; };
const thrown = (build: () => unknown): unknown => {
try {
build();
} catch (error) {
return error;
}
assert.fail("did not throw");
};
// Characterization: what the builders do today. // Characterization: what the builders do today.
test("builds the PayPal order request for a valid order", () => { test("builds the PayPal order request for a valid order", () => {
@@ -130,61 +117,85 @@ test("sends no shipping amount when the order has no delivery method", () => {
}); });
}); });
// 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". test("rejects an order without a total before computing the items", () => {
// That check now throws CheckoutNotReadyError, which the checkout answers 400 instead of 502. for (const total of [0, null, undefined]) {
test("rejects an empty cart or an order without a total as not ready for checkout", () => { assert.throws(() => buildPayPalOrderItems(validOrder({ total })), { name: "Error", message: "Cart is empty or has no total" });
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("sends no items: amounts rounded per unit would miss the net and the VAT of the subtotal", () => { test("computes one item per cart line, which is not sent to PayPal", () => {
// 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 order = validOrder({
const unitNetCents = Math.round((1250 * 100) / 119); cart: [
const unit = buildSession(orderFor([[12.5, 3]], 4.95)).purchaseUnits[0]; { id: 1, count: 1, product: notebook },
{ id: 2, count: 3, product: { id: 102, name: "Journaling Booklet", cover: { price: 12.5 } } }
assert.equal("items" in unit, false); ]
assert.deepEqual([3 * unitNetCents, 3 * (1250 - unitNetCents)], [3150, 600]);
assert.deepEqual(unit.amount, {
currencyCode: "EUR",
value: "42.45",
breakdown: {
itemTotal: { currencyCode: "EUR", value: "31.51" },
taxTotal: { currencyCode: "EUR", value: "5.99" },
shipping: { currencyCode: "EUR", value: "4.95" }
}
}); });
assert.deepEqual(buildPayPalOrderItems(order), [
{
name: "Notizbuch A5 Punktraster",
unitAmount: { currencyCode: "EUR", value: "15.04" },
tax: { currencyCode: "EUR", value: "2.86" },
quantity: "1"
},
{
name: "Journaling Booklet",
unitAmount: { currencyCode: "EUR", value: "10.50" },
tax: { currencyCode: "EUR", value: "2.00" },
quantity: "3"
}
]);
}); });
// Fixed defects of https://git.librete.ch/libretech/mp/issues/67. // Known defects: these assert the correct behaviour and fail today, which the todo marks as expected.
test("D1: a missing delivery address is rejected with a clear validation error", () => { 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)); assert.throws(() => buildSession(validOrder({ deliveryAddress: null })), isClearError(/address/i));
}); }
);
test("D2: a two-line delivery address does not throw a TypeError", () => { 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" }))); assertNoTypeError(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })));
}); }
);
test("D3: a pickup whose delivery price is null is sent with shipping 0.00", () => { 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 }); 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", () => { 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)); 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", () => { test(
"D5: with a payment surcharge the breakdown still adds up to the amount",
{ todo: "the surcharge is in the amount but not in the breakdown — https://git.librete.ch/libretech/mp/issues/67" },
() => {
// total = 17.90 + 4.95 shipping + 1.50 surcharge // total = 17.90 + 4.95 shipping + 1.50 surcharge
const { amount } = buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })).purchaseUnits[0]; const { amount } = buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })).purchaseUnits[0];
assert.equal(amount.value, "24.35"); assert.equal(amount.value, "24.35");
assert.equal(breakdownCents(amount.breakdown), cents(amount)); assert.equal(breakdownCents(amount.breakdown), cents(amount));
}); }
);
// This held before the fixes too, for every whole-cent subtotal (Strapi stores prices as decimal(10,2)). It guards the fixes above. // Not a todo: this holds today for every whole-cent subtotal (Strapi stores prices as decimal(10,2)). It guards the fixes above.
test("D6: the breakdown adds up to the amount to the cent, also for the subtotals whose net is closest to half a cent", () => { test("D6: the breakdown adds up to the amount to the cent, also for the subtotals whose net is closest to half a cent", () => {
const carts: { lines: [number, number][]; delivery: number | null }[] = [ const carts: { lines: [number, number][]; delivery: number | null }[] = [
{ lines: [[17.9, 1]], delivery: 4.95 }, { lines: [[17.9, 1]], delivery: 4.95 },
@@ -221,187 +232,3 @@ test("D6: the breakdown adds up to the amount to the cent, also for the subtotal
// Compare the count, not the array: diffing tens of thousands of entries against [] takes many GB of memory. // Compare the count, not the array: diffing tens of thousands of entries against [] takes many GB of memory.
assert.equal(mismatches.length, 0, `${mismatches.length} carts do not add up, e.g. ${JSON.stringify(mismatches.slice(0, 3))}`); assert.equal(mismatches.length, 0, `${mismatches.length} carts do not add up, e.g. ${JSON.stringify(mismatches.slice(0, 3))}`);
}); });
// Shipping address: the structured delivery address, else the structured invoice address, else the text delivery address.
const structured = (overrides = {}) => ({
givenName: "Erika",
familyName: "Mustermann",
streetAddress: "Musterstraße 1",
postalCode: "70190",
addressLevel2: "Stuttgart",
country: "DE",
...overrides
});
const berlin = structured({ givenName: "Max", streetAddress: "Hauptstraße 5", postalCode: "10115", addressLevel2: "Berlin" });
const berlinShipping = {
name: { fullName: "Max Mustermann" },
address: { addressLine1: "Hauptstraße 5", postalCode: "10115", adminArea2: "Berlin", countryCode: "DE" }
};
// An address.structured-address component that was saved without components: Strapi fills in the default country.
const emptyStructured = { givenName: null, familyName: null, streetAddress: null, postalCode: null, addressLevel2: null, country: "DE" };
const shippingOf = (order) => buildSession(order).purchaseUnits[0].shipping;
test("takes the shipping address from the structured delivery address before the invoice and the text address", () => {
assert.deepEqual(shippingOf(validOrder({ deliveryAddressStructured: berlin, invoiceAddressStructured: structured() })), berlinShipping);
});
test("falls back to the structured invoice address when no structured delivery address is entered", () => {
for (const deliveryAddressStructured of [null, undefined, emptyStructured, { givenName: " ", country: "DE" }]) {
assert.deepEqual(
shippingOf(validOrder({ deliveryAddressStructured, invoiceAddressStructured: berlin, deliveryAddress: null })),
berlinShipping
);
}
});
test("trims the components, names the buyer by the name parts there are and upper-cases the country, DE by default", () => {
const order = (address) => validOrder({ deliveryAddressStructured: { ...structured(), ...address } });
assert.deepEqual(shippingOf(order({ givenName: " Erika ", familyName: null, streetAddress: " Musterstraße 1 ", country: "at" })), {
name: { fullName: "Erika" },
address: { addressLine1: "Musterstraße 1", postalCode: "70190", adminArea2: "Stuttgart", countryCode: "AT" }
});
for (const country of [null, undefined, ""]) {
assert.equal(shippingOf(order({ country })).address.countryCode, "DE");
}
});
test("rejects a structured address that is entered but incomplete instead of shipping to another address", () => {
const order = validOrder({ deliveryAddressStructured: structured({ streetAddress: "", postalCode: null }), invoiceAddressStructured: berlin });
assert.throws(() => buildSession(order), { name: "CheckoutNotReadyError", message: "deliveryAddressStructured lacks streetAddress, postalCode" });
assert.throws(() => buildSession(validOrder({ invoiceAddressStructured: structured({ givenName: null, familyName: "" }) })), {
name: "CheckoutNotReadyError",
message: "invoiceAddressStructured lacks givenName and familyName"
});
assert.throws(() => buildSession(validOrder({ deliveryAddressStructured: structured({ country: "Deutschland" }) })), {
name: "CheckoutNotReadyError",
message: "deliveryAddressStructured has no two-letter country code"
});
});
test("parses the text delivery address when no structured address is entered", () => {
const shippingFor = (deliveryAddress) => shippingOf(validOrder({ deliveryAddress, deliveryAddressStructured: emptyStructured }));
assert.deepEqual(shippingFor(" Erika Mustermann\r\nMusterstraße 1\r\n\r\n70190 Stuttgart-Bad Cannstatt \n"), {
name: { fullName: "Erika Mustermann" },
address: { addressLine1: "Musterstraße 1", postalCode: "70190", adminArea2: "Stuttgart-Bad Cannstatt", countryCode: "DE" }
});
// The street is the line above the postal code and city, as in a German address; the lines after the name go into addressLine2.
assert.deepEqual(shippingFor("Erika Mustermann\nc/o Muster GmbH\nHinterhaus\nMusterstraße 1\n70190 Stuttgart"), {
name: { fullName: "Erika Mustermann" },
address: {
addressLine1: "Musterstraße 1",
addressLine2: "c/o Muster GmbH, Hinterhaus",
postalCode: "70190",
adminArea2: "Stuttgart",
countryCode: "DE"
}
});
});
test("rejects a text delivery address it cannot parse, and a missing one, with a validation error", () => {
const cases: [unknown, string][] = [
["Erika Mustermann\nMusterstraße 1", "deliveryAddress needs the name, the street, and the postal code and city on lines of their own"],
["Erika Mustermann\n\nMusterstraße 1\n", "deliveryAddress needs the name, the street, and the postal code and city on lines of their own"],
["Erika Mustermann\nMusterstraße 1\n70190", "The last line of deliveryAddress is not the postal code and the city"],
[" \n ", "The order has no delivery address"],
["", "The order has no delivery address"],
[null, "The order has no delivery address"],
[undefined, "The order has no delivery address"]
];
for (const [deliveryAddress, message] of cases) {
assert.throws(() => buildSession(validOrder({ deliveryAddress })), { name: "CheckoutNotReadyError", message });
}
});
// Money in whole cents, and the errors the checkout answers.
test("reads decimals that arrive as strings like numbers", () => {
const order = validOrder({ subtotal: "17.90", total: "22.85", VAT: "2.86", delivery: { id: 1, name: "DHL", price: "4.95" } });
assert.deepEqual(buildSession(order), buildSession(validOrder()));
});
test("rejects totals that do not add up, as after a price change, instead of sending PayPal a breakdown it rejects", () => {
// The delivery method costs 5.95 now; the order's total was computed when it cost 4.95.
assert.throws(() => buildSession(validOrder({ delivery: { id: 1, name: "DHL", price: 5.95 } })), {
name: "CheckoutNotReadyError",
message: "The order's totals do not add up: total 2285 cents, items 1504 + VAT 286 + shipping 595 + surcharge 0"
});
assert.throws(() => buildSession(validOrder({ delivery: { id: 1, name: "DHL", price: -4.95 }, total: 12.95 })), {
name: "CheckoutNotReadyError",
message: "The order's totals do not add up: total 1295 cents, items 1504 + VAT 286 + shipping -495 + surcharge 0"
});
for (const overrides of [{ VAT: null }, { subtotal: undefined }, { VAT: "n/a" }]) {
assert.throws(() => buildSession(validOrder(overrides)), { name: "CheckoutNotReadyError", message: "The order has no subtotal or no VAT" });
}
});
test("sends a payment surcharge as handling, and no handling for a payment method that costs nothing", () => {
const breakdownOf = (order) => buildSession(order).purchaseUnits[0].amount.breakdown;
assert.deepEqual(breakdownOf(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })), {
itemTotal: { currencyCode: "EUR", value: "15.04" },
taxTotal: { currencyCode: "EUR", value: "2.86" },
shipping: { currencyCode: "EUR", value: "4.95" },
handling: { currencyCode: "EUR", value: "1.50" }
});
for (const payment of [null, undefined, { id: 1, name: "PayPal", price: 0 }, { id: 1, name: "PayPal", price: null }]) {
assert.equal("handling" in breakdownOf(validOrder({ payment })), false);
}
// A surcharge the total lacks, as when the price of the payment method changed after the totals were computed.
assert.throws(() => buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 } })), {
name: "CheckoutNotReadyError",
message: "The order's totals do not add up: total 2285 cents, items 1504 + VAT 286 + shipping 495 + surcharge 150"
});
});
test("adds up to the cent with a surcharge, with a pickup without a price, for every whole-cent subtotal up to 300.00", () => {
const mismatches: string[] = [];
for (let cent = 1; cent <= 30000; cent++) {
for (const [delivery, payment] of [
[4.95, 0.35],
[0, 1.5]
]) {
const order = orderFor([[cent / 100, 1]], delivery, payment);
if (delivery === 0) order.delivery = { id: 2, name: "Abholung", price: null };
const { amount } = buildSession(order).purchaseUnits[0];
if (breakdownCents(amount.breakdown) !== cents(amount) || cents(amount.breakdown.handling) !== Math.round(payment * 100)) {
mismatches.push(`${cent}/${delivery}/${payment}`);
}
}
}
assert.equal(mismatches.length, 0, `${mismatches.length} carts do not add up, e.g. ${mismatches.slice(0, 3).join(", ")}`);
});
test("rejects a deleted product next to available ones as unavailable", () => {
const order = validOrder({ cart: [{ id: 1, count: 1, product: notebook }, { id: 2, count: 1, product: null }, null] });
assert.throws(
() => buildSession(order),
(error) => error instanceof ProductUnavailableError && error.name === "ProductUnavailableError" && error.message === PRODUCT_UNAVAILABLE
);
});
test("answers an order that is not ready 400 with the reason, a deleted product 409 and any other error 502", () => {
assert.deepEqual(checkoutFailure(thrown(() => buildSession(validOrder({ deliveryAddress: null })))), {
status: 400,
message: "Order is not ready for checkout",
reason: "The order has no delivery address"
});
assert.equal(checkoutFailure(thrown(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })))).status, 400);
assert.equal(checkoutFailure(thrown(() => buildSession(validOrder({ total: 0 })))).status, 400);
assert.deepEqual(checkoutFailure(thrown(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })))), {
status: 409,
message: "A product in the cart is no longer available"
});
assert.ok(thrown(() => buildSession(validOrder({ total: 0 }))) instanceof CheckoutNotReadyError);
const sdkError = Object.assign(new Error("OAuth 2 provider says client authentication failed."), { statusCode: 401 });
for (const error of [sdkError, new TypeError("Cannot read properties of null (reading 'split')"), "/result/error", undefined]) {
assert.deepEqual(checkoutFailure(error), { status: 502, message: "Could not create the PayPal order" });
}
});
-260
View File
@@ -1,260 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { orderTotalCents, parseCents, verifyApprovedOrder, verifyCompletedCapture } from "../../src/checkout/paypal-verify.ts";
import { buildPayPalOrderRequest } from "../../src/checkout/paypal-order.ts";
const UUID = "11111111-2222-4333-8444-555555555555";
// PayPal's Orders v2 JSON as PayPalApi parses the response body: the PayPal order once the buyer approved it
// (GET /v2/checkout/orders/:id), and the PayPal order after its capture (POST /v2/checkout/orders/:id/capture with
// Prefer: return=representation). Both belong to the order below. Every call returns a fresh copy to change.
const fixture = (name: string) => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8"));
const approved = () => fixture("paypal-order-approved.json");
const captured = () => fixture("paypal-order-captured.json");
// The order of paypal-order.test.ts: subtotal 17.90, delivery 4.95, total 22.85.
const order = (overrides = {}) => ({
id: 4711,
uuid: UUID,
email: "erika@example.org",
subtotal: 17.9,
total: 22.85,
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
});
// What identifies the buyer or the merchant in the fixtures. A reason is logged, so it repeats none of it.
const PERSONAL_DATA = [
"Erika",
"Mustermann",
"erika",
"QYR5Z8XDVJNXQ",
"Musterstraße",
"70190",
"Stuttgart",
"merchant@example.com",
"7KNGBPH2U58GQ"
];
/** The reason of a rejection, after checking that it repeats no personal data. */
const rejection = (result: { ok: boolean; reason?: string }): string => {
assert.equal(result.ok, false, "expected a rejection");
for (const value of PERSONAL_DATA) assert.ok(!result.reason.includes(value), `the reason repeats ${value}: ${result.reason}`);
return result.reason;
};
// Money
test("parses PayPal's money values into integer cents", () => {
assert.equal(parseCents("22.85"), 2285);
assert.equal(parseCents("0.01"), 1);
assert.equal(parseCents("4.9"), 490);
assert.equal(parseCents("100"), 10000);
assert.equal(parseCents("0.00"), 0);
assert.equal(parseCents("1234567.89"), 123456789);
});
test("parses nothing but a non-negative amount with at most two decimals", () => {
for (const value of ["", "22.850", "-22.85", "22,85", " 22.85", "22.85 ", "2.285e1", "22.", ".85", "0x10", "Infinity", "NaN", "12345678901.00"]) {
assert.equal(parseCents(value), null, JSON.stringify(value));
}
for (const value of [22.85, null, undefined, {}, ["22.85"]]) {
assert.equal(parseCents(value), null, String(value));
}
});
test("turns the order total into integer cents, also where its float is a hair below the cent", () => {
// 22.85 * 100 is 2284.9999999999995 and 0.29 * 100 is 28.999999999999996 in floating point
assert.equal(orderTotalCents({ total: 22.85 }), 2285);
assert.equal(orderTotalCents({ total: 0.29 }), 29);
assert.equal(orderTotalCents({ total: 17.9 + 4.95 }), 2285);
for (const total of [null, undefined, 0, -4.95, NaN, Infinity]) {
assert.equal(orderTotalCents({ total }), null, String(total));
}
});
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;
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(", ")}`);
});
test("the fixtures belong to the PayPal order the checkout requests for the order", () => {
const request = buildPayPalOrderRequest("https://shop.example/checkout/3", order()).purchaseUnits[0];
for (const paypalOrder of [approved(), captured()]) {
const unit = paypalOrder.purchase_units[0];
assert.equal(unit.custom_id, request.customId);
assert.equal(unit.amount.value, request.amount.value);
assert.equal(unit.amount.currency_code, request.amount.currencyCode);
}
});
// The PayPal order the buyer approved
test("accepts the PayPal order the buyer approved for the order", () => {
assert.deepEqual(verifyApprovedOrder(order(), approved()), { ok: true });
});
test("rejects a PayPal order that is not approved", () => {
for (const status of ["CREATED", "SAVED", "PAYER_ACTION_REQUIRED", "VOIDED", "COMPLETED"]) {
assert.equal(rejection(verifyApprovedOrder(order(), { ...approved(), status })), `the PayPal order status is ${status}, not APPROVED`);
}
const { status, ...withoutStatus } = approved();
assert.equal(rejection(verifyApprovedOrder(order(), withoutStatus)), "the PayPal order status is missing or unexpected, not APPROVED");
// a value that is not one of PayPal's statuses is not repeated
assert.equal(
rejection(verifyApprovedOrder(order(), { ...approved(), status: "APPROVED BY ERIKA MUSTERMANN" })),
"the PayPal order status is missing or unexpected, not APPROVED"
);
});
test("rejects the PayPal order of a cheap order captured for an expensive one", () => {
// The buyer pays order A, then calls the capture of order B with A's PayPal order.
const orderB = order({ id: 4712, uuid: "99999999-8888-4777-8666-555555555555", total: 249 });
assert.equal(rejection(verifyApprovedOrder(orderB, approved())), "the custom_id of the PayPal order is not the order's uuid");
});
test("rejects a PayPal order without a custom_id, also for an order without a uuid", () => {
const paypalOrder = approved();
delete paypalOrder.purchase_units[0].custom_id;
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the custom_id of the PayPal order is not the order's uuid");
// undefined is not compared with undefined, nor "" with ""
assert.equal(
rejection(verifyApprovedOrder(order({ uuid: undefined }), paypalOrder)),
"the custom_id of the PayPal order is not the order's uuid"
);
paypalOrder.purchase_units[0].custom_id = "";
assert.equal(rejection(verifyApprovedOrder(order({ uuid: "" }), paypalOrder)), "the custom_id of the PayPal order is not the order's uuid");
});
test("rejects a PayPal order whose amount is not the order total", () => {
const withAmount = (value: unknown) => {
const paypalOrder = approved();
paypalOrder.purchase_units[0].amount.value = value;
return paypalOrder;
};
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("22.84"))), "the PayPal order amount 22.84 is not the order total 22.85");
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("22.86"))), "the PayPal order amount 22.86 is not the order total 22.85");
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("2285"))), "the PayPal order amount 2285.00 is not the order total 22.85");
// the cart changed after the checkout
assert.equal(rejection(verifyApprovedOrder(order({ total: 249 }), approved())), "the PayPal order amount 22.85 is not the order total 249.00");
for (const value of ["22.850", "22,85", "", 22.85, null]) {
assert.equal(rejection(verifyApprovedOrder(order(), withAmount(value))), "the PayPal order amount is not a money value", String(value));
}
});
test("rejects a PayPal order in another currency or without an amount", () => {
const paypalOrder = approved();
paypalOrder.purchase_units[0].amount.currency_code = "USD";
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order currency is USD, not EUR");
paypalOrder.purchase_units[0].amount.currency_code = "Euro";
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order currency is missing or unexpected, not EUR");
delete paypalOrder.purchase_units[0].amount;
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order has no amount");
});
test("rejects every PayPal order for an order without a total", () => {
for (const total of [null, 0]) {
assert.equal(rejection(verifyApprovedOrder(order({ total }), approved())), "the order has no total");
}
});
test("rejects a PayPal order without a purchase unit, and anything that is not a PayPal order", () => {
assert.equal(rejection(verifyApprovedOrder(order(), { ...approved(), purchase_units: [] })), "the PayPal order has no purchase unit");
const { purchase_units, ...withoutUnits } = approved();
assert.equal(rejection(verifyApprovedOrder(order(), withoutUnits)), "the PayPal order has no purchase unit");
// "/result/error" is what PayPalApi.checkPaymentStatus answered for every error
for (const value of [null, undefined, "/result/error", [], 42]) {
assert.equal(rejection(verifyApprovedOrder(order(), value)), "the PayPal order is not an object", String(value));
}
});
// The PayPal order after the capture
test("accepts the completed capture of the order total and returns the capture id", () => {
assert.deepEqual(verifyCompletedCapture(order(), captured()), { ok: true, captureId: "3C679366HH908993F" });
});
test("rejects a capture response whose PayPal order is not completed", () => {
for (const status of ["APPROVED", "PAYER_ACTION_REQUIRED", "VOIDED"]) {
assert.equal(rejection(verifyCompletedCapture(order(), { ...captured(), status })), `the PayPal order status is ${status}, not COMPLETED`);
}
});
test("rejects a capture PayPal holds for review, declined or refunded", () => {
for (const status of ["PENDING", "DECLINED", "FAILED", "REFUNDED", "PARTIALLY_REFUNDED"]) {
const response = captured();
response.purchase_units[0].payments.captures[0].status = status;
if (status === "PENDING") response.purchase_units[0].payments.captures[0].status_details = { reason: "PENDING_REVIEW" };
assert.equal(rejection(verifyCompletedCapture(order(), response)), `the capture status is ${status}, not COMPLETED`);
}
});
test("rejects a capture response without a capture", () => {
// what PayPal answers with Prefer: return=minimal, which the capture used to ask for
const minimal = { id: "5O190127TN364715T", status: "COMPLETED", links: captured().links };
assert.equal(rejection(verifyCompletedCapture(order(), minimal)), "the capture response has no capture");
const withoutPayments = captured();
delete withoutPayments.purchase_units[0].payments;
assert.equal(rejection(verifyCompletedCapture(order(), withoutPayments)), "the capture response has no capture");
const withoutCaptures = captured();
withoutCaptures.purchase_units[0].payments.captures = [];
assert.equal(rejection(verifyCompletedCapture(order(), withoutCaptures)), "the capture response has no capture");
assert.equal(rejection(verifyCompletedCapture(order(), { ...captured(), purchase_units: [] })), "the capture response has no capture");
});
test("rejects a capture of another amount or in another currency", () => {
const lower = captured();
lower.purchase_units[0].payments.captures[0].amount.value = "2.29";
assert.equal(rejection(verifyCompletedCapture(order(), lower)), "the capture amount 2.29 is not the order total 22.85");
// the cart changed during the capture
assert.equal(rejection(verifyCompletedCapture(order({ total: 249 }), captured())), "the capture amount 22.85 is not the order total 249.00");
const usd = captured();
usd.purchase_units[0].payments.captures[0].amount.currency_code = "USD";
assert.equal(rejection(verifyCompletedCapture(order(), usd)), "the capture currency is USD, not EUR");
const withoutAmount = captured();
delete withoutAmount.purchase_units[0].payments.captures[0].amount;
assert.equal(rejection(verifyCompletedCapture(order(), withoutAmount)), "the capture has no amount");
});
test("rejects a capture without a valid id", () => {
for (const id of [undefined, "", 12345, "3C679366HH908993F; Erika Mustermann", "x".repeat(65)]) {
const response = captured();
response.purchase_units[0].payments.captures[0].id = id;
assert.equal(rejection(verifyCompletedCapture(order(), response)), "the capture has no valid id", String(id));
}
});
test("rejects anything that is not a capture response", () => {
for (const value of [null, undefined, "/result/error", [], 42]) {
assert.equal(rejection(verifyCompletedCapture(order(), value)), "the capture response is not an object", String(value));
}
});
-3
View File
@@ -389,10 +389,7 @@ export interface ApiOrderOrder extends Schema.CollectionType {
invoiceSent: Attribute.Boolean & Attribute.DefaultTo<false>; invoiceSent: Attribute.Boolean & Attribute.DefaultTo<false>;
payment: Attribute.Relation<"api::order.order", "oneToOne", "api::payment.payment">; payment: Attribute.Relation<"api::order.order", "oneToOne", "api::payment.payment">;
paymentAuthorised: Attribute.Boolean & Attribute.DefaultTo<false>; paymentAuthorised: Attribute.Boolean & Attribute.DefaultTo<false>;
paymentCapturedAt: Attribute.DateTime & Attribute.Private;
paymentStatus: Attribute.String; paymentStatus: Attribute.String;
paypalCaptureId: Attribute.String & Attribute.Private;
paypalOrderId: Attribute.String & Attribute.Private;
subtotal: Attribute.Decimal; subtotal: Attribute.Decimal;
total: Attribute.Decimal; total: Attribute.Decimal;
updatedAt: Attribute.DateTime; updatedAt: Attribute.DateTime;