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:
+2
-4
@@ -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;
|
||||
|
||||
@@ -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<string, any>) => {
|
||||
@@ -110,9 +110,8 @@ export default factories.createCoreService("api::product.product", ({ strapi })
|
||||
}
|
||||
}));
|
||||
|
||||
export const calculateTotalProductPrice = (product: Partial<Product>): 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?: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user