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