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
125 lines
3.4 KiB
TypeScript
125 lines
3.4 KiB
TypeScript
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"
|
|
}
|
|
});
|