Checkout: only customer fields on order updates, verified PayPal capture, safe logging #6
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