refactor(checkout): move VAT rates, pricing and the PayPal order into pure modules

src/checkout/ holds code without the PayPal SDK or Strapi at runtime, so it can be
unit tested without node_modules:

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

No behaviour change.

Refs libretech/mp#71
This commit is contained in:
2026-10-08 22:10:28 +02:00
parent 901a3b385f
commit 9873c184d6
6 changed files with 144 additions and 87 deletions
+2 -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!;
@@ -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;
+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?: {
+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;
+6 -79
View File
@@ -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"
}; };