feat(checkout): capture a payment through injected dependencies
capturePayment(deps, { uuid, paypalOrderId, now }) is the capture flow
without Strapi or the PayPal SDK, so it is tested with fakes:
- a missing order is answered 404;
- a paid order is answered alreadyCaptured for the PayPal order it was
paid with, else 409, without a call or a write;
- a PayPal order the checkout did not store on the order is answered
409 before PayPal is asked anything;
- a PayPal order that does not verify as approved for the order is
answered 409 without a capture;
- the capture is sent with the PayPal-Request-Id
capture-<uuid>-<paypalOrderId>, so a retry captures once;
- a capture that does not verify leaves the order unpaid, is logged as
an error with the order id and the reason, and is answered 502: PayPal
may have taken the money, so it needs a manual refund check;
- the payment is written only while the order still carries the PayPal
order id, so an order whose cart or address changed during the
capture stays unpaid (409, logged with the capture id);
- PayPal and network errors are answered 502 and logged by name,
message and status code only.
Refs libretech/mp#71
This commit is contained in:
2 files changed
+530
No files matched your search
@@ -0,0 +1,155 @@
|
||||
import { verifyApprovedOrder, verifyCompletedCapture } from "./paypal-verify";
|
||||
import type { VerifiableOrder } from "./paypal-verify";
|
||||
import { describeError } from "../logging/error";
|
||||
|
||||
// POST /orders/:uuid/capture/:paypalOrderId, which the shop calls once the buyer approved the payment in PayPal's window
|
||||
// (libreshop/shop pages/checkout/3.vue, onApprove). The PayPal order id comes from the browser, so it must be the one the checkout
|
||||
// stored on the order before PayPal is asked anything, and PayPal's answers are checked against the order before it is marked paid.
|
||||
// The order controller passes Strapi and PayPalApi as the dependencies; the tests pass fakes.
|
||||
|
||||
/** The fields of the order the capture reads. */
|
||||
export type CapturableOrder = VerifiableOrder & {
|
||||
id: number | string;
|
||||
paymentAuthorised?: boolean | null;
|
||||
paypalOrderId?: string | null;
|
||||
};
|
||||
|
||||
/** What a verified capture writes to the order. */
|
||||
export type CapturedPayment = {
|
||||
paymentAuthorised: true;
|
||||
paymentStatus: "captured";
|
||||
paypalCaptureId: string;
|
||||
paymentCapturedAt: string;
|
||||
};
|
||||
|
||||
export type CaptureLog = Record<"info" | "warn" | "error", (message: string, fields: Record<string, unknown>) => void>;
|
||||
|
||||
export type CapturePaymentDeps<O extends CapturableOrder, R> = {
|
||||
/** The order with this uuid, or null. */
|
||||
findOrder: (uuid: string) => Promise<O | null>;
|
||||
/** PayPal's order as parsed JSON: GET /v2/checkout/orders/:id. */
|
||||
getPayPalOrder: (paypalOrderId: string) => Promise<unknown>;
|
||||
/** Captures the PayPal order and returns PayPal's order as parsed JSON: POST /v2/checkout/orders/:id/capture. */
|
||||
capturePayPalOrder: (paypalOrderId: string, requestId: string) => Promise<unknown>;
|
||||
/** Writes data to the order only while the order still carries this PayPal order id: the updated order, or null if it does not. */
|
||||
updateOrder: (id: O["id"], paypalOrderId: string, data: CapturedPayment) => Promise<R | null>;
|
||||
log: CaptureLog;
|
||||
};
|
||||
|
||||
export type CapturePaymentResult<R> =
|
||||
| { status: 200; alreadyCaptured: false; order: R }
|
||||
| { status: 200; alreadyCaptured: true }
|
||||
| { status: 404 | 409 | 500 | 502; message: string };
|
||||
|
||||
export const NOT_FOUND = "Order not found";
|
||||
export const ALREADY_PAID = "Order is already paid";
|
||||
export const NOT_THIS_ORDER = "Payment does not belong to this order";
|
||||
export const NOT_MATCHING = "Payment does not match this order";
|
||||
export const PAYPAL_FAILED = "PayPal could not capture the payment";
|
||||
export const NOT_CONFIRMED = "Payment could not be confirmed";
|
||||
export const NOT_RECORDED = "Payment could not be recorded";
|
||||
export const ORDER_CHANGED = "Order changed during the payment";
|
||||
|
||||
/**
|
||||
* The PayPal-Request-Id of the capture. PayPal answers a repeated id with the first capture's response instead of capturing again,
|
||||
* so a capture that is retried, or sent twice, takes the money once.
|
||||
*/
|
||||
export const captureRequestId = (uuid: string, paypalOrderId: string): string => `capture-${uuid}-${paypalOrderId}`;
|
||||
|
||||
/**
|
||||
* Captures the PayPal order the checkout created for the order and marks the order paid, in this order:
|
||||
* 1. a missing order is answered 404;
|
||||
* 2. a paid order is answered alreadyCaptured if the PayPal order is the one it was paid with, else 409, without a write;
|
||||
* 3. a PayPal order the checkout did not store on the order is answered 409, without asking PayPal;
|
||||
* 4. a PayPal order that is not APPROVED for this order's uuid, total and currency is answered 409, without a capture;
|
||||
* 5. the capture must be COMPLETED for the order total in EUR, else the order stays unpaid and 502 is answered;
|
||||
* 6. the payment is written only while the order still carries this PayPal order id, so a cart or address changed during the
|
||||
* capture is not marked paid.
|
||||
* PayPal and network errors are answered 502 and logged with name, message and status code only.
|
||||
*/
|
||||
export const capturePayment = async <O extends CapturableOrder, R>(
|
||||
deps: CapturePaymentDeps<O, R>,
|
||||
{ uuid, paypalOrderId, now }: { uuid: string; paypalOrderId: string; now: Date }
|
||||
): Promise<CapturePaymentResult<R>> => {
|
||||
const { log } = deps;
|
||||
|
||||
// An empty uuid finds no order. Strapi would drop an undefined condition and find any order.
|
||||
const order = typeof uuid === "string" && uuid !== "" ? await deps.findOrder(uuid) : null;
|
||||
if (!order) return { status: 404, message: NOT_FOUND };
|
||||
|
||||
const isStoredPayPalOrder = typeof order.paypalOrderId === "string" && order.paypalOrderId !== "" && order.paypalOrderId === paypalOrderId;
|
||||
|
||||
if (order.paymentAuthorised) {
|
||||
if (isStoredPayPalOrder) return { status: 200, alreadyCaptured: true };
|
||||
log.warn("Rejected a capture for a paid order with another PayPal order", { order: order.id });
|
||||
return { status: 409, message: ALREADY_PAID };
|
||||
}
|
||||
if (!isStoredPayPalOrder) {
|
||||
log.warn("Rejected a capture with a PayPal order the checkout did not create for the order", {
|
||||
order: order.id,
|
||||
hasPayPalOrder: typeof order.paypalOrderId === "string" && order.paypalOrderId !== ""
|
||||
});
|
||||
return { status: 409, message: NOT_THIS_ORDER };
|
||||
}
|
||||
|
||||
let paypalOrder: unknown;
|
||||
try {
|
||||
paypalOrder = await deps.getPayPalOrder(paypalOrderId);
|
||||
} catch (error) {
|
||||
log.error("Could not get the PayPal order", { order: order.id, error: describeError(error) });
|
||||
return { status: 502, message: PAYPAL_FAILED };
|
||||
}
|
||||
|
||||
const approved = verifyApprovedOrder(order, paypalOrder);
|
||||
if (approved.ok === false) {
|
||||
log.warn("Rejected a capture: the PayPal order does not match the order", { order: order.id, reason: approved.reason });
|
||||
return { status: 409, message: NOT_MATCHING };
|
||||
}
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
captured = await deps.capturePayPalOrder(paypalOrderId, captureRequestId(order.uuid, paypalOrderId));
|
||||
} catch (error) {
|
||||
log.error("PayPal did not capture the payment", { order: order.id, error: describeError(error) });
|
||||
return { status: 502, message: PAYPAL_FAILED };
|
||||
}
|
||||
|
||||
const capture = verifyCompletedCapture(order, captured);
|
||||
if (capture.ok === false) {
|
||||
// PayPal answered the capture, so it may have taken the money: the PayPal order needs a manual check, and a refund if it was paid.
|
||||
log.error("The PayPal capture does not match the order, which stays unpaid: check the PayPal order for a refund", {
|
||||
order: order.id,
|
||||
reason: capture.reason
|
||||
});
|
||||
return { status: 502, message: NOT_CONFIRMED };
|
||||
}
|
||||
|
||||
const data: CapturedPayment = {
|
||||
paymentAuthorised: true,
|
||||
paymentStatus: "captured",
|
||||
paypalCaptureId: capture.captureId,
|
||||
paymentCapturedAt: now.toISOString()
|
||||
};
|
||||
|
||||
let updated: R | null;
|
||||
try {
|
||||
updated = await deps.updateOrder(order.id, paypalOrderId, data);
|
||||
} catch (error) {
|
||||
log.error("PayPal captured the payment, but the order could not be marked paid: check the order", {
|
||||
order: order.id,
|
||||
capture: capture.captureId,
|
||||
error: describeError(error)
|
||||
});
|
||||
return { status: 500, message: NOT_RECORDED };
|
||||
}
|
||||
if (updated === null || updated === undefined) {
|
||||
log.error("PayPal captured the payment, but the order changed during the capture and stays unpaid: check the order for a refund", {
|
||||
order: order.id,
|
||||
capture: capture.captureId
|
||||
});
|
||||
return { status: 409, message: ORDER_CHANGED };
|
||||
}
|
||||
|
||||
log.info("Captured the payment", { order: order.id, capture: capture.captureId });
|
||||
return { status: 200, alreadyCaptured: false, order: updated };
|
||||
};
|
||||
@@ -0,0 +1,375 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
ALREADY_PAID,
|
||||
NOT_CONFIRMED,
|
||||
NOT_FOUND,
|
||||
NOT_MATCHING,
|
||||
NOT_RECORDED,
|
||||
NOT_THIS_ORDER,
|
||||
ORDER_CHANGED,
|
||||
PAYPAL_FAILED,
|
||||
capturePayment,
|
||||
captureRequestId
|
||||
} from "../../src/checkout/capture-payment.ts";
|
||||
import { formatLogLine } from "../../src/logging/format.ts";
|
||||
|
||||
const UUID = "11111111-2222-4333-8444-555555555555";
|
||||
const PAYPAL_ORDER_ID = "5O190127TN364715T";
|
||||
const OTHER_PAYPAL_ORDER_ID = "8MC585209K746392H";
|
||||
const CAPTURE_ID = "3C679366HH908993F";
|
||||
// The server's time when the capture request arrives.
|
||||
const NOW = new Date("2026-10-08T19:13:05.000Z");
|
||||
const CREDENTIALS = "RFVNTVlfQ0xJRU5UX0lEOkRVTU1ZX0NMSUVOVF9TRUNSRVQ="; // base64 of the dummy "DUMMY_CLIENT_ID:DUMMY_CLIENT_SECRET"
|
||||
|
||||
// PayPal's Orders v2 JSON for the order below, see paypal-verify.test.ts. Every call returns a fresh copy to change.
|
||||
const fixture = (name: string) => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8"));
|
||||
|
||||
// The row the capture reads with strapi.db.query("api::order.order").findOne({ where: { uuid } }) after a checkout stored the
|
||||
// PayPal order it created: subtotal 17.90, delivery 4.95, total 22.85.
|
||||
const orderRow = (overrides = {}) => ({
|
||||
id: 4711,
|
||||
uuid: UUID,
|
||||
email: "erika@example.org",
|
||||
subtotal: 17.9,
|
||||
total: 22.85,
|
||||
VAT: 2.86,
|
||||
paymentAuthorised: false,
|
||||
paymentStatus: null,
|
||||
invoiceSent: false,
|
||||
paypalOrderId: PAYPAL_ORDER_ID,
|
||||
paypalCaptureId: null,
|
||||
paymentCapturedAt: null,
|
||||
...overrides
|
||||
});
|
||||
|
||||
/**
|
||||
* Fakes of the dependencies over one order row, which record every call and every log line. updateOrder writes like the
|
||||
* controller's update with where { id, paypalOrderId }: only while the row still carries that PayPal order, else it answers null.
|
||||
* duringCapture runs while PayPal captures, as a request that changes the order at that moment would.
|
||||
*/
|
||||
const setup = ({
|
||||
row = orderRow(),
|
||||
paypalOrder = fixture("paypal-order-approved.json"),
|
||||
captured = fixture("paypal-order-captured.json"),
|
||||
getError = undefined,
|
||||
captureError = undefined,
|
||||
updateError = undefined,
|
||||
duringCapture = (row) => {}
|
||||
} = {}) => {
|
||||
const calls = { findOrder: [], getPayPalOrder: [], capturePayPalOrder: [], updateOrder: [] };
|
||||
const logs = [];
|
||||
const deps = {
|
||||
findOrder: async (uuid) => {
|
||||
calls.findOrder.push(uuid);
|
||||
return row !== null && row.uuid === uuid ? { ...row } : null;
|
||||
},
|
||||
getPayPalOrder: async (id) => {
|
||||
calls.getPayPalOrder.push(id);
|
||||
if (getError) throw getError;
|
||||
return paypalOrder;
|
||||
},
|
||||
capturePayPalOrder: async (id, requestId) => {
|
||||
calls.capturePayPalOrder.push({ id, requestId });
|
||||
duringCapture(row);
|
||||
if (captureError) throw captureError;
|
||||
return captured;
|
||||
},
|
||||
updateOrder: async (id, paypalOrderId, data) => {
|
||||
calls.updateOrder.push({ id, paypalOrderId, data });
|
||||
if (updateError) throw updateError;
|
||||
if (row.id !== id || row.paypalOrderId !== paypalOrderId) return null;
|
||||
Object.assign(row, data);
|
||||
return { ...row };
|
||||
},
|
||||
log: {
|
||||
info: (message, fields) => logs.push({ level: "info", message, fields }),
|
||||
warn: (message, fields) => logs.push({ level: "warn", message, fields }),
|
||||
error: (message, fields) => logs.push({ level: "error", message, fields })
|
||||
}
|
||||
};
|
||||
return { deps, calls, logs, row };
|
||||
};
|
||||
|
||||
const capture = (deps, { uuid = UUID, paypalOrderId = PAYPAL_ORDER_ID } = {}) => capturePayment(deps, { uuid, paypalOrderId, now: NOW });
|
||||
|
||||
const paypalCalls = (calls) => calls.getPayPalOrder.length + calls.capturePayPalOrder.length;
|
||||
|
||||
// What must never reach the log: the credentials of a failed request, and the buyer's data in PayPal's responses.
|
||||
const NEVER_LOGGED = [CREDENTIALS, "Authorization", "Erika", "Mustermann", "erika", "Musterstraße", "QYR5Z8XDVJNXQ", "INSTRUMENT_DECLINED"];
|
||||
|
||||
/** Checks the recorded log, and each line as the console shows it (config/logger.ts formats with formatLogLine). */
|
||||
const assertCleanLog = (logs) => {
|
||||
const lines = logs.map(({ level, message, fields }) =>
|
||||
formatLogLine({ timestamp: "2026-10-08 19:13:05.000", level, message: `app:${level[0]}:order-controller: ${message}`, ...fields })
|
||||
);
|
||||
for (const text of [JSON.stringify(logs), ...lines]) {
|
||||
for (const value of NEVER_LOGGED) assert.ok(!text.includes(value), `the log holds ${value}: ${text}`);
|
||||
}
|
||||
};
|
||||
|
||||
// What the PayPal SDK throws for an error response: @apimatic/core's ApiError, here the SDK's CustomError, with the request, whose
|
||||
// Authorization header holds the client credentials (token request) or an access token, and PayPal's error body.
|
||||
const paypalApiError = (statusCode: number, message: string) =>
|
||||
Object.assign(new Error(message), {
|
||||
request: {
|
||||
method: "POST",
|
||||
url: `https://api-m.sandbox.paypal.com/v2/checkout/orders/${PAYPAL_ORDER_ID}/capture`,
|
||||
headers: { Authorization: `Basic ${CREDENTIALS}`, "PayPal-Request-Id": captureRequestId(UUID, PAYPAL_ORDER_ID) }
|
||||
},
|
||||
statusCode,
|
||||
headers: { "paypal-debug-id": "f1a2b3c4d5e6f" },
|
||||
body: JSON.stringify({ name: "UNPROCESSABLE_ENTITY", details: [{ issue: "INSTRUMENT_DECLINED" }], debug_id: "f1a2b3c4d5e6f" })
|
||||
});
|
||||
|
||||
// What axios throws when PayPal cannot be reached: the request config with its headers.
|
||||
const networkError = (message = "getaddrinfo EAI_AGAIN api-m.sandbox.paypal.com") =>
|
||||
Object.assign(new Error(message), {
|
||||
name: "AxiosError",
|
||||
code: "EAI_AGAIN",
|
||||
config: { method: "post", url: "https://api-m.sandbox.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } }
|
||||
});
|
||||
|
||||
// The capture
|
||||
|
||||
test("captures the PayPal order the checkout stored and writes exactly the payment fields", async () => {
|
||||
const { deps, calls, logs } = setup();
|
||||
const result = await capture(deps);
|
||||
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.alreadyCaptured, false);
|
||||
assert.deepEqual(calls.findOrder, [UUID]);
|
||||
assert.deepEqual(calls.getPayPalOrder, [PAYPAL_ORDER_ID]);
|
||||
assert.deepEqual(calls.capturePayPalOrder, [{ id: PAYPAL_ORDER_ID, requestId: `capture-${UUID}-${PAYPAL_ORDER_ID}` }]);
|
||||
assert.deepEqual(calls.updateOrder, [
|
||||
{
|
||||
id: 4711,
|
||||
paypalOrderId: PAYPAL_ORDER_ID,
|
||||
data: { paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID, paymentCapturedAt: "2026-10-08T19:13:05.000Z" }
|
||||
}
|
||||
]);
|
||||
// the controller answers the updated order, sanitized
|
||||
assert.equal(result.order.paymentAuthorised, true);
|
||||
assert.deepEqual(logs, [{ level: "info", message: "Captured the payment", fields: { order: 4711, capture: CAPTURE_ID } }]);
|
||||
assertCleanLog(logs);
|
||||
});
|
||||
|
||||
test("sends the same PayPal-Request-Id on every attempt, so that PayPal captures once", async () => {
|
||||
assert.equal(captureRequestId(UUID, PAYPAL_ORDER_ID), "capture-11111111-2222-4333-8444-555555555555-5O190127TN364715T");
|
||||
// PayPal accepts at most 108 characters
|
||||
assert.ok(captureRequestId(UUID, PAYPAL_ORDER_ID).length <= 108);
|
||||
|
||||
// The first attempt times out after PayPal may have captured. The retry repeats the id, and PayPal answers the first capture.
|
||||
const first = setup({ captureError: Object.assign(networkError("timeout of 30000ms exceeded"), { code: "ECONNABORTED" }) });
|
||||
assert.deepEqual(await capture(first.deps), { status: 502, message: PAYPAL_FAILED });
|
||||
const retry = setup();
|
||||
assert.equal((await capture(retry.deps)).status, 200);
|
||||
|
||||
assert.equal(first.calls.capturePayPalOrder[0].requestId, retry.calls.capturePayPalOrder[0].requestId);
|
||||
});
|
||||
|
||||
// Paying a cheap order, capturing an expensive one
|
||||
|
||||
test("rejects a PayPal order the checkout did not store on the order, without asking PayPal", async () => {
|
||||
// The buyer paid order A with PayPal order OTHER_PAYPAL_ORDER_ID and calls the capture of the expensive order B with it.
|
||||
const { deps, calls, logs, row } = setup({ row: orderRow({ id: 4712, uuid: UUID, total: 249 }) });
|
||||
|
||||
assert.deepEqual(await capture(deps, { paypalOrderId: OTHER_PAYPAL_ORDER_ID }), { status: 409, message: NOT_THIS_ORDER });
|
||||
assert.equal(paypalCalls(calls), 0);
|
||||
assert.equal(calls.updateOrder.length, 0);
|
||||
assert.equal(row.paymentAuthorised, false);
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "warn",
|
||||
message: "Rejected a capture with a PayPal order the checkout did not create for the order",
|
||||
fields: { order: 4712, hasPayPalOrder: true }
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects every PayPal order for an order without a stored one, without asking PayPal", async () => {
|
||||
// no checkout yet, an order from before the checkout stored it, or a cart changed since the checkout
|
||||
for (const paypalOrderId of [null, undefined, ""]) {
|
||||
const { deps, calls, logs } = setup({ row: orderRow({ paypalOrderId }) });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 409, message: NOT_THIS_ORDER });
|
||||
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
|
||||
assert.equal(logs[0].fields.hasPayPalOrder, false);
|
||||
}
|
||||
|
||||
// a request without a PayPal order id
|
||||
const { deps, calls } = setup();
|
||||
assert.deepEqual(await capture(deps, { paypalOrderId: "" }), { status: 409, message: NOT_THIS_ORDER });
|
||||
assert.equal(paypalCalls(calls), 0);
|
||||
});
|
||||
|
||||
test("does not capture a PayPal order whose amount is not the order total", async () => {
|
||||
const paypalOrder = fixture("paypal-order-approved.json");
|
||||
paypalOrder.purchase_units[0].amount.value = "2.29";
|
||||
const { deps, calls, logs } = setup({ paypalOrder });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 409, message: NOT_MATCHING });
|
||||
assert.deepEqual(calls.getPayPalOrder, [PAYPAL_ORDER_ID]);
|
||||
assert.equal(calls.capturePayPalOrder.length, 0);
|
||||
assert.equal(calls.updateOrder.length, 0);
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "warn",
|
||||
message: "Rejected a capture: the PayPal order does not match the order",
|
||||
fields: { order: 4711, reason: "the PayPal order amount 2.29 is not the order total 22.85" }
|
||||
}
|
||||
]);
|
||||
assertCleanLog(logs);
|
||||
});
|
||||
|
||||
test("does not capture a PayPal order created for another order, in another currency or not approved", async () => {
|
||||
const cases: [(paypalOrder) => void, string][] = [
|
||||
[
|
||||
(paypalOrder) => (paypalOrder.purchase_units[0].custom_id = "99999999-8888-4777-8666-555555555555"),
|
||||
"the custom_id of the PayPal order is not the order's uuid"
|
||||
],
|
||||
[(paypalOrder) => (paypalOrder.purchase_units[0].amount.currency_code = "USD"), "the PayPal order currency is USD, not EUR"],
|
||||
[(paypalOrder) => (paypalOrder.status = "CREATED"), "the PayPal order status is CREATED, not APPROVED"]
|
||||
];
|
||||
for (const [change, reason] of cases) {
|
||||
const paypalOrder = fixture("paypal-order-approved.json");
|
||||
change(paypalOrder);
|
||||
const { deps, calls, logs } = setup({ paypalOrder });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 409, message: NOT_MATCHING });
|
||||
assert.equal(calls.capturePayPalOrder.length + calls.updateOrder.length, 0);
|
||||
assert.equal(logs[0].fields.reason, reason);
|
||||
assertCleanLog(logs);
|
||||
}
|
||||
});
|
||||
|
||||
// A paid order
|
||||
|
||||
test("answers alreadyCaptured for a paid order and the PayPal order it was paid with, without a call or a write", async () => {
|
||||
const { deps, calls, logs } = setup({ row: orderRow({ paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID }) });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 200, alreadyCaptured: true });
|
||||
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
|
||||
assert.deepEqual(logs, []);
|
||||
});
|
||||
|
||||
test("rejects another PayPal order for a paid order, without a call or a write", async () => {
|
||||
const paidRows = [
|
||||
orderRow({ paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID }),
|
||||
// paid before the checkout stored the PayPal order
|
||||
orderRow({ paymentAuthorised: true, paymentStatus: "authorised", paypalOrderId: null })
|
||||
];
|
||||
for (const row of paidRows) {
|
||||
const { deps, calls } = setup({ row });
|
||||
|
||||
assert.deepEqual(await capture(deps, { paypalOrderId: OTHER_PAYPAL_ORDER_ID }), { status: 409, message: ALREADY_PAID });
|
||||
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("answers 404 for a missing order, without asking PayPal", async () => {
|
||||
const { deps, calls } = setup({ row: null });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 404, message: NOT_FOUND });
|
||||
assert.equal(paypalCalls(calls), 0);
|
||||
|
||||
// No uuid: no lookup at all, since Strapi drops an undefined where condition and would find any order.
|
||||
for (const uuid of ["", undefined]) {
|
||||
const { deps, calls } = setup();
|
||||
assert.deepEqual(await capturePayment(deps, { uuid, paypalOrderId: PAYPAL_ORDER_ID, now: NOW }), { status: 404, message: NOT_FOUND });
|
||||
assert.deepEqual(calls.findOrder, []);
|
||||
}
|
||||
});
|
||||
|
||||
// PayPal fails
|
||||
|
||||
test("answers 502 when PayPal fails, and logs the error's name, message and status code but not its request", async () => {
|
||||
const failures: [string, Error & { statusCode?: number }][] = [
|
||||
["getError", paypalApiError(401, "Authentication failed due to missing authorization header, or invalid authentication credentials.")],
|
||||
["getError", networkError()],
|
||||
["captureError", paypalApiError(422, "The requested action could not be performed, semantically incorrect, or failed business validation.")],
|
||||
["captureError", networkError()]
|
||||
];
|
||||
for (const [failing, error] of failures) {
|
||||
const { deps, calls, logs, row } = setup({ [failing]: error });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 502, message: PAYPAL_FAILED });
|
||||
assert.equal(calls.capturePayPalOrder.length, failing === "getError" ? 0 : 1);
|
||||
assert.equal(calls.updateOrder.length, 0);
|
||||
assert.equal(row.paymentAuthorised, false);
|
||||
assert.equal(logs.length, 1);
|
||||
assert.equal(logs[0].level, "error");
|
||||
assert.deepEqual(logs[0].fields, { order: 4711, error: { name: error.name, message: error.message, statusCode: error.statusCode } });
|
||||
assertCleanLog(logs);
|
||||
}
|
||||
});
|
||||
|
||||
// After PayPal answered the capture
|
||||
|
||||
test("keeps the order unpaid and answers 502 when the capture does not match, logging the order and the reason only", async () => {
|
||||
const pending = fixture("paypal-order-captured.json");
|
||||
pending.purchase_units[0].payments.captures[0].status = "PENDING";
|
||||
const lower = fixture("paypal-order-captured.json");
|
||||
lower.purchase_units[0].payments.captures[0].amount.value = "2.29";
|
||||
const minimal = { id: PAYPAL_ORDER_ID, status: "COMPLETED", links: [] };
|
||||
|
||||
const cases: [unknown, string][] = [
|
||||
[pending, "the capture status is PENDING, not COMPLETED"],
|
||||
[lower, "the capture amount 2.29 is not the order total 22.85"],
|
||||
[minimal, "the capture response has no capture"]
|
||||
];
|
||||
for (const [captured, reason] of cases) {
|
||||
const { deps, calls, logs, row } = setup({ captured });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 502, message: NOT_CONFIRMED });
|
||||
assert.equal(calls.updateOrder.length, 0);
|
||||
assert.equal(row.paymentAuthorised, false);
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "error",
|
||||
message: "The PayPal capture does not match the order, which stays unpaid: check the PayPal order for a refund",
|
||||
fields: { order: 4711, reason }
|
||||
}
|
||||
]);
|
||||
assertCleanLog(logs);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the order unpaid when its cart or address changed during the capture", async () => {
|
||||
// addProduct, removeProduct and updateByUuid clear paypalOrderId in the write that changes the order
|
||||
const { deps, calls, logs, row } = setup({
|
||||
duringCapture: (row) => {
|
||||
row.total = 249;
|
||||
row.paypalOrderId = null;
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 409, message: ORDER_CHANGED });
|
||||
assert.equal(calls.updateOrder.length, 1);
|
||||
assert.equal(row.paymentAuthorised, false);
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "error",
|
||||
message: "PayPal captured the payment, but the order changed during the capture and stays unpaid: check the order for a refund",
|
||||
fields: { order: 4711, capture: CAPTURE_ID }
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("answers 500 when the order cannot be marked paid after the capture, and logs the capture", async () => {
|
||||
const { deps, logs } = setup({ updateError: new Error("Connection terminated unexpectedly") });
|
||||
|
||||
assert.deepEqual(await capture(deps), { status: 500, message: NOT_RECORDED });
|
||||
assert.deepEqual(logs, [
|
||||
{
|
||||
level: "error",
|
||||
message: "PayPal captured the payment, but the order could not be marked paid: check the order",
|
||||
fields: {
|
||||
order: 4711,
|
||||
capture: CAPTURE_ID,
|
||||
error: { name: "Error", message: "Connection terminated unexpectedly", statusCode: undefined }
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
Reference in new issue
Block a user