From 9873c184d69929ac4b691e234afdc2c323c72fc6 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Thu, 8 Oct 2026 22:10:28 +0200 Subject: [PATCH] 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 https://git.librete.ch/libretech/mp/issues/71 --- config/constants.ts | 6 +- src/api/product/services/product.ts | 7 +- src/checkout/paypal-order.ts | 124 ++++++++++++++++++++++++++++ src/checkout/pricing.ts | 5 ++ src/checkout/vat.ts | 4 + src/services/PayPalApi.ts | 85 ++----------------- 6 files changed, 144 insertions(+), 87 deletions(-) create mode 100644 src/checkout/paypal-order.ts create mode 100644 src/checkout/pricing.ts create mode 100644 src/checkout/vat.ts diff --git a/config/constants.ts b/config/constants.ts index d3de926..0fa3b8c 100644 --- a/config/constants.ts +++ b/config/constants.ts @@ -1,4 +1,5 @@ import { Environment } from "@paypal/paypal-server-sdk"; +import { vatDecimal } from "../src/checkout/vat"; export const pdfApiUrl = process.env.PDF_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 shopSecondaryLogoUrl = process.env.SHOP_SECONDARY_LOGO_URL ?? ""; -// TODO: Should be retrieved from DepotApi -export const vatIncludedDecimal = 1.19; -// TODO: Should be retrieved from DepotApi -export const vatDecimal = 0.19; +export { vatIncludedDecimal, vatDecimal } from "../src/checkout/vat"; export const vatDecimalExcluded = 1 - vatDecimal; export const maxProductsSitemap = 500; diff --git a/src/api/product/services/product.ts b/src/api/product/services/product.ts index 799b57f..5982994 100644 --- a/src/api/product/services/product.ts +++ b/src/api/product/services/product.ts @@ -3,7 +3,7 @@ */ import { factories } from "@strapi/strapi"; -import { Product } from "../../../../types"; +import { calculateTotalProductPrice } from "../../../checkout/pricing"; export default factories.createCoreService("api::product.product", ({ strapi }) => ({ find: async (params: Record) => { @@ -110,9 +110,8 @@ export default factories.createCoreService("api::product.product", ({ strapi }) } })); -export const calculateTotalProductPrice = (product: Partial): number => { - return (product?.cover?.price ?? 0) + (product?.pages?.price ?? 0) + (product?.ruling?.price ?? 0); -}; +// Implemented in src/checkout/pricing.ts; re-exported so existing importers keep working. +export { calculateTotalProductPrice }; export interface ProductParams { populate?: { diff --git a/src/checkout/paypal-order.ts b/src/checkout/paypal-order.ts new file mode 100644 index 0000000..7cf509c --- /dev/null +++ b/src/checkout/paypal-order.ts @@ -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" + } +}); diff --git a/src/checkout/pricing.ts b/src/checkout/pricing.ts new file mode 100644 index 0000000..7e449db --- /dev/null +++ b/src/checkout/pricing.ts @@ -0,0 +1,5 @@ +import type { Product } from "../../types"; + +export const calculateTotalProductPrice = (product: Partial): number => { + return (product?.cover?.price ?? 0) + (product?.pages?.price ?? 0) + (product?.ruling?.price ?? 0); +}; diff --git a/src/checkout/vat.ts b/src/checkout/vat.ts new file mode 100644 index 0000000..462c1f5 --- /dev/null +++ b/src/checkout/vat.ts @@ -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; diff --git a/src/services/PayPalApi.ts b/src/services/PayPalApi.ts index c121839..304b392 100644 --- a/src/services/PayPalApi.ts +++ b/src/services/PayPalApi.ts @@ -1,16 +1,7 @@ -import { - Client, - Environment, - LogLevel, - OrdersController, - CheckoutPaymentIntent, - OrderRequest, - OrderApplicationContextShippingPreference, - Item -} from "@paypal/paypal-server-sdk"; -import { paypalClientId, paypalClientSecret, paypalEnvironment, vatIncludedDecimal, vatDecimalExcluded } from "../../config/constants"; +import { Client, Environment, LogLevel, OrdersController, OrderRequest } from "@paypal/paypal-server-sdk"; +import { paypalClientId, paypalClientSecret, paypalEnvironment } from "../../config/constants"; import { Order } from "../../types"; -import { calculateTotalProductPrice } from "../api/product/services/product"; +import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../checkout/paypal-order"; class PayPalApi { private readonly client: Client; @@ -34,76 +25,12 @@ class PayPalApi { } async createSessionOrThrow(returnUrl: string, order: Order) { - if (!order.total) throw new Error("Cart is empty or has no total"); - - 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; - }); + // Outside the try block, so these throws are not logged here. The items are computed but not sent. + buildPayPalOrderItems(order); try { const collect = { - body: { - 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, + body: buildPayPalOrderRequest(returnUrl, order) as OrderRequest, prefer: "return=minimal" };