Test the checkout code; publish images only for releases (#5)
- src/checkout/ (PayPal order request, pricing, VAT) and src/logging/format.ts hold the checkout's pure code, moved out of PayPalApi, the product service and config without a behaviour change. - npm test runs node:test on the TypeScript sources (type stripping), with no dependencies: 21 tests, 13 pin today's output, 8 describe the correct behaviour for the known defects of libretech/mp#67 and are marked todo. - CI: the tests run in the image's base (node:22.14.0-alpine) for every pull request, push to main and release tag. The image is built for pull requests and built and published only for v* tags; main no longer publishes :main and :sha-* images. Refs libretech/mp#71 Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #5.
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user