fix(order): capture only the PayPal order the checkout stored, verified against the order

The capture took the PayPal order id from the URL and captured it with
Prefer: return=minimal, then marked the order paid on status COMPLETED.
The checkout never stored its PayPal order (the paypalOrderId written at
capture was no schema attribute and was dropped), and neither the order
nor the amount was compared. A buyer could pay a cheap order and call
the capture of an expensive one with that PayPal order id: the expensive
order was marked paid and invoiced. checkPaymentStatus also turned every
error into the string "/result/error", which failed the capture without
a log.

checkout now answers 409 for a paid order and 400 with the missing
fields for an order without email, invoice address or delivery method.
It stores the id of the PayPal order it created as paypalOrderId and
answers { id, links } as before. A PayPal error is answered 502.

capturePayment is an adapter for src/checkout/capture-payment.ts. It
reads the PayPal order with GET, captures with
Prefer: return=representation and a PayPal-Request-Id, and writes
paymentAuthorised, paymentStatus "captured", paypalCaptureId and
paymentCapturedAt with where { id, paypalOrderId }.

updateByUuid, addProduct and removeProduct set paypalOrderId to null in
the write that changes the order, so a changed cart or address needs a
new checkout, and an earlier approval cannot be captured for it.

PayPalApi gains getOrder and captureOrder, which return PayPal's parsed
JSON and let errors propagate. createSessionOrThrow logs an error by
name, message and status code instead of the error object, which holds
the request with its Authorization header; its request building is
unchanged.

Refs libretech/mp#71
This commit is contained in:
libretech committed 2026-10-09 01:12:31 +02:00
1 parent c7a422621a
commit 66e5800418
3 files changed
+88 -58

No files matched your search

