feat(checkout): verify the approved PayPal order and its capture against the order
verifyApprovedOrder accepts PayPal's order only if it is APPROVED, its first purchase unit's custom_id is the order's uuid, and its amount is the order total in EUR. verifyCompletedCapture accepts the order after the capture only if it and its first capture are COMPLETED for the order total in EUR, and returns the capture id. Both read PayPal's Orders v2 JSON in snake_case, as PayPalApi parses the response body. Money is compared in integer cents: PayPal's "12.34" is parsed strictly, the order total is Math.round(total * 100). For every total up to 3000.00 the amount the checkout sends (total.toFixed(2)) parses to the cents the capture compares. A rejection reason repeats only PayPal's statuses, currency codes and amounts, never the payer's or the shipping data of the response. Refs libretech/mp#71
This commit is contained in:
4 files changed
+577
No files matched your search
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"id": "5O190127TN364715T",
|
||||
"intent": "CAPTURE",
|
||||
"status": "APPROVED",
|
||||
"payment_source": {
|
||||
"paypal": {
|
||||
"email_address": "erika.buyer@example.org",
|
||||
"account_id": "QYR5Z8XDVJNXQ",
|
||||
"account_status": "VERIFIED",
|
||||
"name": {
|
||||
"given_name": "Erika",
|
||||
"surname": "Mustermann"
|
||||
},
|
||||
"address": {
|
||||
"country_code": "DE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"purchase_units": [
|
||||
{
|
||||
"reference_id": "11111111-2222-4333-8444-555555555555",
|
||||
"amount": {
|
||||
"currency_code": "EUR",
|
||||
"value": "22.85",
|
||||
"breakdown": {
|
||||
"item_total": {
|
||||
"currency_code": "EUR",
|
||||
"value": "15.04"
|
||||
},
|
||||
"shipping": {
|
||||
"currency_code": "EUR",
|
||||
"value": "4.95"
|
||||
},
|
||||
"tax_total": {
|
||||
"currency_code": "EUR",
|
||||
"value": "2.86"
|
||||
}
|
||||
}
|
||||
},
|
||||
"payee": {
|
||||
"email_address": "merchant@example.com",
|
||||
"merchant_id": "7KNGBPH2U58GQ"
|
||||
},
|
||||
"custom_id": "11111111-2222-4333-8444-555555555555",
|
||||
"invoice_id": "R-12-4711",
|
||||
"shipping": {
|
||||
"name": {
|
||||
"full_name": "Erika Mustermann"
|
||||
},
|
||||
"address": {
|
||||
"address_line_1": "Musterstraße 1",
|
||||
"admin_area_2": "Stuttgart",
|
||||
"postal_code": "70190",
|
||||
"country_code": "DE"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"payer": {
|
||||
"name": {
|
||||
"given_name": "Erika",
|
||||
"surname": "Mustermann"
|
||||
},
|
||||
"email_address": "erika.buyer@example.org",
|
||||
"payer_id": "QYR5Z8XDVJNXQ",
|
||||
"address": {
|
||||
"country_code": "DE"
|
||||
}
|
||||
},
|
||||
"create_time": "2026-10-08T19:12:40Z",
|
||||
"links": [
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
|
||||
"rel": "self",
|
||||
"method": "GET"
|
||||
},
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
|
||||
"rel": "update",
|
||||
"method": "PATCH"
|
||||
},
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T/capture",
|
||||
"rel": "capture",
|
||||
"method": "POST"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"id": "5O190127TN364715T",
|
||||
"intent": "CAPTURE",
|
||||
"status": "COMPLETED",
|
||||
"payment_source": {
|
||||
"paypal": {
|
||||
"email_address": "erika.buyer@example.org",
|
||||
"account_id": "QYR5Z8XDVJNXQ",
|
||||
"account_status": "VERIFIED",
|
||||
"name": {
|
||||
"given_name": "Erika",
|
||||
"surname": "Mustermann"
|
||||
},
|
||||
"address": {
|
||||
"country_code": "DE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"purchase_units": [
|
||||
{
|
||||
"reference_id": "11111111-2222-4333-8444-555555555555",
|
||||
"amount": {
|
||||
"currency_code": "EUR",
|
||||
"value": "22.85",
|
||||
"breakdown": {
|
||||
"item_total": {
|
||||
"currency_code": "EUR",
|
||||
"value": "15.04"
|
||||
},
|
||||
"shipping": {
|
||||
"currency_code": "EUR",
|
||||
"value": "4.95"
|
||||
},
|
||||
"tax_total": {
|
||||
"currency_code": "EUR",
|
||||
"value": "2.86"
|
||||
}
|
||||
}
|
||||
},
|
||||
"payee": {
|
||||
"email_address": "merchant@example.com",
|
||||
"merchant_id": "7KNGBPH2U58GQ"
|
||||
},
|
||||
"custom_id": "11111111-2222-4333-8444-555555555555",
|
||||
"invoice_id": "R-12-4711",
|
||||
"shipping": {
|
||||
"name": {
|
||||
"full_name": "Erika Mustermann"
|
||||
},
|
||||
"address": {
|
||||
"address_line_1": "Musterstraße 1",
|
||||
"admin_area_2": "Stuttgart",
|
||||
"postal_code": "70190",
|
||||
"country_code": "DE"
|
||||
}
|
||||
},
|
||||
"payments": {
|
||||
"captures": [
|
||||
{
|
||||
"id": "3C679366HH908993F",
|
||||
"status": "COMPLETED",
|
||||
"amount": {
|
||||
"currency_code": "EUR",
|
||||
"value": "22.85"
|
||||
},
|
||||
"final_capture": true,
|
||||
"seller_protection": {
|
||||
"status": "ELIGIBLE",
|
||||
"dispute_categories": ["ITEM_NOT_RECEIVED", "UNAUTHORIZED_TRANSACTION"]
|
||||
},
|
||||
"seller_receivable_breakdown": {
|
||||
"gross_amount": {
|
||||
"currency_code": "EUR",
|
||||
"value": "22.85"
|
||||
},
|
||||
"paypal_fee": {
|
||||
"currency_code": "EUR",
|
||||
"value": "0.92"
|
||||
},
|
||||
"net_amount": {
|
||||
"currency_code": "EUR",
|
||||
"value": "21.93"
|
||||
}
|
||||
},
|
||||
"custom_id": "11111111-2222-4333-8444-555555555555",
|
||||
"invoice_id": "R-12-4711",
|
||||
"links": [
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/payments/captures/3C679366HH908993F",
|
||||
"rel": "self",
|
||||
"method": "GET"
|
||||
},
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/payments/captures/3C679366HH908993F/refund",
|
||||
"rel": "refund",
|
||||
"method": "POST"
|
||||
},
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
|
||||
"rel": "up",
|
||||
"method": "GET"
|
||||
}
|
||||
],
|
||||
"create_time": "2026-10-08T19:13:02Z",
|
||||
"update_time": "2026-10-08T19:13:02Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"payer": {
|
||||
"name": {
|
||||
"given_name": "Erika",
|
||||
"surname": "Mustermann"
|
||||
},
|
||||
"email_address": "erika.buyer@example.org",
|
||||
"payer_id": "QYR5Z8XDVJNXQ",
|
||||
"address": {
|
||||
"country_code": "DE"
|
||||
}
|
||||
},
|
||||
"create_time": "2026-10-08T19:12:40Z",
|
||||
"update_time": "2026-10-08T19:13:02Z",
|
||||
"links": [
|
||||
{
|
||||
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
|
||||
"rel": "self",
|
||||
"method": "GET"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { orderTotalCents, parseCents, verifyApprovedOrder, verifyCompletedCapture } from "../../src/checkout/paypal-verify.ts";
|
||||
import { buildPayPalOrderRequest } from "../../src/checkout/paypal-order.ts";
|
||||
|
||||
const UUID = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
// PayPal's Orders v2 JSON as PayPalApi parses the response body: the PayPal order once the buyer approved it
|
||||
// (GET /v2/checkout/orders/:id), and the PayPal order after its capture (POST /v2/checkout/orders/:id/capture with
|
||||
// Prefer: return=representation). Both belong to the order below. Every call returns a fresh copy to change.
|
||||
const fixture = (name: string) => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8"));
|
||||
const approved = () => fixture("paypal-order-approved.json");
|
||||
const captured = () => fixture("paypal-order-captured.json");
|
||||
|
||||
// The order of paypal-order.test.ts: subtotal 17.90, delivery 4.95, total 22.85.
|
||||
const order = (overrides = {}) => ({
|
||||
id: 4711,
|
||||
uuid: UUID,
|
||||
email: "erika@example.org",
|
||||
subtotal: 17.9,
|
||||
total: 22.85,
|
||||
VAT: 2.86,
|
||||
delivery: { id: 1, name: "DHL", price: 4.95 },
|
||||
invoiceNumber: "R-12-4711",
|
||||
deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
|
||||
...overrides
|
||||
});
|
||||
|
||||
// What identifies the buyer or the merchant in the fixtures. A reason is logged, so it repeats none of it.
|
||||
const PERSONAL_DATA = [
|
||||
"Erika",
|
||||
"Mustermann",
|
||||
"erika",
|
||||
"QYR5Z8XDVJNXQ",
|
||||
"Musterstraße",
|
||||
"70190",
|
||||
"Stuttgart",
|
||||
"merchant@example.com",
|
||||
"7KNGBPH2U58GQ"
|
||||
];
|
||||
|
||||
/** The reason of a rejection, after checking that it repeats no personal data. */
|
||||
const rejection = (result: { ok: boolean; reason?: string }): string => {
|
||||
assert.equal(result.ok, false, "expected a rejection");
|
||||
for (const value of PERSONAL_DATA) assert.ok(!result.reason.includes(value), `the reason repeats ${value}: ${result.reason}`);
|
||||
return result.reason;
|
||||
};
|
||||
|
||||
// Money
|
||||
|
||||
test("parses PayPal's money values into integer cents", () => {
|
||||
assert.equal(parseCents("22.85"), 2285);
|
||||
assert.equal(parseCents("0.01"), 1);
|
||||
assert.equal(parseCents("4.9"), 490);
|
||||
assert.equal(parseCents("100"), 10000);
|
||||
assert.equal(parseCents("0.00"), 0);
|
||||
assert.equal(parseCents("1234567.89"), 123456789);
|
||||
});
|
||||
|
||||
test("parses nothing but a non-negative amount with at most two decimals", () => {
|
||||
for (const value of ["", "22.850", "-22.85", "22,85", " 22.85", "22.85 ", "2.285e1", "22.", ".85", "0x10", "Infinity", "NaN", "12345678901.00"]) {
|
||||
assert.equal(parseCents(value), null, JSON.stringify(value));
|
||||
}
|
||||
for (const value of [22.85, null, undefined, {}, ["22.85"]]) {
|
||||
assert.equal(parseCents(value), null, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
test("turns the order total into integer cents, also where its float is a hair below the cent", () => {
|
||||
// 22.85 * 100 is 2284.9999999999995 and 0.29 * 100 is 28.999999999999996 in floating point
|
||||
assert.equal(orderTotalCents({ total: 22.85 }), 2285);
|
||||
assert.equal(orderTotalCents({ total: 0.29 }), 29);
|
||||
assert.equal(orderTotalCents({ total: 17.9 + 4.95 }), 2285);
|
||||
for (const total of [null, undefined, 0, -4.95, NaN, Infinity]) {
|
||||
assert.equal(orderTotalCents({ total }), null, String(total));
|
||||
}
|
||||
});
|
||||
|
||||
test("the amount the checkout sends PayPal is the amount the capture accepts, for every total up to 3000.00", () => {
|
||||
const mismatches: number[] = [];
|
||||
for (let cents = 1; cents <= 300000; cents++) {
|
||||
const total = cents / 100;
|
||||
// buildPayPalOrderRequest sends order.total.toFixed(2); the order service rounds every total to the cent
|
||||
if (parseCents(total.toFixed(2)) !== cents || orderTotalCents({ total }) !== cents) mismatches.push(total);
|
||||
}
|
||||
assert.equal(mismatches.length, 0, `e.g. ${mismatches.slice(0, 5).join(", ")}`);
|
||||
});
|
||||
|
||||
test("the fixtures belong to the PayPal order the checkout requests for the order", () => {
|
||||
const request = buildPayPalOrderRequest("https://shop.example/checkout/3", order()).purchaseUnits[0];
|
||||
|
||||
for (const paypalOrder of [approved(), captured()]) {
|
||||
const unit = paypalOrder.purchase_units[0];
|
||||
assert.equal(unit.custom_id, request.customId);
|
||||
assert.equal(unit.amount.value, request.amount.value);
|
||||
assert.equal(unit.amount.currency_code, request.amount.currencyCode);
|
||||
}
|
||||
});
|
||||
|
||||
// The PayPal order the buyer approved
|
||||
|
||||
test("accepts the PayPal order the buyer approved for the order", () => {
|
||||
assert.deepEqual(verifyApprovedOrder(order(), approved()), { ok: true });
|
||||
});
|
||||
|
||||
test("rejects a PayPal order that is not approved", () => {
|
||||
for (const status of ["CREATED", "SAVED", "PAYER_ACTION_REQUIRED", "VOIDED", "COMPLETED"]) {
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), { ...approved(), status })), `the PayPal order status is ${status}, not APPROVED`);
|
||||
}
|
||||
const { status, ...withoutStatus } = approved();
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), withoutStatus)), "the PayPal order status is missing or unexpected, not APPROVED");
|
||||
// a value that is not one of PayPal's statuses is not repeated
|
||||
assert.equal(
|
||||
rejection(verifyApprovedOrder(order(), { ...approved(), status: "APPROVED BY ERIKA MUSTERMANN" })),
|
||||
"the PayPal order status is missing or unexpected, not APPROVED"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects the PayPal order of a cheap order captured for an expensive one", () => {
|
||||
// The buyer pays order A, then calls the capture of order B with A's PayPal order.
|
||||
const orderB = order({ id: 4712, uuid: "99999999-8888-4777-8666-555555555555", total: 249 });
|
||||
|
||||
assert.equal(rejection(verifyApprovedOrder(orderB, approved())), "the custom_id of the PayPal order is not the order's uuid");
|
||||
});
|
||||
|
||||
test("rejects a PayPal order without a custom_id, also for an order without a uuid", () => {
|
||||
const paypalOrder = approved();
|
||||
delete paypalOrder.purchase_units[0].custom_id;
|
||||
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the custom_id of the PayPal order is not the order's uuid");
|
||||
// undefined is not compared with undefined, nor "" with ""
|
||||
assert.equal(
|
||||
rejection(verifyApprovedOrder(order({ uuid: undefined }), paypalOrder)),
|
||||
"the custom_id of the PayPal order is not the order's uuid"
|
||||
);
|
||||
paypalOrder.purchase_units[0].custom_id = "";
|
||||
assert.equal(rejection(verifyApprovedOrder(order({ uuid: "" }), paypalOrder)), "the custom_id of the PayPal order is not the order's uuid");
|
||||
});
|
||||
|
||||
test("rejects a PayPal order whose amount is not the order total", () => {
|
||||
const withAmount = (value: unknown) => {
|
||||
const paypalOrder = approved();
|
||||
paypalOrder.purchase_units[0].amount.value = value;
|
||||
return paypalOrder;
|
||||
};
|
||||
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("22.84"))), "the PayPal order amount 22.84 is not the order total 22.85");
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("22.86"))), "the PayPal order amount 22.86 is not the order total 22.85");
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("2285"))), "the PayPal order amount 2285.00 is not the order total 22.85");
|
||||
// the cart changed after the checkout
|
||||
assert.equal(rejection(verifyApprovedOrder(order({ total: 249 }), approved())), "the PayPal order amount 22.85 is not the order total 249.00");
|
||||
for (const value of ["22.850", "22,85", "", 22.85, null]) {
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), withAmount(value))), "the PayPal order amount is not a money value", String(value));
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a PayPal order in another currency or without an amount", () => {
|
||||
const paypalOrder = approved();
|
||||
paypalOrder.purchase_units[0].amount.currency_code = "USD";
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order currency is USD, not EUR");
|
||||
|
||||
paypalOrder.purchase_units[0].amount.currency_code = "Euro";
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order currency is missing or unexpected, not EUR");
|
||||
|
||||
delete paypalOrder.purchase_units[0].amount;
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order has no amount");
|
||||
});
|
||||
|
||||
test("rejects every PayPal order for an order without a total", () => {
|
||||
for (const total of [null, 0]) {
|
||||
assert.equal(rejection(verifyApprovedOrder(order({ total }), approved())), "the order has no total");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a PayPal order without a purchase unit, and anything that is not a PayPal order", () => {
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), { ...approved(), purchase_units: [] })), "the PayPal order has no purchase unit");
|
||||
const { purchase_units, ...withoutUnits } = approved();
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), withoutUnits)), "the PayPal order has no purchase unit");
|
||||
|
||||
// "/result/error" is what PayPalApi.checkPaymentStatus answered for every error
|
||||
for (const value of [null, undefined, "/result/error", [], 42]) {
|
||||
assert.equal(rejection(verifyApprovedOrder(order(), value)), "the PayPal order is not an object", String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// The PayPal order after the capture
|
||||
|
||||
test("accepts the completed capture of the order total and returns the capture id", () => {
|
||||
assert.deepEqual(verifyCompletedCapture(order(), captured()), { ok: true, captureId: "3C679366HH908993F" });
|
||||
});
|
||||
|
||||
test("rejects a capture response whose PayPal order is not completed", () => {
|
||||
for (const status of ["APPROVED", "PAYER_ACTION_REQUIRED", "VOIDED"]) {
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), { ...captured(), status })), `the PayPal order status is ${status}, not COMPLETED`);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a capture PayPal holds for review, declined or refunded", () => {
|
||||
for (const status of ["PENDING", "DECLINED", "FAILED", "REFUNDED", "PARTIALLY_REFUNDED"]) {
|
||||
const response = captured();
|
||||
response.purchase_units[0].payments.captures[0].status = status;
|
||||
if (status === "PENDING") response.purchase_units[0].payments.captures[0].status_details = { reason: "PENDING_REVIEW" };
|
||||
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), response)), `the capture status is ${status}, not COMPLETED`);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a capture response without a capture", () => {
|
||||
// what PayPal answers with Prefer: return=minimal, which the capture used to ask for
|
||||
const minimal = { id: "5O190127TN364715T", status: "COMPLETED", links: captured().links };
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), minimal)), "the capture response has no capture");
|
||||
|
||||
const withoutPayments = captured();
|
||||
delete withoutPayments.purchase_units[0].payments;
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), withoutPayments)), "the capture response has no capture");
|
||||
|
||||
const withoutCaptures = captured();
|
||||
withoutCaptures.purchase_units[0].payments.captures = [];
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), withoutCaptures)), "the capture response has no capture");
|
||||
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), { ...captured(), purchase_units: [] })), "the capture response has no capture");
|
||||
});
|
||||
|
||||
test("rejects a capture of another amount or in another currency", () => {
|
||||
const lower = captured();
|
||||
lower.purchase_units[0].payments.captures[0].amount.value = "2.29";
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), lower)), "the capture amount 2.29 is not the order total 22.85");
|
||||
|
||||
// the cart changed during the capture
|
||||
assert.equal(rejection(verifyCompletedCapture(order({ total: 249 }), captured())), "the capture amount 22.85 is not the order total 249.00");
|
||||
|
||||
const usd = captured();
|
||||
usd.purchase_units[0].payments.captures[0].amount.currency_code = "USD";
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), usd)), "the capture currency is USD, not EUR");
|
||||
|
||||
const withoutAmount = captured();
|
||||
delete withoutAmount.purchase_units[0].payments.captures[0].amount;
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), withoutAmount)), "the capture has no amount");
|
||||
});
|
||||
|
||||
test("rejects a capture without a valid id", () => {
|
||||
for (const id of [undefined, "", 12345, "3C679366HH908993F; Erika Mustermann", "x".repeat(65)]) {
|
||||
const response = captured();
|
||||
response.purchase_units[0].payments.captures[0].id = id;
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), response)), "the capture has no valid id", String(id));
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects anything that is not a capture response", () => {
|
||||
for (const value of [null, undefined, "/result/error", [], 42]) {
|
||||
assert.equal(rejection(verifyCompletedCapture(order(), value)), "the capture response is not an object", String(value));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user