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" };