+63 -39
View File
@@ -7,10 +7,19 @@ import { orderDefaultParams } from "../services/order";
import { calculateTotalProductPrice } from "../../product/services/product"; import { calculateTotalProductPrice } from "../../product/services/product";
import { parseCustomerUpdate } from "../../../checkout/customer-update"; import { parseCustomerUpdate } from "../../../checkout/customer-update";
import type { CustomerUpdate } from "../../../checkout/customer-update"; import type { CustomerUpdate } from "../../../checkout/customer-update";
import { isLockedForCustomer } from "../../../checkout/order-state"; import { isLockedForCustomer, missingCheckoutFields } from "../../../checkout/order-state";
import { capturePayment } from "../../../checkout/capture-payment";
import type { CaptureLog } from "../../../checkout/capture-payment";
import { describeError } from "../../../logging/error";
const ORDER_LOCKED = "Order can no longer be changed"; const ORDER_LOCKED = "Order can no longer be changed";
const captureLog: CaptureLog = {
info: (message, fields) => strapi.log.info(`app:i:order-controller: ${message}`, fields),
warn: (message, fields) => strapi.log.warn(`app:w:order-controller: ${message}`, fields),
error: (message, fields) => strapi.log.error(`app:e:order-controller: ${message}`, fields)
};
/** /**
* The order controller is a funnel for the client to interact with the order service. * The order controller is a funnel for the client to interact with the order service.
* From creating until finalizing an order, the controller is responsible for handling * From creating until finalizing an order, the controller is responsible for handling
@@ -117,7 +126,8 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
strapi.log.warn("app:w:order-controller: Rejected order update", { order: order.id, fields: update.fields }); strapi.log.warn("app:w:order-controller: Rejected order update", { order: order.id, fields: update.fields });
return ctx.badRequest("Invalid order update", { errors: update.errors }); return ctx.badRequest("Invalid order update", { errors: update.errors });
} }
const data: CustomerUpdate & { customer?: ID } = { ...update.data }; // A changed address or delivery method needs a new checkout: the PayPal order created before can no longer be captured.
const data: CustomerUpdate & { customer?: ID; paypalOrderId: null } = { ...update.data, paypalOrderId: null };
if (data.delivery !== undefined) { if (data.delivery !== undefined) {
const delivery = await strapi.db.query("api::delivery.delivery").findOne({ where: { id: data.delivery }, select: ["id"] }); const delivery = await strapi.db.query("api::delivery.delivery").findOne({ where: { id: data.delivery }, select: ["id"] });
@@ -165,8 +175,9 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
strapi.log.info(`app:v:order-controller: - Adding product to cart ${order.id}`, { totalBefore: order.total }); strapi.log.info(`app:v:order-controller: - Adding product to cart ${order.id}`, { totalBefore: order.total });
// A changed cart needs a new checkout: the PayPal order created before can no longer be captured.
const orderUnsafe = await strapi.service("api::order.order").update(order.id, { const orderUnsafe = await strapi.service("api::order.order").update(order.id, {
data: { cart: cart }, data: { cart: cart, paypalOrderId: null },
...orderDefaultParams ...orderDefaultParams
}); });
strapi.log.info("app:v:order-controller: ✔ Adding product to cart", { totalAfter: orderUnsafe.total }); strapi.log.info("app:v:order-controller: ✔ Adding product to cart", { totalAfter: orderUnsafe.total });
@@ -198,14 +209,19 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
updatedCart[existingIndex].count -= count; updatedCart[existingIndex].count -= count;
} }
// A changed cart needs a new checkout: the PayPal order created before can no longer be captured.
const orderUnsafe = await strapi.service("api::order.order").update(order.id, { const orderUnsafe = await strapi.service("api::order.order").update(order.id, {
data: { cart: updatedCart }, data: { cart: updatedCart, paypalOrderId: null },
...orderDefaultParams ...orderDefaultParams
}); });
strapi.log.info("app:v:order-controller: ✔ Removing product from cart", { totalAfter: order.total }); strapi.log.info("app:v:order-controller: ✔ Removing product from cart", { totalAfter: order.total });
return await sanitize.contentAPI.output(orderUnsafe, strapi.getModel("api::order.order")); return await sanitize.contentAPI.output(orderUnsafe, strapi.getModel("api::order.order"));
}, },
/**
* Creates the PayPal order for the order and stores its id on the order: the capture accepts this PayPal order and no other.
* Answers PayPal's id and links, which the shop's PayPal button opens.
*/
checkout: async (ctx) => { checkout: async (ctx) => {
const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
const { returnUrl } = await sanitize.contentAPI.query(ctx.query, strapi.getModel("api::order.order")); const { returnUrl } = await sanitize.contentAPI.query(ctx.query, strapi.getModel("api::order.order"));
@@ -214,49 +230,57 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
...orderDefaultParams ...orderDefaultParams
}); });
if (!order) return ctx.notFound("Order not found"); if (!order) return ctx.notFound("Order not found");
if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED);
return await paypalApi.createSessionOrThrow(returnUrl as string, order); const missing = missingCheckoutFields(order);
if (missing.length > 0) return ctx.badRequest("Order is not ready for checkout", { missing });
let session: { id: string; links: unknown };
try {
session = await paypalApi.createSessionOrThrow(returnUrl as string, order);
} catch (error) {
strapi.log.error("app:e:order-controller: Could not create the PayPal order", { order: order.id, error: describeError(error) });
return ctx.badGateway("Could not create the PayPal order");
}
await strapi.db.query("api::order.order").update({ where: { id: order.id }, data: { paypalOrderId: session.id } });
return { id: session.id, links: session.links };
}, },
/** Captures the PayPal order the checkout stored and marks the order paid, see src/checkout/capture-payment.ts. */
capturePayment: async (ctx) => { capturePayment: async (ctx) => {
const { uuid, paypalOrderId } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const { uuid, paypalOrderId } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
strapi.log.info(`app:i:order-controller: Capturing payment for order ${uuid} with PayPal order ${paypalOrderId}`);
const order = await strapi.db.query("api::order.order").findOne({ const result = await capturePayment(
where: { uuid }, {
...orderDefaultParams findOrder: (orderUuid) => strapi.db.query("api::order.order").findOne({ where: { uuid: orderUuid } }),
}); getPayPalOrder: (id) => paypalApi.getOrder(id),
if (!order) return ctx.notFound("Order not found"); capturePayPalOrder: (id, requestId) => paypalApi.captureOrder(id, requestId),
// The where clause makes the write conditional: a cart or address change clears paypalOrderId, so an order changed
if (order.paymentAuthorised) { // during the capture is not found, and stays unpaid. Strapi's update answers null then and runs no afterUpdate.
strapi.log.info(`app:i:order-controller: Payment already authorised for order ${uuid}`); updateOrder: (id, storedPayPalOrderId, data) =>
return { success: true, alreadyCaptured: true }; strapi.db.query("api::order.order").update({ where: { id, paypalOrderId: storedPayPalOrderId }, data, ...orderDefaultParams }),
} log: captureLog
try {
// Capture the payment via PayPal server SDK
const captureResult = await paypalApi.checkPaymentStatus({ orderID: paypalOrderId as string });
if (captureResult.status === "COMPLETED") {
// Update order with payment info
const updatedOrder = await strapi.entityService.update("api::order.order", order.id, {
data: {
paymentAuthorised: true,
paymentStatus: "authorised",
paypalOrderId: paypalOrderId as string
}, },
...orderDefaultParams {
}); uuid: typeof uuid === "string" ? uuid : "",
paypalOrderId: typeof paypalOrderId === "string" ? paypalOrderId : "",
strapi.log.info(`app:i:order-controller: Payment captured successfully for order ${uuid}`); now: new Date()
return await sanitize.contentAPI.output(updatedOrder, strapi.getModel("api::order.order"));
} else {
strapi.log.error(`app:e:order-controller: Payment capture failed for order ${uuid}`, { captureResult });
return ctx.badRequest("Payment capture failed", { status: captureResult.status });
} }
} catch (error) { );
strapi.log.error(`app:e:order-controller: Error capturing payment for order ${uuid}`, { error });
return ctx.internalServerError("Payment capture failed"); switch (result.status) {
case 200:
if (result.alreadyCaptured === true) return { success: true, alreadyCaptured: true };
return await sanitize.contentAPI.output(result.order, strapi.getModel("api::order.order"));
case 404:
return ctx.notFound(result.message);
case 409:
return ctx.conflict(result.message);
case 502:
return ctx.badGateway(result.message);
default:
return ctx.internalServerError(result.message);
} }
}, },
+19 -16
View File
@@ -2,6 +2,7 @@ import { Client, Environment, LogLevel, OrdersController, OrderRequest } from "@
import { paypalClientId, paypalClientSecret, paypalEnvironment } from "../../config/constants"; import { paypalClientId, paypalClientSecret, paypalEnvironment } from "../../config/constants";
import { Order } from "../../types"; import { Order } from "../../types";
import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../checkout/paypal-order"; import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../checkout/paypal-order";
import { describeError } from "../logging/error";
class PayPalApi { class PayPalApi {
private readonly client: Client; private readonly client: Client;
@@ -24,7 +25,8 @@ class PayPalApi {
this.ordersController = new OrdersController(this.client); this.ordersController = new OrdersController(this.client);
} }
async createSessionOrThrow(returnUrl: string, order: Order) { /** Creates the PayPal order for the order and returns PayPal's id for it, which the capture accepts, and its links. */
async createSessionOrThrow(returnUrl: string, order: Order): Promise<{ id: string; links: unknown }> {
// Outside the try block, so these throws are not logged here. The items are computed but not sent. // Outside the try block, so these throws are not logged here. The items are computed but not sent.
buildPayPalOrderItems(order); buildPayPalOrderItems(order);
@@ -41,31 +43,32 @@ class PayPalApi {
strapi.log.info("app:i:paypal-api: " + JSON.stringify({ session: sessionData })); strapi.log.info("app:i:paypal-api: " + JSON.stringify({ session: sessionData }));
if (typeof sessionData?.id !== "string" || sessionData.id === "") throw new Error("PayPal answered without an order id");
return { return {
id: sessionData.id, id: sessionData.id,
links: sessionData.links links: sessionData.links
}; };
} catch (error) { } catch (error) {
strapi.log.error("app:e:paypal-api: " + JSON.stringify({ error })); // Never the error object: an SDK or axios error carries the request with its Authorization header.
strapi.log.error("app:e:paypal-api: Creating the PayPal order failed", { error: describeError(error) });
throw error; throw error;
} }
} }
async checkPaymentStatus(redirect: { orderID: string }) { /** PayPal's order as parsed JSON in PayPal's snake_case (GET /v2/checkout/orders/:id). Throws PayPal's and network errors. */
try { async getOrder(id: string): Promise<unknown> {
const collect = { const { body } = await this.ordersController.ordersGet({ id });
id: redirect.orderID, return JSON.parse(body as string);
prefer: "return=minimal"
};
const { body } = await this.ordersController.ordersCapture(collect);
const captureResult = JSON.parse(body as string);
strapi.log.info("app:i:paypal-api: " + JSON.stringify({ captureResult }));
return captureResult;
} catch (error) {
return "/result/error";
} }
/**
* Captures the PayPal order and returns the whole order after the capture as parsed JSON in PayPal's snake_case
* (POST /v2/checkout/orders/:id/capture, Prefer: return=representation). PayPal answers a repeated requestId with the
* response of the first capture instead of capturing again. Throws PayPal's and network errors.
*/
async captureOrder(id: string, requestId: string): Promise<unknown> {
const { body } = await this.ordersController.ordersCapture({ id, paypalRequestId: requestId, prefer: "return=representation" });
return JSON.parse(body as string);
} }
async handleWebhook(req: any) { async handleWebhook(req: any) {
+6 -3
View File
@@ -46,12 +46,15 @@ const MAX = {
}; };
const MAX_TEXT = "Max Mustermann\nHauptstraße 5\n10115 Berlin"; const MAX_TEXT = "Max Mustermann\nHauptstraße 5\n10115 Berlin";
// Every attribute of the order that only the server writes (src/api/order/content-types/order/schema.json), the fields Strapi // Every attribute of the order that only the server writes (src/api/order/content-types/order/schema.json), among them the PayPal
// manages, and the paypalOrderId the capture writes; with values a client would send to get a paid order for less. // order the checkout stores and the capture it verified, and the fields Strapi manages; with values a client would send to get a
// paid order for less.
const SERVER_FIELDS = { const SERVER_FIELDS = {
paymentAuthorised: true, paymentAuthorised: true,
paymentStatus: "authorised", paymentStatus: "captured",
paypalOrderId: "5O190127TN364715T", paypalOrderId: "5O190127TN364715T",
paypalCaptureId: "3C679366HH908993F",
paymentCapturedAt: "2026-01-01T00:00:00.000Z",
total: 0.01, total: 0.01,
subtotal: 0.01, subtotal: 0.01,
VAT: 0, VAT: 0,