105 lines
6.0 KiB
TypeScript
105 lines
6.0 KiB
TypeScript
// 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 };
|
|
};
|