Checkout: only customer fields on order updates, verified PayPal capture, safe logging (#6)
build / test (push) Successful in 11s
build / build (push) Successful in 8m31s

Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #6.
This commit is contained in:
libretech authored and Michael Czechowski committed 2026-10-15 10:23:02 +02:00
1 parent 9b0c6af8b7
commit 5002cac469
24 files changed
+2911 -313

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
// Checks PayPal's answers against the shop order before the order is marked paid: the PayPal order the buyer approved
// (GET /v2/checkout/orders/:id) and the order after its capture (POST /v2/checkout/orders/:id/capture, Prefer: return=representation).
// The JSON is PayPal's Orders v2 JSON in snake_case, as PayPalApi parses the raw response body. Money is compared in integer cents.
// A reason says what does not match. It repeats only PayPal's statuses, currency codes and amounts, never a name, an email address
// or an address from the response, because it is logged.
/** The fields of the shop order the checks compare. */
export type VerifiableOrder = {
uuid: string;
total: number | null;
};
export type ApprovedOrderCheck = { ok: true } | { ok: false; reason: string };
export type CompletedCaptureCheck = { ok: true; captureId: string } | { ok: false; reason: string };
const CURRENCY = "EUR";
type Json = Record<string, unknown>;
const isObject = (value: unknown): value is Json => typeof value === "object" && value !== null && !Array.isArray(value);
/** PayPal's money value as integer cents: "12.34" is 1234. Null for anything but a non-negative amount with at most two decimals. */
export const parseCents = (value: unknown): number | null => {
if (typeof value !== "string") return null;
const match = /^(\d{1,10})(?:\.(\d{1,2}))?$/.exec(value);
if (match === null) return null;
return Number(match[1]) * 100 + Number((match[2] ?? "").padEnd(2, "0"));
};
/** The order total in integer cents, or null if the order has no positive total. Strapi returns the decimal column as a number. */
export const orderTotalCents = (order: { total: number | null }): number | null =>
typeof order.total === "number" && Number.isFinite(order.total) && order.total > 0 ? Math.round(order.total * 100) : null;
const formatCents = (cents: number): string => `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
// The values of OrderStatus and CaptureStatus in @paypal/paypal-server-sdk 0.6.1. A reason repeats these and ISO currency codes,
// and no other value from the response.
const ORDER_STATUSES = ["CREATED", "SAVED", "APPROVED", "VOIDED", "COMPLETED", "PAYER_ACTION_REQUIRED"];
const CAPTURE_STATUSES = ["COMPLETED", "DECLINED", "PARTIALLY_REFUNDED", "PENDING", "REFUNDED", "FAILED"];
const shown = (value: unknown, isKnown: (value: string) => boolean): string =>
typeof value === "string" && isKnown(value) ? value : "missing or unexpected";
const orderStatus = (value: unknown): string => shown(value, (status) => ORDER_STATUSES.includes(status));
const captureStatus = (value: unknown): string => shown(value, (status) => CAPTURE_STATUSES.includes(status));
const currency = (value: unknown): string => shown(value, (code) => /^[A-Z]{3}$/.test(code));
const firstPurchaseUnit = (paypalOrder: Json): Json | null => {
const units = paypalOrder.purchase_units;
return Array.isArray(units) && isObject(units[0]) ? units[0] : null;
};
/** Why PayPal's money ({ currency_code, value }) is not the order total in EUR, or null if it is. */
const amountMismatch = (subject: string, money: unknown, order: VerifiableOrder): string | null => {
if (!isObject(money)) return `${subject} has no amount`;
if (money.currency_code !== CURRENCY) return `${subject} currency is ${currency(money.currency_code)}, not ${CURRENCY}`;
const cents = parseCents(money.value);
if (cents === null) return `${subject} amount is not a money value`;
const total = orderTotalCents(order);
if (total === null) return "the order has no total";
if (cents !== total) return `${subject} amount ${formatCents(cents)} is not the order total ${formatCents(total)}`;
return null;
};
/**
* The PayPal order may be captured for this order: the buyer approved it, it was created for this order (its custom_id is the
* order's uuid, see buildPayPalOrderRequest), and its amount is the order total in EUR.
*/
export const verifyApprovedOrder = (order: VerifiableOrder, paypalOrder: unknown): ApprovedOrderCheck => {
if (!isObject(paypalOrder)) return { ok: false, reason: "the PayPal order is not an object" };
if (paypalOrder.status !== "APPROVED") {
return { ok: false, reason: `the PayPal order status is ${orderStatus(paypalOrder.status)}, not APPROVED` };
}
const unit = firstPurchaseUnit(paypalOrder);
if (unit === null) return { ok: false, reason: "the PayPal order has no purchase unit" };
if (typeof order.uuid !== "string" || order.uuid === "" || unit.custom_id !== order.uuid) {
return { ok: false, reason: "the custom_id of the PayPal order is not the order's uuid" };
}
const mismatch = amountMismatch("the PayPal order", unit.amount, order);
return mismatch === null ? { ok: true } : { ok: false, reason: mismatch };
};
/**
* PayPal captured the order total in EUR: the PayPal order and its first capture are COMPLETED. Returns the capture's id.
* A capture PayPal holds for review (PENDING) or declined (DECLINED) is not a payment.
*/
export const verifyCompletedCapture = (order: VerifiableOrder, captured: unknown): CompletedCaptureCheck => {
if (!isObject(captured)) return { ok: false, reason: "the capture response is not an object" };
if (captured.status !== "COMPLETED") return { ok: false, reason: `the PayPal order status is ${orderStatus(captured.status)}, not COMPLETED` };
const unit = firstPurchaseUnit(captured);
const payments = unit !== null && isObject(unit.payments) ? unit.payments : null;
const capture = payments !== null && Array.isArray(payments.captures) && isObject(payments.captures[0]) ? payments.captures[0] : null;
if (capture === null) return { ok: false, reason: "the capture response has no capture" };
if (capture.status !== "COMPLETED") return { ok: false, reason: `the capture status is ${captureStatus(capture.status)}, not COMPLETED` };
const mismatch = amountMismatch("the capture", capture.amount, order);
if (mismatch !== null) return { ok: false, reason: mismatch };
if (typeof capture.id !== "string" || !/^[A-Za-z0-9-]{1,64}$/.test(capture.id)) return { ok: false, reason: "the capture has no valid id" };
return { ok: true, captureId: capture.id };
};