Compare commits

10 Commits
Author SHA1 Message Date
libretech 5002cac469 Checkout: only customer fields on order updates, verified PayPal capture, safe logging (#6)
build / test (push) Successful in 11s
build / build (push) Successful in 8m31s
Co-authored-by: Michael Czechowski <mail@librete.ch>
2026-10-15 10:23:02 +02:00
libretech 9b0c6af8b7 Test the checkout code; publish images only for releases (#5)
build / test (push) Successful in 7s
build / build (push) Skipped
- src/checkout/ (PayPal order request, pricing, VAT) and src/logging/format.ts
  hold the checkout's pure code, moved out of PayPalApi, the product service
  and config without a behaviour change.
- npm test runs node:test on the TypeScript sources (type stripping), with no
  dependencies: 21 tests, 13 pin today's output, 8 describe the correct
  behaviour for the known defects of libretech/mp#67
  and are marked todo.
- CI: the tests run in the image's base (node:22.14.0-alpine) for every pull
  request, push to main and release tag. The image is built for pull requests
  and built and published only for v* tags; main no longer publishes :main
  and :sha-* images.

Refs libretech/mp#71

Co-authored-by: Michael Czechowski <mail@librete.ch>
2026-10-08 22:36:47 +02:00
libretech 901a3b385f feat(product): price standalone products from the cover alone
build / build (push) Successful in 8m52s
Recovered work in progress from 2026-05. A product-cover can be marked
standalone, and a standalone product (Journaling Booklet) prices as the
cover price without the pattern/pages/ruling lookups, which do not exist
for it. The remaining component lookups are now guarded so a missing
relation yields null instead of calling findOne with undefined.
2026-09-23 22:10:06 +02:00
libretech 7f7cacc448 ci: bump runner-image pin v0.1.0 → v0.2.0 (#4)
build / build (push) Successful in 5m57s
Co-authored-by: Michael Czechowski <mail@dailysh.it>
Co-committed-by: Michael Czechowski <mail@dailysh.it>
2026-05-04 23:46:06 +02:00
libretech fa2a81ecd1 fix(ci): correct image digest separator
build / build (push) Successful in 7m47s
2026-04-30 12:20:33 +02:00
libretech 8e0554572b ci: digest-pin runner-image v0.1.0
build / build (push) Failing after 0s
2026-04-30 12:17:55 +02:00
libretech 3213a5a400 ci: update runner-image path to public/ namespace
build / build (push) Failing after 1s
2026-04-30 11:57:23 +02:00
libretech 5c36ce7dbb chore: update action ref to public/actions
build / build (push) Successful in 6m4s
2026-04-29 22:50:14 +02:00
libretech 9600bc8fd3 chore: slim build.yml to use shared docker-build action (#3)
build / build (push) Successful in 6m43s
2026-04-29 22:08:31 +02:00
libretech defd078e4b feat(cms): white-label email templates and API doc config (#2)
Build and publish / build (push) Successful in 7m11s
## Summary

- Replace all hardcoded muellerprints brand identity in email templates with template variables sourced from env vars
- Inject shop context into both `sendInvoice` and `sendDeliveryNote` template calls in `order.ts`
- Make API documentation title/description env-var driven

## New env vars (all optional with safe defaults)

| Var | Default | Used in |
|-----|---------|---------|
| `SHOP_NAME` | `"Shop"` | Email subject, body, footer |
| `SHOP_CONTACT_NAME` | `""` | Email footer |
| `SHOP_ADDRESS` | `""` | Email footer |
| `SHOP_PHONE` | `""` | Email footer |
| `SHOP_EMAIL` | `ADMIN_EMAIL_ADDRESS` | Email footer |
| `SHOP_LOGO_URL` | `""` | Email header logo |
| `SHOP_SECONDARY_LOGO_URL` | `""` | Email footer logo |
| `API_TITLE` | `"Paperwork API"` | Swagger/OpenAPI title |
| `API_DESCRIPTION` | `"Paperwork API"` | Swagger/OpenAPI description |

## Test plan

- [ ] Set `SHOP_NAME=TestShop` and trigger invoice send → confirm subject and body use `TestShop`
- [ ] Leave `SHOP_NAME` unset → confirm default `"Shop"` appears
- [ ] Set `SHOP_LOGO_URL` → confirm logo renders in email header; unset → confirm no broken `<img>` tag
- [ ] Check Strapi admin `/documentation` with `API_TITLE=MyAPI` env var

Closes #1

Reviewed-on: #2
Co-authored-by: Michael Czechowski <mail@dailysh.it>
Co-committed-by: Michael Czechowski <mail@dailysh.it>
2026-04-29 20:13:23 +02:00
41 changed files with 3379 additions and 293 deletions

No files matched your search

+27 -38
View File
@@ -1,4 +1,4 @@
name: Build and publish name: build
on: on:
push: push:
@@ -7,49 +7,38 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
# Required secrets:
# REGISTRY git.librete.ch
# REGISTRY_USER libretech-bot
# REGISTRY_PASS bot PAT (write:package; bot is in libreshop Owners team)
# Required variable:
# PUBLISH_ENABLED "true" to actually push (off = build-only on PRs)
#
# Image: git.librete.ch/libreshop/cms
# main pushes → :main + :sha-<short>
# tag pushes → :<tag> + :latest
jobs: jobs:
build: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: git.librete.ch/libretech/runner-image:v1 image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# The tests need no dependencies. They run in the image's base (Dockerfile FROM), so on the runtime's Node,
# without network. The source is piped in because the job container's paths do not exist on the Docker host.
- name: npm test, on the Node of the image
run: |
base=$(sed -n 's/^FROM \([^ ]*\).*/\1/p' Dockerfile | head -1)
tar -c --exclude=.git . | docker run -i --rm --network none -e npm_config_update_notifier=false "$base" \
sh -c 'mkdir /w && cd /w && tar -x && npm test'
# The image is built to check a pull request, and built and published only for a release tag.
# Nothing pulls per-commit images, so main no longer publishes :main and :sha-* images.
build:
needs: test
if: github.event_name == 'pull_request' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
container:
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3 - uses: https://git.librete.ch/public/actions/.gitea/actions/docker-build@main
- name: Login (only when publishing)
if: ${{ vars.PUBLISH_ENABLED == 'true' }}
uses: docker/login-action@v3
with: with:
registry: ${{ secrets.REGISTRY }} registry: ${{ secrets.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }} registry_user: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }} registry_pass: ${{ secrets.REGISTRY_PASS }}
publish: ${{ startsWith(github.ref, 'refs/tags/v') && vars.PUBLISH_ENABLED == 'true' }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.REGISTRY }}/libreshop/cms
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,format=short
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
- uses: docker/build-push-action@v6
with:
context: .
push: ${{ vars.PUBLISH_ENABLED == 'true' && github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+20 -1
View File
@@ -2,7 +2,26 @@
All notable changes to libreshop/cms are documented here. All notable changes to libreshop/cms are documented here.
## Unreleased ## [0.1.1] - 2026-04-29
### Changed
- White-label email templates: replace all hardcoded muellerprints company
identity (name, contact, address, phone, email, logo URLs) with
`<%= shopName %>` / `<%= shopContactName %>` / `<%= shopAddress %>` /
`<%= shopPhone %>` / `<%= shopEmail %>` / `<%= shopLogoUrl %>` /
`<%= shopSecondaryLogoUrl %>` template variables, sourced from env vars
`SHOP_NAME`, `SHOP_CONTACT_NAME`, `SHOP_ADDRESS`, `SHOP_PHONE`,
`SHOP_EMAIL`, `SHOP_LOGO_URL`, `SHOP_SECONDARY_LOGO_URL`.
- `config/constants.ts`: export shop brand constants from env.
- `src/api/order/services/order.ts`: inject shop context into template calls.
- `config/plugins.ts`: API doc title/description driven by `API_TITLE` /
`API_DESCRIPTION` env vars (defaults: `"Paperwork API"`).
- `src/extensions/documentation/config/settings.json`: neutral placeholder.
Closes #1
## [0.1.0] - 2026-04-29
- Extracted from `mp/cms/` (2026-04-29). The component history before - Extracted from `mp/cms/` (2026-04-29). The component history before
the extraction lives in the `muellerprints` repository. the extraction lives in the `muellerprints` repository.
+10 -4
View File
@@ -1,4 +1,5 @@
import { Environment } from "@paypal/paypal-server-sdk"; import { Environment } from "@paypal/paypal-server-sdk";
import { vatDecimal } from "../src/checkout/vat";
export const pdfApiUrl = process.env.PDF_API_ADDRESS!; export const pdfApiUrl = process.env.PDF_API_ADDRESS!;
export const mailApiUrl = process.env.MAIL_API_ADDRESS!; export const mailApiUrl = process.env.MAIL_API_ADDRESS!;
@@ -10,10 +11,15 @@ export const paypalEnvironment = process.env.PAYPAL_ENVIRONMENT! === "production
export const adminEmail = process.env.ADMIN_EMAIL_ADDRESS!; export const adminEmail = process.env.ADMIN_EMAIL_ADDRESS!;
// TODO: Should be retrieved from DepotApi export const shopName = process.env.SHOP_NAME ?? "Shop";
export const vatIncludedDecimal = 1.19; export const shopContactName = process.env.SHOP_CONTACT_NAME ?? "";
// TODO: Should be retrieved from DepotApi export const shopAddress = process.env.SHOP_ADDRESS ?? "";
export const vatDecimal = 0.19; export const shopPhone = process.env.SHOP_PHONE ?? "";
export const shopEmail = process.env.SHOP_EMAIL ?? process.env.ADMIN_EMAIL_ADDRESS ?? "";
export const shopLogoUrl = process.env.SHOP_LOGO_URL ?? "";
export const shopSecondaryLogoUrl = process.env.SHOP_SECONDARY_LOGO_URL ?? "";
export { vatIncludedDecimal, vatDecimal } from "../src/checkout/vat";
export const vatDecimalExcluded = 1 - vatDecimal; export const vatDecimalExcluded = 1 - vatDecimal;
export const maxProductsSitemap = 500; export const maxProductsSitemap = 500;
+9 -8
View File
@@ -1,16 +1,17 @@
import { winston } from "@strapi/logger"; import { winston } from "@strapi/logger";
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( format: winston.format.combine(winston.format.printf((info) => formatLogLine(info, { stack: level === "debug" })))
winston.format.printf(({ timestamp, level, message, ...rest }) => {
let restString = JSON.stringify(rest, undefined, 2);
restString = restString === "{}" ? "" : restString;
return `${timestamp}Z ${level}: ${message} ${restString}`;
})
)
}) })
] ]
}; };
+4 -8
View File
@@ -10,15 +10,11 @@ export default ({ env }) => ({
openapi: "3.0.1", openapi: "3.0.1",
info: { info: {
version: "1.0.0", version: "1.0.0",
title: "MUELLERPTINTS. Paperwork", title: env("API_TITLE", "Paperwork API"),
description: "API Documentation for MUELLERPRINTS. Paperwork", description: env("API_DESCRIPTION", "Paperwork API"),
termsOfService: false, termsOfService: false,
contact: { contact: false,
name: "Michael W. Czechowski", license: false,
email: "mail@dailysh.it",
url: "https://dailysh.it"
},
license: "Copyright (C) 2024 Michael W. Czechowski",
externalDocs: false externalDocs: false
}, },
"x-strapi-config": { "x-strapi-config": {
+1
View File
@@ -7,6 +7,7 @@
"build": "strapi build --debug", "build": "strapi build --debug",
"strapi": "strapi", "strapi": "strapi",
"lint:fix": "prettier --write \"{src,types,config}/**/*.{js,jsx,ts,tsx}\"", "lint:fix": "prettier --write \"{src,types,config}/**/*.{js,jsx,ts,tsx}\"",
"test": "node --experimental-strip-types --import ./tests/unit/support/register.mjs --test 'tests/unit/**/*.test.ts'",
"generate:types": "strapi ts:generate-types", "generate:types": "strapi ts:generate-types",
"import": "npm run strapi import -- -f database/export.tar.gz --force", "import": "npm run strapi import -- -f database/export.tar.gz --force",
"export": "npm run strapi export -- --no-encrypt --file database/export_$(date +'%Y%m%d%H%M%S')", "export": "npm run strapi export -- --no-encrypt --file database/export_$(date +'%Y%m%d%H%M%S')",
@@ -83,6 +83,18 @@
"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
+112 -54
View File
@@ -1,9 +1,25 @@
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.
@@ -68,6 +84,7 @@ 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: {
@@ -75,7 +92,6 @@ 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"));
}, },
@@ -85,6 +101,7 @@ 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: {
@@ -92,35 +109,46 @@ 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 sanitizedBody = await sanitize.contentAPI.input(ctx.request.body, strapi.getModel("api::order.order")); const update = parseCustomerUpdate(ctx.request.body, new Date());
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
// @ts-expect-error if (data.invoiceAddressStructured) {
if (sanitizedBody?.data?.invoiceAddressStructured) { const address = data.invoiceAddressStructured;
const customerAddress = const customerAddress =
// @ts-expect-error data.invoiceAddress ||
sanitizedBody.data.invoiceAddress || `${address.givenName} ${address.familyName}\n${address.streetAddress}\n${address.postalCode} ${address.addressLevel2}`;
// @ts-expect-error data.customer = await strapi.service("api::customer.customer").findOrCreateCustomer(customerAddress, address);
`${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, {
// @ts-expect-error data,
data: sanitizedBody?.data,
...orderDefaultParams ...orderDefaultParams
}); });
@@ -131,6 +159,7 @@ 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;
@@ -147,8 +176,9 @@ 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 }, data: { cart: cart, paypalOrderId: null },
...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 });
@@ -162,6 +192,7 @@ 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 || [])];
@@ -179,14 +210,20 @@ 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 }, data: { cart: updatedCart, paypalOrderId: null },
...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"));
@@ -195,49 +232,68 @@ 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);
return await paypalApi.createSessionOrThrow(returnUrl as string, order); const missing = missingCheckoutFields(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 order = await strapi.db.query("api::order.order").findOne({ const result = await capturePayment(
where: { uuid }, {
...orderDefaultParams findOrder: (orderUuid) => strapi.db.query("api::order.order").findOne({ where: { uuid: orderUuid } }),
}); getPayPalOrder: (id) => paypalApi.getOrder(id),
if (!order) return ctx.notFound("Order not found"); capturePayPalOrder: (id, requestId) => paypalApi.captureOrder(id, requestId),
// The where clause makes the write conditional: a cart or address change clears paypalOrderId, so an order changed
if (order.paymentAuthorised) { // during the capture is not found, and stays unpaid. Strapi's update answers null then and runs no afterUpdate.
strapi.log.info(`app:i:order-controller: Payment already authorised for order ${uuid}`); updateOrder: (id, storedPayPalOrderId, data) =>
return { success: true, alreadyCaptured: true }; strapi.db.query("api::order.order").update({ where: { id, paypalOrderId: storedPayPalOrderId }, data, ...orderDefaultParams }),
} log: captureLog
try {
// Capture the payment via PayPal server SDK
const captureResult = await paypalApi.checkPaymentStatus({ orderID: paypalOrderId as string });
if (captureResult.status === "COMPLETED") {
// Update order with payment info
const updatedOrder = await strapi.entityService.update("api::order.order", order.id, {
data: {
paymentAuthorised: true,
paymentStatus: "authorised",
paypalOrderId: paypalOrderId as string
}, },
...orderDefaultParams {
}); uuid: typeof uuid === "string" ? uuid : "",
paypalOrderId: typeof paypalOrderId === "string" ? paypalOrderId : "",
strapi.log.info(`app:i:order-controller: Payment captured successfully for order ${uuid}`); now: new Date()
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"); switch (result.status) {
case 200:
if (result.alreadyCaptured === true) return { success: true, alreadyCaptured: true };
return await sanitize.contentAPI.output(result.order, strapi.getModel("api::order.order"));
case 404:
return ctx.notFound(result.message);
case 409:
return ctx.conflict(result.message);
case 502:
return ctx.badGateway(result.message);
default:
return ctx.internalServerError(result.message);
} }
}, },
@@ -245,6 +301,7 @@ 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");
@@ -263,6 +320,7 @@ 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");
@@ -300,7 +358,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(`paypal-api: ${JSON.stringify({ error })}`); strapi.log.error("app:e:order-controller: Handling the PayPal webhook failed", { error: describeError(error) });
return ctx.internalServerError(); return ctx.internalServerError();
} }
} }
+13 -8
View File
@@ -1,9 +1,9 @@
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 { sanitize } from "@strapi/utils"; import { errors, sanitize } from "@strapi/utils";
import { ID } from "@strapi/database/dist/types"; import { ID } from "@strapi/database/dist/types";
import { vatDecimal, vatIncludedDecimal, baseUrl, adminEmail } 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";
import { CartProduct, Order, PdfBody } from "../../../../types"; import { CartProduct, Order, PdfBody } from "../../../../types";
import { pdfApi } from "../../../services/PdfApi"; import { pdfApi } from "../../../services/PdfApi";
@@ -32,6 +32,7 @@ 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: {
@@ -80,13 +81,15 @@ 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 };
try { try {
const emailConfig: MessageBody = { const emailConfig: MessageBody = {
to_email: oderUnsafe.email, to_email: oderUnsafe.email,
subject: template(invoiceEmailTemplate.subject)(oderUnsafe), subject: template(invoiceEmailTemplate.subject)({ ...shopContext, ...oderUnsafe }),
message: template(invoiceEmailTemplate.text)({ baseUrl, ...oderUnsafe }), message: template(invoiceEmailTemplate.text)({ baseUrl, ...shopContext, ...oderUnsafe }),
html: template(invoiceEmailTemplate.html)({ baseUrl, ...oderUnsafe }) html: template(invoiceEmailTemplate.html)({ baseUrl, ...shopContext, ...oderUnsafe })
}; };
strapi.log.debug(`app:d:order-service: - Email config ${emailConfig.html}`); strapi.log.debug(`app:d:order-service: - Email config ${emailConfig.html}`);
@@ -118,12 +121,14 @@ 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 };
try { try {
const emailConfig: MessageBody = { const emailConfig: MessageBody = {
to_email: oderUnsafe.email, to_email: oderUnsafe.email,
subject: template(deliveryNoteEmailTemplate.subject)(oderUnsafe), subject: template(deliveryNoteEmailTemplate.subject)({ ...shopContext, ...oderUnsafe }),
message: template(deliveryNoteEmailTemplate.text)(oderUnsafe), message: template(deliveryNoteEmailTemplate.text)({ ...shopContext, ...oderUnsafe }),
html: template(deliveryNoteEmailTemplate.html)({ ...oderUnsafe, baseUrl }) html: template(deliveryNoteEmailTemplate.html)({ baseUrl, ...shopContext, ...oderUnsafe })
}; };
await mailApi.sendTextMessage(emailConfig); await mailApi.sendTextMessage(emailConfig);
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}`);
@@ -54,6 +54,10 @@
"price": { "price": {
"type": "decimal" "type": "decimal"
}, },
"standalone": {
"type": "boolean",
"default": false
},
"products": { "products": {
"type": "relation", "type": "relation",
"relation": "oneToMany", "relation": "oneToMany",
@@ -11,10 +11,26 @@ export default {
strapi.log.verbose("app:v:product-lifecycle: Before create", { strapi.log.verbose("app:v:product-lifecycle: Before create", {
params params
}); });
const cover = await strapi.entityService.findOne("api::product-cover.product-cover", params.data?.cover, { fields: ["price"] }); const cover = params.data?.cover
const pattern = await strapi.entityService.findOne("api::product-pattern.product-pattern", params.data?.pattern); ? await strapi.entityService.findOne("api::product-cover.product-cover", params.data.cover, { fields: ["price", "standalone"] })
const pages = await strapi.entityService.findOne("api::product-page.product-page", params.data?.pages, { fields: ["price"] }); : null;
const ruling = await strapi.entityService.findOne("api::product-ruling.product-ruling", params.data?.ruling, { fields: ["price"] });
// Standalone products (e.g. Journaling Booklet) have no pattern/pages/ruling.
// Price is the cover price alone — skip combinatorial component lookups.
if (params.data?.standalone || (cover as any)?.standalone) {
params.data.totalPrice = cover?.price ?? 0;
return;
}
const pattern = params.data?.pattern
? await strapi.entityService.findOne("api::product-pattern.product-pattern", params.data.pattern)
: null;
const pages = params.data?.pages
? await strapi.entityService.findOne("api::product-page.product-page", params.data.pages, { fields: ["price"] })
: null;
const ruling = params.data?.ruling
? await strapi.entityService.findOne("api::product-ruling.product-ruling", params.data.ruling, { fields: ["price"] })
: null;
const product = { const product = {
...params.data, ...params.data,
cover, cover,
+18 -1
View File
@@ -108,6 +108,12 @@ export default factories.createCoreController("api::product.product", ({ strapi
strapi.log.verbose(`Fetching variants for product ${id}`); strapi.log.verbose(`Fetching variants for product ${id}`);
const product = await getProductDetails(id as ID); const product = await getProductDetails(id as ID);
// Standalone products (e.g. Journaling Booklet) have no pattern — no variants.
if (!product.pattern?.id) {
return [];
}
const variants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", { const variants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", {
...productVariantParams, ...productVariantParams,
filters: { filters: {
@@ -146,6 +152,11 @@ export default factories.createCoreController("api::product.product", ({ strapi
const product = await getProductDetails(id as ID); const product = await getProductDetails(id as ID);
const { pattern, cover, ruling, pages } = product; const { pattern, cover, ruling, pages } = product;
// Standalone products (e.g. Journaling Booklet) have no pattern → no variants.
if (!pattern?.id) {
return { allProductPattern: [], productVariants: [], patterns: [] };
}
const productVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", { const productVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", {
...productVariantParams, ...productVariantParams,
filters: { filters: {
@@ -203,6 +214,11 @@ export default factories.createCoreController("api::product.product", ({ strapi
const product = await getProductDetails(id as ID); const product = await getProductDetails(id as ID);
// Standalone products (e.g. Journaling Booklet) have no pattern → no variant groups.
if (!product.pattern?.id) {
return { pages: [], cover: [], ruling: [] };
}
const allProductPatternVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>( const allProductPatternVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>(
"api::product.product", "api::product.product",
{ {
@@ -335,7 +351,8 @@ export default factories.createCoreController("api::product.product", ({ strapi
const productGroups = new Map(); const productGroups = new Map();
allProductsSanitized.forEach((product) => { allProductsSanitized.forEach((product) => {
const key = `${product.cover.id}-${product.pattern.id}`; // Standalone products (e.g. Journaling Booklet) have no pattern — never grouped.
const key = product.pattern?.id ? `${product.cover?.id}-${product.pattern.id}` : `standalone-${product.id}`;
if (!productGroups.has(key) || calculateTotalProductPrice(product) < calculateTotalProductPrice(productGroups.get(key))) { if (!productGroups.has(key) || calculateTotalProductPrice(product) < calculateTotalProductPrice(productGroups.get(key))) {
productGroups.set(key, product); productGroups.set(key, product);
} }
+3 -4
View File
@@ -3,7 +3,7 @@
*/ */
import { factories } from "@strapi/strapi"; import { factories } from "@strapi/strapi";
import { Product } from "../../../../types"; import { calculateTotalProductPrice } from "../../../checkout/pricing";
export default factories.createCoreService("api::product.product", ({ strapi }) => ({ export default factories.createCoreService("api::product.product", ({ strapi }) => ({
find: async (params: Record<string, any>) => { find: async (params: Record<string, any>) => {
@@ -110,9 +110,8 @@ export default factories.createCoreService("api::product.product", ({ strapi })
} }
})); }));
export const calculateTotalProductPrice = (product: Partial<Product>): number => { // Implemented in src/checkout/pricing.ts; re-exported so existing importers keep working.
return (product?.cover?.price ?? 0) + (product?.pages?.price ?? 0) + (product?.ruling?.price ?? 0); export { calculateTotalProductPrice };
};
export interface ProductParams { export interface ProductParams {
populate?: { populate?: {
+155
View File
@@ -0,0 +1,155 @@
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
@@ -0,0 +1,146 @@
// 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
@@ -0,0 +1,27 @@
/** 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
@@ -0,0 +1,96 @@
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;
}
}
};
};
+244
View File
@@ -0,0 +1,244 @@
import type { CustomerStructuredAddress } from "./customer-update";
import { vatIncludedDecimal } from "./vat";
// The PayPal order that PayPalApi.createSessionOrThrow creates, built without the PayPal SDK so it can be unit tested.
// String literals stand in for the SDK enums, with the values of @paypal/paypal-server-sdk 0.6.1.
//
// The request sends no items. With items, PayPal requires item_total to be the sum of unit_amount × quantity and tax_total the
// sum of tax × quantity, and amounts rounded per unit miss the net and the VAT of the subtotal, which the invoice states:
// 3 × 12.50 gives 3 × 10.50 = 31.50 net and 3 × 2.00 = 6.00 tax, the subtotal of 37.50 gives 31.51 net and 5.99 VAT.
export type PayPalMoney = {
currencyCode: string;
value: string;
};
export type PayPalShipping = {
name: { fullName: string };
address: { addressLine1: string; addressLine2?: string; postalCode: string; adminArea2: string; countryCode: string };
};
export type PayPalBreakdown = {
itemTotal: PayPalMoney;
taxTotal: PayPalMoney;
/** Undefined for an order without a delivery method. */
shipping: PayPalMoney | undefined;
/** The surcharge of the payment method, only when it is above 0. */
handling?: PayPalMoney;
};
export type PayPalOrderRequest = {
/** CheckoutPaymentIntent.Capture */
intent: "CAPTURE";
purchaseUnits: {
referenceId: string;
amount: PayPalMoney & { breakdown: PayPalBreakdown };
shipping: PayPalShipping;
customId: string;
invoiceId: string;
}[];
applicationContext: {
returnUrl: string;
cancelUrl: string;
/** OrderApplicationContextShippingPreference.SetProvidedAddress */
shippingPreference: "SET_PROVIDED_ADDRESS";
};
};
/**
* The fields of the order the request is built from, as the checkout reads the order (strapi.db.query findOne with
* orderDefaultParams). Each of them may be null. Prices are decimals, which Strapi returns as numbers.
*/
export type PayPalOrderSource = {
uuid: string;
invoiceNumber?: string | null;
subtotal?: number | string | null;
total?: number | string | null;
VAT?: number | string | null;
delivery?: { price?: number | string | null } | null;
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. */
export class CheckoutNotReadyError extends Error {
constructor(message: string) {
super(message);
this.name = "CheckoutNotReadyError";
}
}
/** A cart line whose product was deleted. */
export class ProductUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "ProductUnavailableError";
}
}
export const CHECKOUT_NOT_READY = "Order is not ready for checkout";
export const PRODUCT_UNAVAILABLE = "A product in the cart is no longer available";
export const PAYPAL_ORDER_FAILED = "Could not create the PayPal order";
export type CheckoutFailure = { status: 400; message: string; reason: string } | { status: 409 | 502; message: string };
/**
* The checkout's answer to an error of PayPalApi.createSessionOrThrow: 400 with the reason for an order that is not ready, 409 for a
* product that is no longer available. Any other error is PayPal's or the network's: 502.
*/
export const checkoutFailure = (error: unknown): CheckoutFailure => {
if (error instanceof CheckoutNotReadyError) return { status: 400, message: CHECKOUT_NOT_READY, reason: error.message };
if (error instanceof ProductUnavailableError) return { status: 409, message: PRODUCT_UNAVAILABLE };
return { status: 502, message: PAYPAL_ORDER_FAILED };
};
// Money is computed in whole cents.
/** A decimal amount of euros as whole cents, or null for anything that is not a finite number. */
const toCents = (amount: unknown): number | null => {
const value = typeof amount === "string" && amount.trim() !== "" ? Number(amount) : amount;
return typeof value === "number" && Number.isFinite(value) ? Math.round(value * 100) : null;
};
/** Whole, non-negative cents as PayPal money: 1504 is "15.04". */
const money = (cents: number): PayPalMoney => ({
currencyCode: "EUR",
value: `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`
});
// vatIncludedDecimal as a whole percentage (119), so that the net of the subtotal is a quotient of whole numbers.
const VAT_INCLUDED_PERCENT = Math.round(vatIncludedDecimal * 100);
/**
* The breakdown of the order's total: the net of its subtotal, its VAT, 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 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 => {
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",
purchaseUnits: [
{
referenceId: order.uuid,
amount: { ...money(totalCents), breakdown: breakdownFor(order, totalCents) },
shipping: shippingFor(order),
customId: order.uuid,
invoiceId: order.invoiceNumber
}
],
applicationContext: {
returnUrl: returnUrl,
cancelUrl: returnUrl,
shippingPreference: "SET_PROVIDED_ADDRESS"
}
};
};
+104
View File
@@ -0,0 +1,104 @@
// 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 };
};
+5
View File
@@ -0,0 +1,5 @@
import type { Product } from "../../types";
export const calculateTotalProductPrice = (product: Partial<Product>): number => {
return (product?.cover?.price ?? 0) + (product?.pages?.price ?? 0) + (product?.ruling?.price ?? 0);
};
+4
View File
@@ -0,0 +1,4 @@
// TODO: Should be retrieved from DepotApi
export const vatIncludedDecimal = 1.19;
// TODO: Should be retrieved from DepotApi
export const vatDecimal = 0.19;
@@ -2,8 +2,8 @@
"openapi": "3.0.1", "openapi": "3.0.1",
"info": { "info": {
"version": "1.0.0", "version": "1.0.0",
"title": "MUELLERPTINTS. Paperwork", "title": "Paperwork API",
"description": "API Documentation for MUELLERPRINTS. Paperwork", "description": "Paperwork API",
"termsOfService": false, "termsOfService": false,
"contact": false, "contact": false,
"license": false "license": false
+30
View File
@@ -0,0 +1,30 @@
/** 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
};
};
+82
View File
@@ -0,0 +1,82 @@
import { describeError } from "./error";
/** What winston hands a printf template (logform's TransformableInfo); Strapi's default format has added `timestamp`. */
export type LogInfo = {
level: string;
message: unknown;
[key: string | symbol]: unknown;
};
export type LogFormatOptions = {
/** Writes the stack of an error value too. config/logger.ts sets it at the debug level, the local one. */
stack?: boolean;
};
// 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;
return `${asText(timestamp)}Z ${asText(level)}: ${asText(message)} ${restString}`;
};
+60 -129
View File
@@ -1,149 +1,80 @@
import { import { Client, OrdersController } from "@paypal/paypal-server-sdk";
Client, import type { Environment, OrderRequest } from "@paypal/paypal-server-sdk";
Environment, import { paypalClientId, paypalClientSecret, paypalEnvironment } from "../../config/constants";
LogLevel, import { createPayPalClient, paypalClientConfiguration } from "../checkout/paypal-client";
OrdersController, import type { PayPalClient } from "../checkout/paypal-client";
CheckoutPaymentIntent, import { buildPayPalOrderRequest } from "../checkout/paypal-order";
OrderRequest, import type { PayPalOrderSource } from "../checkout/paypal-order";
OrderApplicationContextShippingPreference, import { describeError } from "../logging/error";
Item
} from "@paypal/paypal-server-sdk";
import { paypalClientId, paypalClientSecret, paypalEnvironment, vatIncludedDecimal, vatDecimalExcluded } from "../../config/constants";
import { Order } from "../../types";
import { calculateTotalProductPrice } from "../api/product/services/product";
class PayPalApi { class PayPalApi {
private readonly client: Client; // 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 ordersController: OrdersController; private readonly orders: PayPalClient<OrdersController>;
constructor(clientId: string, clientSecret: string, environment: Environment) { constructor(clientId: string, clientSecret: string, environment: Environment) {
this.client = new Client({ this.orders = createPayPalClient({
clientCredentialsAuthCredentials: { configuration: paypalClientConfiguration({ clientId, clientSecret, environment }),
oAuthClientId: clientId, createController: (configuration) => new OrdersController(new Client(configuration)),
oAuthClientSecret: clientSecret onReplace: (error) =>
}, 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) { /**
if (!order.total) throw new Error("Cart is empty or has no total"); * Creates the PayPal order for the order and returns PayPal's id for it, which the capture accepts, and its links. Throws
* CheckoutNotReadyError or ProductUnavailableError before PayPal is asked, for an order PayPal cannot take.
const items = order.cart.map((item) => { */
const totalProductPrice = calculateTotalProductPrice(item.product); async createSessionOrThrow(returnUrl: string, order: PayPalOrderSource & { id?: number | string }): Promise<{ id: string; links: unknown }> {
const amount = totalProductPrice / vatIncludedDecimal; // Outside the try block: an order that is not ready is the customer's to fix, not a PayPal failure.
const tax = totalProductPrice - amount; const request = buildPayPalOrderRequest(returnUrl, order) as OrderRequest;
return {
name: item.product.name,
unitAmount: {
currencyCode: "EUR",
value: amount.toFixed(2)
},
tax: {
currencyCode: "EUR",
value: tax.toFixed(2)
},
quantity: item.count.toString()
} as Item;
});
try { try {
const collect = { const { body } = await this.orders.call((orders) => orders.ordersCreate({ body: request, prefer: "return=minimal" }));
body: { const session = JSON.parse(body as string);
intent: CheckoutPaymentIntent.Capture, if (typeof session?.id !== "string" || session.id === "") throw new Error("PayPal answered without an order id");
purchaseUnits: [
{
// items,
referenceId: order.uuid,
amount: {
currencyCode: "EUR",
value: order.total.toFixed(2),
breakdown: {
itemTotal: {
currencyCode: "EUR",
value: (Math.round((order.subtotal / vatIncludedDecimal) * 100) / 100).toFixed(2)
},
taxTotal: {
currencyCode: "EUR",
value: order.VAT.toFixed(2)
},
shipping: order.delivery
? {
currencyCode: "EUR",
value: order.delivery.price.toFixed(2)
}
: undefined
}
},
shipping: {
name: {
fullName: order.deliveryAddress.split("\n")[0]
},
address: {
addressLine1: order.deliveryAddress.split("\n")[1],
postalCode: order.deliveryAddress.split("\n")[2].slice(0, 5),
adminArea2: order.deliveryAddress.split("\n")[2].slice(6),
countryCode: "DE"
}
},
customId: order.uuid,
invoiceId: order.invoiceNumber
}
],
applicationContext: {
returnUrl: returnUrl,
cancelUrl: returnUrl,
shippingPreference: OrderApplicationContextShippingPreference.SetProvidedAddress
}
} as OrderRequest,
prefer: "return=minimal"
};
strapi.log.silly("app:d:paypal-api: Creating PayPal order " + JSON.stringify({ collect })); // The PayPal order's id and status only: the request and PayPal's order hold the buyer's name and address.
strapi.log.info("app:i:paypal-api: Created the PayPal order", {
const { body, ...httpResponse } = await this.ordersController.ordersCreate(collect); order: order.id,
const sessionData = JSON.parse(body as string); paypalOrderId: session.id,
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) {
strapi.log.error("app:e:paypal-api: " + JSON.stringify({ error })); // Never the error object: an SDK or axios error carries the request with its Authorization header.
strapi.log.error("app:e:paypal-api: Creating the PayPal order failed", { order: order.id, error: describeError(error) });
throw error; throw error;
} }
} }
async checkPaymentStatus(redirect: { orderID: string }) { /** PayPal's order as parsed JSON in PayPal's snake_case (GET /v2/checkout/orders/:id). Throws PayPal's and network errors. */
try { async getOrder(id: string): Promise<unknown> {
const collect = { const { body } = await this.orders.call((orders) => orders.ordersGet({ id }));
id: redirect.orderID, return JSON.parse(body as string);
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) { /**
// Implement PayPal webhook validation and processing * Captures the PayPal order and returns the whole order after the capture as parsed JSON in PayPal's snake_case
strapi.log.verbose("app:i:paypal-api: " + JSON.stringify({ req })); * (POST /v2/checkout/orders/:id/capture, Prefer: return=representation). PayPal answers a repeated requestId with the
* 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
+31 -31
View File
@@ -1,5 +1,5 @@
export const invoiceEmailTemplate = { export const invoiceEmailTemplate = {
subject: "MUELLERPRINTS Rechnungsnummer <%= invoiceNumber %>", subject: "<%= shopName %> Rechnungsnummer <%= invoiceNumber %>",
text: `Rechnungsnummer: <%= invoiceNumber %> text: `Rechnungsnummer: <%= invoiceNumber %>
Ausstellungsdatum: <%= new Date(acceptedTermsAndConditionsAt).toLocaleDateString("de-DE") %> Ausstellungsdatum: <%= new Date(acceptedTermsAndConditionsAt).toLocaleDateString("de-DE") %>
@@ -7,7 +7,7 @@ Ausstellungsdatum: <%= new Date(acceptedTermsAndConditionsAt).toLocaleDateString
Hallo <%= invoiceAddressStructured.givenName %>, Hallo <%= invoiceAddressStructured.givenName %>,
vielen Dank für deinen Einkauf bei MUELLERPRINTS. vielen Dank für deinen Einkauf bei <%= shopName %>.
Deine Bestellnummer: <%= id %> Deine Bestellnummer: <%= id %>
Zur Bestellübersicht: <%= baseUrl %>/checkout/result/<%= uuid %> Zur Bestellübersicht: <%= baseUrl %>/checkout/result/<%= uuid %>
@@ -49,14 +49,14 @@ inkl. MwSt. (19%): <%= VAT.toFixed(2) %> EUR
Deine Rechnung kannst du jederzeit über diesen Link herunterladen: <%= baseUrl %><%= invoice.url %> Deine Rechnung kannst du jederzeit über diesen Link herunterladen: <%= baseUrl %><%= invoice.url %>
<% } %> <% } %>
Vielen Dank, dass du dich für MUELLERPRINTS entschieden hast. Vielen Dank, dass du dich für <%= shopName %> entschieden hast.
------------------ ------------------
MUELLERPRINTS <%= shopName %>
Max Müller <%= shopContactName %>
Rotenbergstraße 39, 70190 Stuttgart <%= shopAddress %>
T +49 (0)711/262 49 64 <%= shopPhone ? "T " + shopPhone : "" %>
paperwork@muellerprints.de`, <%= shopEmail %>`,
html: `<!DOCTYPE html> html: `<!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
@@ -74,7 +74,7 @@ paperwork@muellerprints.de`,
<!-- Header --> <!-- Header -->
<tr> <tr>
<td style="background-color: #E31E26; padding: 30px 40px;"> <td style="background-color: #E31E26; padding: 30px 40px;">
<img src="https://muellerprints-paperwork.com/paperwork-logo.png" alt="MUELLERPRINTS PAPERWORK" style="max-width: 200px; height: auto;"> <% if (shopLogoUrl) { %><img src="<%= shopLogoUrl %>" alt="<%= shopName %>" style="max-width: 200px; height: auto;"><% } %>
</td> </td>
</tr> </tr>
@@ -83,7 +83,7 @@ paperwork@muellerprints.de`,
<td style="padding: 40px;"> <td style="padding: 40px;">
<!-- Greeting --> <!-- Greeting -->
<h1 style="margin: 0 0 20px; color: #333; font-size: 24px; font-weight: normal;"> <h1 style="margin: 0 0 20px; color: #333; font-size: 24px; font-weight: normal;">
Vielen Dank für deinen Einkauf bei MUELLERPRINTS. Vielen Dank für deinen Einkauf bei <%= shopName %>.
</h1> </h1>
<!-- Order Info --> <!-- Order Info -->
@@ -197,7 +197,7 @@ paperwork@muellerprints.de`,
<% } %> <% } %>
<p style="color: #666; font-size: 14px; margin: 20px 0;"> <p style="color: #666; font-size: 14px; margin: 20px 0;">
Vielen Dank, dass du dich für MUELLERPRINTS entschieden hast. Vielen Dank, dass du dich für <%= shopName %> entschieden hast.
</p> </p>
</td> </td>
</tr> </tr>
@@ -209,15 +209,15 @@ paperwork@muellerprints.de`,
<tr> <tr>
<td style="color: #666; font-size: 12px; line-height: 1.5;"> <td style="color: #666; font-size: 12px; line-height: 1.5;">
<p style="margin: 0 0 10px;"> <p style="margin: 0 0 10px;">
MUELLERPRINTS<br> <%= shopName %><br>
Max Müller<br> <% if (shopContactName) { %><%= shopContactName %><br><% } %>
Rotenbergstraße 39, 70190 Stuttgart<br> <% if (shopAddress) { %><%= shopAddress %><br><% } %>
T +49 (0)711/262 49 64<br> <% if (shopPhone) { %>T <%= shopPhone %><br><% } %>
paperwork@muellerprints.de <% if (shopEmail) { %><%= shopEmail %><% } %>
</p> </p>
</td> </td>
<td style="text-align: right; vertical-align: bottom;"> <td style="text-align: right; vertical-align: bottom;">
<img src="https://www.muellerprints.de/images/digi_logo.png" alt="MUELLERPRINTS" style="max-width: 100px; height: auto;"> <% if (shopSecondaryLogoUrl) { %><img src="<%= shopSecondaryLogoUrl %>" alt="<%= shopName %>" style="max-width: 100px; height: auto;"><% } %>
</td> </td>
</tr> </tr>
</table> </table>
@@ -243,7 +243,7 @@ paperwork@muellerprints.de`,
}; };
export const deliveryNoteEmailTemplate = { export const deliveryNoteEmailTemplate = {
subject: "Your Delivery Note <%= deliveryNoteNumber %> from MUELLERPRINTS", subject: "Your Delivery Note <%= deliveryNoteNumber %> from <%= shopName %>",
text: `Dear Customer, text: `Dear Customer,
Thank you for your Your delivery note <%= deliveryNoteNumber %> is attached to this email. Thank you for your Your delivery note <%= deliveryNoteNumber %> is attached to this email.
@@ -272,14 +272,14 @@ Delivery Address:
If you have any questions about your delivery, please don't hesitate to contact us. If you have any questions about your delivery, please don't hesitate to contact us.
Best regards, Best regards,
MUELLERPRINTS PAPERWORK <%= shopName %>
------------------ ------------------
MUELLERPRINTS PAPERWORK <%= shopName %>
Max Müller <%= shopContactName %>
Rotenbergstraße 39, 70190 Stuttgart <%= shopAddress %>
T +49 (0)711/262 49 64 <%= shopPhone ? "T " + shopPhone : "" %>
paperwork@muellerprints.de`, <%= shopEmail %>`,
html: `<!DOCTYPE html> html: `<!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
@@ -295,7 +295,7 @@ paperwork@muellerprints.de`,
<!-- Header --> <!-- Header -->
<tr> <tr>
<td style="background-color: #E31E26; padding: 30px 40px;"> <td style="background-color: #E31E26; padding: 30px 40px;">
<img src="https://muellerprints-paperwork.com/paperwork-logo.png" alt="MUELLERPRINTS PAPERWORK" style="max-width: 200px; height: auto;"> <% if (shopLogoUrl) { %><img src="<%= shopLogoUrl %>" alt="<%= shopName %>" style="max-width: 200px; height: auto;"><% } %>
</td> </td>
</tr> </tr>
@@ -407,15 +407,15 @@ paperwork@muellerprints.de`,
<tr> <tr>
<td style="color: #666; font-size: 12px; line-height: 1.5;"> <td style="color: #666; font-size: 12px; line-height: 1.5;">
<p style="margin: 0 0 10px;"> <p style="margin: 0 0 10px;">
MUELLERPRINTS PAPERWORK<br> <%= shopName %><br>
Max Müller<br> <% if (shopContactName) { %><%= shopContactName %><br><% } %>
Rotenbergstraße 39, 70190 Stuttgart<br> <% if (shopAddress) { %><%= shopAddress %><br><% } %>
T +49 (0)711/262 49 64<br> <% if (shopPhone) { %>T <%= shopPhone %><br><% } %>
paperwork@muellerprints.de <% if (shopEmail) { %><%= shopEmail %><% } %>
</p> </p>
</td> </td>
<td style="text-align: right; vertical-align: bottom;"> <td style="text-align: right; vertical-align: bottom;">
<img src="https://www.muellerprints.de/images/digi_logo.png" alt="MUELLERPRINTS" style="max-width: 100px; height: auto;"> <% if (shopSecondaryLogoUrl) { %><img src="<%= shopSecondaryLogoUrl %>" alt="<%= shopName %>" style="max-width: 100px; height: auto;"><% } %>
</td> </td>
</tr> </tr>
</table> </table>
+11 -1
View File
@@ -110,8 +110,12 @@ async function generateNewProducts(strapi: Strapi, componentType: string, compon
strapi.log.debug(`app:d:lifecycle-factory ${JSON.stringify({ covers, patterns, pages, rulings })}`); strapi.log.debug(`app:d:lifecycle-factory ${JSON.stringify({ covers, patterns, pages, rulings })}`);
// Standalone covers (e.g. the Journaling Booklet) are not combinatorial —
// their product is created manually and must never be fanned out.
const sellableCovers = covers.filter((cover) => cover && !(cover as ProductCover).standalone);
// Generate new combinations // Generate new combinations
for (const cover of covers) { for (const cover of sellableCovers) {
for (const pattern of patterns) { for (const pattern of patterns) {
for (const page of pages) { for (const page of pages) {
for (const ruling of rulings) { for (const ruling of rulings) {
@@ -181,6 +185,12 @@ async function updateRelatedProducts(strapi: Strapi, componentType: string, comp
for (const product of products) { for (const product of products) {
try { try {
// Standalone products (cover-only, e.g. Journaling Booklet) are not
// factory-managed — never rename/reslug/delete them.
if (!product.pattern?.id) {
continue;
}
// Get the current components for this product // Get the current components for this product
const cover = product.cover?.id; const cover = product.cover?.id;
const pattern = product.pattern?.id; const pattern = product.pattern?.id;
+375
View File
@@ -0,0 +1,375 @@
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
@@ -0,0 +1,351 @@
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: {} });
});
@@ -0,0 +1,88 @@
{
"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"
}
]
}
@@ -0,0 +1,131 @@
{
"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
@@ -0,0 +1,67 @@
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));
});
+173
View File
@@ -0,0 +1,173 @@
import { test } from "node:test";
import assert from "node:assert/strict";
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"
// Characterization: what the console line looks like today.
test("renders timestamp, level and message, with a trailing space when there are no other fields", () => {
assert.equal(
formatLogLine({ timestamp: TIMESTAMP, level: "info", message: "app:i:paypal-api: hello" }),
"2026-10-08 21:29:40.426Z info: app:i:paypal-api: hello "
);
});
test("appends the other fields as JSON indented by two spaces", () => {
assert.equal(
formatLogLine({
timestamp: TIMESTAMP,
level: "debug",
message: "app:d:order-service: Calculating totals",
productsTotal: 17.9,
deliveryPrice: 4.95
}),
'2026-10-08 21:29:40.426Z debug: app:d:order-service: Calculating totals {\n "productsTotal": 17.9,\n "deliveryPrice": 4.95\n}'
);
});
test("ignores the symbol keys winston adds", () => {
const info = { timestamp: TIMESTAMP, level: "info", message: "hello", [Symbol.for("level")]: "info", [Symbol.for("splat")]: [] };
assert.equal(formatLogLine(info), "2026-10-08 21:29:40.426Z info: hello ");
});
// Fixed defects of https://git.librete.ch/libretech/mp/issues/67.
test("does not throw on a circular reference", () => {
const request: Record<string, unknown> = { method: "POST", path: "/v1/oauth2/token" };
request.self = 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", () => {
const error = 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}` } }
});
// What strapi.log.error(error) hands this formatter: Strapi's logErrors format spreads the error and appends its stack.
const line = formatLogLine({ ...error, message: `${error.message}\n${error.stack}`, level: "error", timestamp: TIMESTAMP });
assert.ok(!line.includes(CREDENTIALS), line);
});
test("renders an Error with its message instead of {}", () => {
const error = new Error("getaddrinfo EAI_AGAIN api-m.paypal.com");
// strapi.log.error("…", { error }), as in the order controller's capturePayment
const line = formatLogLine({
timestamp: TIMESTAMP,
level: "error",
message: "app:e:order-controller: Error capturing payment for order x",
error
});
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
@@ -0,0 +1,56 @@
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
@@ -0,0 +1,171 @@
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);
});
+407
View File
@@ -0,0 +1,407 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
CheckoutNotReadyError,
ProductUnavailableError,
PRODUCT_UNAVAILABLE,
buildPayPalOrderRequest,
checkoutFailure
} from "../../src/checkout/paypal-order.ts";
import { calculateTotalProductPrice } from "../../src/checkout/pricing.ts";
import { vatDecimal, vatIncludedDecimal } from "../../src/checkout/vat.ts";
const RETURN_URL = "https://shop.example/checkout/3";
const UUID = "11111111-2222-4333-8444-555555555555";
const notebook = { id: 101, name: "Notizbuch A5 Punktraster", cover: { price: 14.9 }, pages: { price: 3 }, ruling: { price: 0 } };
// Shaped like the order the checkout controller passes to createSessionOrThrow (strapi.db.query findOne with orderDefaultParams),
// with the totals the order service computes: subtotal 17.90, total 17.90 + 4.95 = 22.85, VAT round(17.90 / 1.19 * 0.19) = 2.86.
const validOrder = (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 },
payment: null,
invoiceNumber: "R-12-4711",
cart: [{ id: 1, count: 1, product: notebook }],
deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
...overrides
});
// 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 cart = lines.map(([price, count], index) => ({
id: index + 1,
count,
product: { id: 201 + index, name: `Produkt ${index + 1}`, cover: { price } }
}));
const subtotal = cart.reduce((v, p) => v + (calculateTotalProductPrice(p.product) ?? 0) * p.count, 0);
return validOrder({
cart,
subtotal,
total: Math.round((subtotal + (deliveryPrice ?? 0) + (paymentPrice ?? 0)) * 100) / 100,
VAT: Math.round((subtotal / vatIncludedDecimal) * vatDecimal * 100) / 100,
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.
const buildSession = (order) => buildPayPalOrderRequest(RETURN_URL, order);
const cents = (money) => (money ? Math.round(Number(money.value) * 100) : 0);
// PayPal rejects an order unless amount = item_total + tax_total + shipping + handling + insurance - shipping_discount - discount.
const breakdownCents = (breakdown) =>
cents(breakdown.itemTotal) +
cents(breakdown.taxTotal) +
cents(breakdown.shipping) +
cents(breakdown.handling) +
cents(breakdown.insurance) -
cents(breakdown.shippingDiscount) -
cents(breakdown.discount);
const isClearError = (pattern: RegExp) => (error: unknown) => error instanceof Error && !(error instanceof TypeError) && pattern.test(error.message);
const assertNoTypeError = (build: () => unknown) => {
try {
build();
} catch (error) {
assert.ok(!(error instanceof TypeError), `threw ${error}`);
}
};
const thrown = (build: () => unknown): unknown => {
try {
build();
} catch (error) {
return error;
}
assert.fail("did not throw");
};
// Characterization: what the builders do today.
test("builds the PayPal order request for a valid order", () => {
const expected = {
intent: "CAPTURE",
purchaseUnits: [
{
referenceId: UUID,
amount: {
currencyCode: "EUR",
value: "22.85",
breakdown: {
itemTotal: { currencyCode: "EUR", value: "15.04" },
taxTotal: { currencyCode: "EUR", value: "2.86" },
shipping: { currencyCode: "EUR", value: "4.95" }
}
},
shipping: {
name: { fullName: "Erika Mustermann" },
address: { addressLine1: "Musterstraße 1", postalCode: "70190", adminArea2: "Stuttgart", countryCode: "DE" }
},
customId: UUID,
invoiceId: "R-12-4711"
}
],
applicationContext: { returnUrl: RETURN_URL, cancelUrl: RETURN_URL, shippingPreference: "SET_PROVIDED_ADDRESS" }
};
const request = buildPayPalOrderRequest(RETURN_URL, validOrder());
assert.deepEqual(request, expected);
// The key order is visible in the "Creating PayPal order" log line.
assert.equal(JSON.stringify(request), JSON.stringify(expected));
});
test("sends no shipping amount when the order has no delivery method", () => {
assert.deepEqual(buildPayPalOrderRequest(RETURN_URL, validOrder({ delivery: null, total: 17.9 })).purchaseUnits[0].amount, {
currencyCode: "EUR",
value: "17.90",
breakdown: {
itemTotal: { currencyCode: "EUR", value: "15.04" },
taxTotal: { currencyCode: "EUR", value: "2.86" },
shipping: undefined
}
});
});
// The request used to be built after a list of items that was never sent; computing it threw "Cart is empty or has no total".
// That check now throws CheckoutNotReadyError, which the checkout answers 400 instead of 502.
test("rejects an empty cart or an order without a total as not ready for checkout", () => {
for (const overrides of [{ total: 0 }, { total: null }, { total: undefined }, { cart: [] }, { cart: null }]) {
assert.throws(() => buildSession(validOrder(overrides)), { name: "CheckoutNotReadyError", message: "Cart is empty or has no total" });
}
});
test("sends no items: amounts rounded per unit would miss the net and the VAT of the subtotal", () => {
// 3 × 12.50: per unit 10.50 net and 2.00 tax, 31.50 and 6.00 for the three; the subtotal of 37.50 has 31.51 net and 5.99 VAT.
const unitNetCents = Math.round((1250 * 100) / 119);
const unit = buildSession(orderFor([[12.5, 3]], 4.95)).purchaseUnits[0];
assert.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" }
}
});
});
// Fixed defects of https://git.librete.ch/libretech/mp/issues/67.
test("D1: a missing delivery address is rejected with a clear validation error", () => {
assert.throws(() => buildSession(validOrder({ deliveryAddress: null })), isClearError(/address/i));
});
test("D2: a two-line delivery address does not throw a TypeError", () => {
assertNoTypeError(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })));
});
test("D3: a pickup whose delivery price is null is sent with shipping 0.00", () => {
const order = validOrder({ delivery: { id: 2, name: "Abholung", price: null }, total: 17.9 });
assert.deepEqual(buildSession(order).purchaseUnits[0].amount.breakdown.shipping, { currencyCode: "EUR", value: "0.00" });
});
test("D4: a cart line whose product was deleted is rejected with a clear error", () => {
assert.throws(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })), isClearError(/product/i));
});
test("D5: with a payment surcharge the breakdown still adds up to the amount", () => {
// 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];
assert.equal(amount.value, "24.35");
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.
test("D6: the breakdown adds up to the amount to the cent, also for the subtotals whose net is closest to half a cent", () => {
const carts: { lines: [number, number][]; delivery: number | null }[] = [
{ lines: [[17.9, 1]], delivery: 4.95 },
// subtotal 36.10000000000001 in floating point
{
lines: [
[4.15, 7],
[2.35, 3]
],
delivery: 4.95
},
// 29.03: net 24.394958, as close to a half cent as a whole-cent subtotal gets
{
lines: [
[9.99, 2],
[9.05, 1]
],
delivery: 4.95
},
// 41.18: net 34.605042, the same from above; no delivery method
{ lines: [[20.59, 2]], delivery: null },
{ lines: [[5.23, 1]], delivery: 4.95 },
{ lines: [[5.48, 1]], delivery: 4.95 }
];
for (let cent = 1; cent <= 30000; cent++) {
carts.push({ lines: [[cent / 100, 1]], delivery: 4.95 }, { lines: [[cent / 100, 1]], delivery: null });
}
const mismatches = carts.filter(({ lines, delivery }) => {
const { amount } = buildSession(orderFor(lines, delivery)).purchaseUnits[0];
return breakdownCents(amount.breakdown) !== cents(amount);
});
// Compare the count, not the array: diffing tens of thousands of entries against [] takes many GB of memory.
assert.equal(mismatches.length, 0, `${mismatches.length} carts do not add up, e.g. ${JSON.stringify(mismatches.slice(0, 3))}`);
});
// Shipping address: the structured delivery address, else the structured invoice address, else the text delivery address.
const structured = (overrides = {}) => ({
givenName: "Erika",
familyName: "Mustermann",
streetAddress: "Musterstraße 1",
postalCode: "70190",
addressLevel2: "Stuttgart",
country: "DE",
...overrides
});
const berlin = structured({ givenName: "Max", streetAddress: "Hauptstraße 5", postalCode: "10115", addressLevel2: "Berlin" });
const berlinShipping = {
name: { fullName: "Max Mustermann" },
address: { addressLine1: "Hauptstraße 5", postalCode: "10115", adminArea2: "Berlin", countryCode: "DE" }
};
// An address.structured-address component that was saved without components: Strapi fills in the default country.
const emptyStructured = { givenName: null, familyName: null, streetAddress: null, postalCode: null, addressLevel2: null, country: "DE" };
const shippingOf = (order) => buildSession(order).purchaseUnits[0].shipping;
test("takes the shipping address from the structured delivery address before the invoice and the text address", () => {
assert.deepEqual(shippingOf(validOrder({ deliveryAddressStructured: berlin, invoiceAddressStructured: structured() })), berlinShipping);
});
test("falls back to the structured invoice address when no structured delivery address is entered", () => {
for (const deliveryAddressStructured of [null, undefined, emptyStructured, { givenName: " ", country: "DE" }]) {
assert.deepEqual(
shippingOf(validOrder({ deliveryAddressStructured, invoiceAddressStructured: berlin, deliveryAddress: null })),
berlinShipping
);
}
});
test("trims the components, names the buyer by the name parts there are and upper-cases the country, DE by default", () => {
const order = (address) => validOrder({ deliveryAddressStructured: { ...structured(), ...address } });
assert.deepEqual(shippingOf(order({ givenName: " Erika ", familyName: null, streetAddress: " Musterstraße 1 ", country: "at" })), {
name: { fullName: "Erika" },
address: { addressLine1: "Musterstraße 1", postalCode: "70190", adminArea2: "Stuttgart", countryCode: "AT" }
});
for (const country of [null, undefined, ""]) {
assert.equal(shippingOf(order({ country })).address.countryCode, "DE");
}
});
test("rejects a structured address that is entered but incomplete instead of shipping to another address", () => {
const order = validOrder({ deliveryAddressStructured: structured({ streetAddress: "", postalCode: null }), invoiceAddressStructured: berlin });
assert.throws(() => buildSession(order), { name: "CheckoutNotReadyError", message: "deliveryAddressStructured lacks streetAddress, postalCode" });
assert.throws(() => buildSession(validOrder({ invoiceAddressStructured: structured({ givenName: null, familyName: "" }) })), {
name: "CheckoutNotReadyError",
message: "invoiceAddressStructured lacks givenName and familyName"
});
assert.throws(() => buildSession(validOrder({ deliveryAddressStructured: structured({ country: "Deutschland" }) })), {
name: "CheckoutNotReadyError",
message: "deliveryAddressStructured has no two-letter country code"
});
});
test("parses the text delivery address when no structured address is entered", () => {
const shippingFor = (deliveryAddress) => shippingOf(validOrder({ deliveryAddress, deliveryAddressStructured: emptyStructured }));
assert.deepEqual(shippingFor(" Erika Mustermann\r\nMusterstraße 1\r\n\r\n70190 Stuttgart-Bad Cannstatt \n"), {
name: { fullName: "Erika Mustermann" },
address: { addressLine1: "Musterstraße 1", postalCode: "70190", adminArea2: "Stuttgart-Bad Cannstatt", countryCode: "DE" }
});
// The street is the line above the postal code and city, as in a German address; the lines after the name go into addressLine2.
assert.deepEqual(shippingFor("Erika Mustermann\nc/o Muster GmbH\nHinterhaus\nMusterstraße 1\n70190 Stuttgart"), {
name: { fullName: "Erika Mustermann" },
address: {
addressLine1: "Musterstraße 1",
addressLine2: "c/o Muster GmbH, Hinterhaus",
postalCode: "70190",
adminArea2: "Stuttgart",
countryCode: "DE"
}
});
});
test("rejects a text delivery address it cannot parse, and a missing one, with a validation error", () => {
const cases: [unknown, string][] = [
["Erika Mustermann\nMusterstraße 1", "deliveryAddress needs the name, the street, and the postal code and city on lines of their own"],
["Erika Mustermann\n\nMusterstraße 1\n", "deliveryAddress needs the name, the street, and the postal code and city on lines of their own"],
["Erika Mustermann\nMusterstraße 1\n70190", "The last line of deliveryAddress is not the postal code and the city"],
[" \n ", "The order has no delivery address"],
["", "The order has no delivery address"],
[null, "The order has no delivery address"],
[undefined, "The order has no delivery address"]
];
for (const [deliveryAddress, message] of cases) {
assert.throws(() => buildSession(validOrder({ deliveryAddress })), { name: "CheckoutNotReadyError", message });
}
});
// Money in whole cents, and the errors the checkout answers.
test("reads decimals that arrive as strings like numbers", () => {
const order = validOrder({ subtotal: "17.90", total: "22.85", VAT: "2.86", delivery: { id: 1, name: "DHL", price: "4.95" } });
assert.deepEqual(buildSession(order), buildSession(validOrder()));
});
test("rejects totals that do not add up, as after a price change, instead of sending PayPal a breakdown it rejects", () => {
// The delivery method costs 5.95 now; the order's total was computed when it cost 4.95.
assert.throws(() => buildSession(validOrder({ delivery: { id: 1, name: "DHL", price: 5.95 } })), {
name: "CheckoutNotReadyError",
message: "The order's totals do not add up: total 2285 cents, items 1504 + VAT 286 + shipping 595 + 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
@@ -0,0 +1,260 @@
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));
}
});
+26
View File
@@ -0,0 +1,26 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { calculateTotalProductPrice } from "../../src/checkout/pricing.ts";
// Characterization: what calculateTotalProductPrice returns today.
test("adds up the cover, pages and ruling prices", () => {
assert.equal(calculateTotalProductPrice({ cover: { price: 14.9 }, pages: { price: 3 }, ruling: { price: 0.5 } }), 18.4);
});
test("prices a standalone product from its cover alone", () => {
assert.equal(calculateTotalProductPrice({ cover: { price: 9.5 } }), 9.5);
});
test("counts a missing component or price as 0", () => {
assert.equal(calculateTotalProductPrice({ cover: null, pages: { price: null }, ruling: undefined }), 0);
});
test("returns 0 for a missing product instead of throwing", () => {
assert.equal(calculateTotalProductPrice(null), 0);
assert.equal(calculateTotalProductPrice(undefined), 0);
});
test("does not round the sum", () => {
assert.equal(calculateTotalProductPrice({ cover: { price: 0.1 }, pages: { price: 0.2 } }), 0.30000000000000004);
});
+6
View File
@@ -0,0 +1,6 @@
// Loaded by `npm test` through `node --import`. Node runs the .ts sources directly (type stripping), but unlike tsc it does not
// resolve extensionless relative imports such as `import { vatIncludedDecimal } from "./vat"` in src/checkout/paypal-order.ts.
// The hook below retries such an import from a .ts file with ".ts" appended. Test files import sources with explicit .ts paths.
import { register } from "node:module";
register("./resolve-ts.mjs", import.meta.url);
+14
View File
@@ -0,0 +1,14 @@
// Module resolve hook, registered by ./register.mjs: `./vat` imported from a .ts file resolves to `./vat.ts`.
export async function resolve(specifier, context, nextResolve) {
try {
return await nextResolve(specifier, context);
} catch (error) {
const relative = specifier.startsWith("./") || specifier.startsWith("../");
if (error?.code !== "ERR_MODULE_NOT_FOUND" || !relative || !context.parentURL?.endsWith(".ts")) throw error;
try {
return await nextResolve(`${specifier}.ts`, context);
} catch {
throw error;
}
}
}
+4
View File
@@ -389,7 +389,10 @@ 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;
@@ -453,6 +456,7 @@ export interface ApiProductCoverProductCover extends Schema.CollectionType {
number number
> & > &
Attribute.DefaultTo<0>; Attribute.DefaultTo<0>;
standalone: Attribute.Boolean & Attribute.DefaultTo<false>;
updatedAt: Attribute.DateTime; updatedAt: Attribute.DateTime;
updatedBy: Attribute.Relation<"api::product-cover.product-cover", "oneToOne", "admin::user"> & Attribute.Private; updatedBy: Attribute.Relation<"api::product-cover.product-cover", "oneToOne", "admin::user"> & Attribute.Private;
}; };