Compare commits
11
Commits
v0.1.1
..
707ca854fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
707ca854fb
|
||
|
|
cbcc54bd75
|
||
|
|
7fd4aeff17
|
||
|
|
9873c184d6
|
||
|
|
901a3b385f
|
||
|
|
7f7cacc448 | ||
|
|
fa2a81ecd1
|
||
|
|
8e0554572b
|
||
|
|
3213a5a400
|
||
|
|
5c36ce7dbb | ||
|
|
9600bc8fd3 |
+28
-38
@@ -1,4 +1,4 @@
|
|||||||
name: Build and publish
|
name: build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -7,49 +7,39 @@ 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 Node version of the image (Dockerfile FROM), so the tests run on the runtime's Node.
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22.14.0"
|
||||||
|
|
||||||
|
- run: npm test
|
||||||
|
env:
|
||||||
|
npm_config_update_notifier: "false"
|
||||||
|
|
||||||
|
# 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 }}
|
|
||||||
|
|||||||
+2
-4
@@ -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!;
|
||||||
@@ -18,10 +19,7 @@ export const shopEmail = process.env.SHOP_EMAIL ?? process.env.ADMIN_EMAIL_ADDRE
|
|||||||
export const shopLogoUrl = process.env.SHOP_LOGO_URL ?? "";
|
export const shopLogoUrl = process.env.SHOP_LOGO_URL ?? "";
|
||||||
export const shopSecondaryLogoUrl = process.env.SHOP_SECONDARY_LOGO_URL ?? "";
|
export const shopSecondaryLogoUrl = process.env.SHOP_SECONDARY_LOGO_URL ?? "";
|
||||||
|
|
||||||
// TODO: Should be retrieved from DepotApi
|
export { vatIncludedDecimal, vatDecimal } from "../src/checkout/vat";
|
||||||
export const vatIncludedDecimal = 1.19;
|
|
||||||
// TODO: Should be retrieved from DepotApi
|
|
||||||
export const vatDecimal = 0.19;
|
|
||||||
export const vatDecimalExcluded = 1 - vatDecimal;
|
export const vatDecimalExcluded = 1 - vatDecimal;
|
||||||
|
|
||||||
export const maxProductsSitemap = 500;
|
export const maxProductsSitemap = 500;
|
||||||
|
|||||||
+2
-8
@@ -1,16 +1,10 @@
|
|||||||
import { winston } from "@strapi/logger";
|
import { winston } from "@strapi/logger";
|
||||||
|
import { formatLogLine } from "../src/logging/format";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
transports: [
|
transports: [
|
||||||
new winston.transports.Console({
|
new winston.transports.Console({
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(winston.format.printf(formatLogLine))
|
||||||
winston.format.printf(({ timestamp, level, message, ...rest }) => {
|
|
||||||
let restString = JSON.stringify(rest, undefined, 2);
|
|
||||||
restString = restString === "{}" ? "" : restString;
|
|
||||||
|
|
||||||
return `${timestamp}Z ${level}: ${message} ${restString}`;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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')",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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?: {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { Order } from "../../types";
|
||||||
|
import { calculateTotalProductPrice } from "./pricing";
|
||||||
|
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.
|
||||||
|
|
||||||
|
export type PayPalMoney = {
|
||||||
|
currencyCode: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PayPalOrderItem = {
|
||||||
|
name: string;
|
||||||
|
unitAmount: PayPalMoney;
|
||||||
|
tax: PayPalMoney;
|
||||||
|
quantity: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PayPalOrderRequest = {
|
||||||
|
/** CheckoutPaymentIntent.Capture */
|
||||||
|
intent: "CAPTURE";
|
||||||
|
purchaseUnits: {
|
||||||
|
referenceId: string;
|
||||||
|
amount: PayPalMoney & {
|
||||||
|
breakdown: {
|
||||||
|
itemTotal: PayPalMoney;
|
||||||
|
taxTotal: PayPalMoney;
|
||||||
|
shipping: PayPalMoney | undefined;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
shipping: {
|
||||||
|
name: { fullName: string };
|
||||||
|
address: { addressLine1: string; postalCode: string; adminArea2: string; countryCode: string };
|
||||||
|
};
|
||||||
|
customId: string;
|
||||||
|
invoiceId: string;
|
||||||
|
}[];
|
||||||
|
applicationContext: {
|
||||||
|
returnUrl: string;
|
||||||
|
cancelUrl: string;
|
||||||
|
/** OrderApplicationContextShippingPreference.SetProvidedAddress */
|
||||||
|
shippingPreference: "SET_PROVIDED_ADDRESS";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The part of PayPalApi.createSessionOrThrow that runs before its try block, so nothing thrown here is logged there.
|
||||||
|
* Rejects an order without a total, then computes one item per cart line. The items are not sent to PayPal, but computing
|
||||||
|
* them throws a TypeError for a cart line without a product.
|
||||||
|
*/
|
||||||
|
export const buildPayPalOrderItems = (order: Order): PayPalOrderItem[] => {
|
||||||
|
if (!order.total) throw new Error("Cart is empty or has no total");
|
||||||
|
|
||||||
|
return order.cart.map((item) => {
|
||||||
|
const totalProductPrice = calculateTotalProductPrice(item.product);
|
||||||
|
const amount = totalProductPrice / vatIncludedDecimal;
|
||||||
|
const tax = totalProductPrice - amount;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.product.name,
|
||||||
|
unitAmount: {
|
||||||
|
currencyCode: "EUR",
|
||||||
|
value: amount.toFixed(2)
|
||||||
|
},
|
||||||
|
tax: {
|
||||||
|
currencyCode: "EUR",
|
||||||
|
value: tax.toFixed(2)
|
||||||
|
},
|
||||||
|
quantity: item.count.toString()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The order request body PayPalApi.createSessionOrThrow sends. It is built inside that try block, so whatever throws here is logged there.
|
||||||
|
*/
|
||||||
|
export const buildPayPalOrderRequest = (returnUrl: string, order: Order): PayPalOrderRequest => ({
|
||||||
|
intent: "CAPTURE",
|
||||||
|
purchaseUnits: [
|
||||||
|
{
|
||||||
|
// items: computed by buildPayPalOrderItems, but not sent
|
||||||
|
referenceId: order.uuid,
|
||||||
|
amount: {
|
||||||
|
currencyCode: "EUR",
|
||||||
|
value: order.total.toFixed(2),
|
||||||
|
breakdown: {
|
||||||
|
itemTotal: {
|
||||||
|
currencyCode: "EUR",
|
||||||
|
value: (Math.round((order.subtotal / vatIncludedDecimal) * 100) / 100).toFixed(2)
|
||||||
|
},
|
||||||
|
taxTotal: {
|
||||||
|
currencyCode: "EUR",
|
||||||
|
value: order.VAT.toFixed(2)
|
||||||
|
},
|
||||||
|
shipping: order.delivery
|
||||||
|
? {
|
||||||
|
currencyCode: "EUR",
|
||||||
|
value: order.delivery.price.toFixed(2)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shipping: {
|
||||||
|
name: {
|
||||||
|
fullName: order.deliveryAddress.split("\n")[0]
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
addressLine1: order.deliveryAddress.split("\n")[1],
|
||||||
|
postalCode: order.deliveryAddress.split("\n")[2].slice(0, 5),
|
||||||
|
adminArea2: order.deliveryAddress.split("\n")[2].slice(6),
|
||||||
|
countryCode: "DE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
customId: order.uuid,
|
||||||
|
invoiceId: order.invoiceNumber
|
||||||
|
}
|
||||||
|
],
|
||||||
|
applicationContext: {
|
||||||
|
returnUrl: returnUrl,
|
||||||
|
cancelUrl: returnUrl,
|
||||||
|
shippingPreference: "SET_PROVIDED_ADDRESS"
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One console line: `<timestamp>Z <level>: <message> <all other fields as indented JSON, if any>`. */
|
||||||
|
export const formatLogLine = ({ timestamp, level, message, ...rest }: LogInfo): string => {
|
||||||
|
let restString = JSON.stringify(rest, undefined, 2);
|
||||||
|
restString = restString === "{}" ? "" : restString;
|
||||||
|
|
||||||
|
return `${timestamp}Z ${level}: ${message} ${restString}`;
|
||||||
|
};
|
||||||
@@ -1,16 +1,7 @@
|
|||||||
import {
|
import { Client, Environment, LogLevel, OrdersController, OrderRequest } from "@paypal/paypal-server-sdk";
|
||||||
Client,
|
import { paypalClientId, paypalClientSecret, paypalEnvironment } from "../../config/constants";
|
||||||
Environment,
|
|
||||||
LogLevel,
|
|
||||||
OrdersController,
|
|
||||||
CheckoutPaymentIntent,
|
|
||||||
OrderRequest,
|
|
||||||
OrderApplicationContextShippingPreference,
|
|
||||||
Item
|
|
||||||
} from "@paypal/paypal-server-sdk";
|
|
||||||
import { paypalClientId, paypalClientSecret, paypalEnvironment, vatIncludedDecimal, vatDecimalExcluded } from "../../config/constants";
|
|
||||||
import { Order } from "../../types";
|
import { Order } from "../../types";
|
||||||
import { calculateTotalProductPrice } from "../api/product/services/product";
|
import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../checkout/paypal-order";
|
||||||
|
|
||||||
class PayPalApi {
|
class PayPalApi {
|
||||||
private readonly client: Client;
|
private readonly client: Client;
|
||||||
@@ -34,76 +25,12 @@ class PayPalApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createSessionOrThrow(returnUrl: string, order: Order) {
|
async createSessionOrThrow(returnUrl: string, order: Order) {
|
||||||
if (!order.total) throw new Error("Cart is empty or has no total");
|
// Outside the try block, so these throws are not logged here. The items are computed but not sent.
|
||||||
|
buildPayPalOrderItems(order);
|
||||||
const items = order.cart.map((item) => {
|
|
||||||
const totalProductPrice = calculateTotalProductPrice(item.product);
|
|
||||||
const amount = totalProductPrice / vatIncludedDecimal;
|
|
||||||
const tax = totalProductPrice - amount;
|
|
||||||
|
|
||||||
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 collect = {
|
||||||
body: {
|
body: buildPayPalOrderRequest(returnUrl, order) as OrderRequest,
|
||||||
intent: CheckoutPaymentIntent.Capture,
|
|
||||||
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"
|
prefer: "return=minimal"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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 ");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Known defects: these assert the correct behaviour and fail today, which the todo marks as expected.
|
||||||
|
|
||||||
|
test(
|
||||||
|
"does not throw on a circular reference",
|
||||||
|
{ todo: "JSON.stringify throws on a circular reference — https://git.librete.ch/libretech/mp/issues/67" },
|
||||||
|
() => {
|
||||||
|
const request: Record<string, unknown> = { method: "POST", path: "/v1/oauth2/token" };
|
||||||
|
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",
|
||||||
|
{ todo: "the PayPal client credentials are logged — https://git.librete.ch/libretech/mp/issues/67" },
|
||||||
|
() => {
|
||||||
|
const error = Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), {
|
||||||
|
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 {}",
|
||||||
|
{ todo: "an Error is rendered as {} — https://git.librete.ch/libretech/mp/issues/67" },
|
||||||
|
() => {
|
||||||
|
const error = new Error("getaddrinfo EAI_AGAIN api-m.paypal.com");
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { buildPayPalOrderItems, buildPayPalOrderRequest } 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) => {
|
||||||
|
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)) * 100) / 100,
|
||||||
|
VAT: Math.round((subtotal / vatIncludedDecimal) * vatDecimal * 100) / 100,
|
||||||
|
delivery: deliveryPrice === null ? null : { id: 1, name: "DHL", price: deliveryPrice }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Both halves in the order createSessionOrThrow runs them: what it would send to PayPal, or what it would throw.
|
||||||
|
const buildSession = (order) => {
|
||||||
|
buildPayPalOrderItems(order);
|
||||||
|
return buildPayPalOrderRequest(RETURN_URL, order);
|
||||||
|
};
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an order without a total before computing the items", () => {
|
||||||
|
for (const total of [0, null, undefined]) {
|
||||||
|
assert.throws(() => buildPayPalOrderItems(validOrder({ total })), { name: "Error", message: "Cart is empty or has no total" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("computes one item per cart line, which is not sent to PayPal", () => {
|
||||||
|
const order = validOrder({
|
||||||
|
cart: [
|
||||||
|
{ id: 1, count: 1, product: notebook },
|
||||||
|
{ id: 2, count: 3, product: { id: 102, name: "Journaling Booklet", cover: { price: 12.5 } } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(buildPayPalOrderItems(order), [
|
||||||
|
{
|
||||||
|
name: "Notizbuch A5 Punktraster",
|
||||||
|
unitAmount: { currencyCode: "EUR", value: "15.04" },
|
||||||
|
tax: { currencyCode: "EUR", value: "2.86" },
|
||||||
|
quantity: "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Journaling Booklet",
|
||||||
|
unitAmount: { currencyCode: "EUR", value: "10.50" },
|
||||||
|
tax: { currencyCode: "EUR", value: "2.00" },
|
||||||
|
quantity: "3"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Known defects: these assert the correct behaviour and fail today, which the todo marks as expected.
|
||||||
|
|
||||||
|
test(
|
||||||
|
"D1: a missing delivery address is rejected with a clear validation error",
|
||||||
|
{ todo: "null deliveryAddress throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
|
||||||
|
() => {
|
||||||
|
assert.throws(() => buildSession(validOrder({ deliveryAddress: null })), isClearError(/address/i));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"D2: a two-line delivery address does not throw a TypeError",
|
||||||
|
{ todo: "a two-line address throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
|
||||||
|
() => {
|
||||||
|
assertNoTypeError(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"D3: a pickup whose delivery price is null is sent with shipping 0.00",
|
||||||
|
{ todo: "delivery price null throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
|
||||||
|
() => {
|
||||||
|
const order = validOrder({ delivery: { id: 2, name: "Abholung", price: null }, total: 17.9 });
|
||||||
|
|
||||||
|
assert.deepEqual(buildSession(order).purchaseUnits[0].amount.breakdown.shipping, { currencyCode: "EUR", value: "0.00" });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"D4: a cart line whose product was deleted is rejected with a clear error",
|
||||||
|
{ todo: "a deleted product throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
|
||||||
|
() => {
|
||||||
|
assert.throws(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })), isClearError(/product/i));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"D5: with a payment surcharge the breakdown still adds up to the amount",
|
||||||
|
{ todo: "the surcharge is in the amount but not in the breakdown — https://git.librete.ch/libretech/mp/issues/67" },
|
||||||
|
() => {
|
||||||
|
// total = 17.90 + 4.95 shipping + 1.50 surcharge
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Not a todo: this holds today for every whole-cent subtotal (Strapi stores prices as decimal(10,2)). It guards the fixes above.
|
||||||
|
test("D6: the breakdown adds up to the amount to the cent, also for the subtotals whose net is closest to half a cent", () => {
|
||||||
|
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))}`);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -453,6 +453,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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user