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>
This commit was merged in pull request #5.
This commit is contained in:
2026-10-08 22:36:47 +02:00
committed by libretech
parent 901a3b385f
commit 9b0c6af8b7
15 changed files with 542 additions and 96 deletions
+124
View File
@@ -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"
}
});
+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;