Checkout: only customer fields on order updates, verified PayPal capture, safe logging #6

Merged
libretech merged 16 commits from fix/paypal-request-logging into main 2026-10-15 10:23:04 +02:00
3 changed files with 89 additions and 59 deletions
Showing only changes of commit 66e5800418 - Show all commits

No files matched your search

+64 -40
View File
@@ -7,10 +7,19 @@ import { orderDefaultParams } from "../services/order";
import { calculateTotalProductPrice } from "../../product/services/product";
import { parseCustomerUpdate } 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 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.
* 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 });
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) {
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 });
// 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, {
data: { cart: cart },
data: { cart: cart, paypalOrderId: null },
...orderDefaultParams
});
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;
}
// 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, {
data: { cart: updatedCart },
data: { cart: updatedCart, paypalOrderId: null },
...orderDefaultParams
});
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"));
},
/**
* 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) => {
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"));
@@ -214,49 +230,57 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
...orderDefaultParams
});
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 });
capturePayment: async (ctx) => {
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({
where: { uuid },
...orderDefaultParams
});
if (!order) return ctx.notFound("Order not found");
if (order.paymentAuthorised) {
strapi.log.info(`app:i:order-controller: Payment already authorised for order ${uuid}`);
return { success: true, alreadyCaptured: true };
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");
}
try {
// Capture the payment via PayPal server SDK
const captureResult = await paypalApi.checkPaymentStatus({ orderID: paypalOrderId as string });
await strapi.db.query("api::order.order").update({ where: { id: order.id }, data: { paypalOrderId: session.id } });
return { id: session.id, links: session.links };
},
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
});
/** Captures the PayPal order the checkout stored and marks the order paid, see src/checkout/capture-payment.ts. */
capturePayment: async (ctx) => {
const { uuid, paypalOrderId } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
strapi.log.info(`app:i:order-controller: Payment captured successfully for order ${uuid}`);
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 });
const result = await capturePayment(
{
findOrder: (orderUuid) => strapi.db.query("api::order.order").findOne({ where: { uuid: orderUuid } }),
getPayPalOrder: (id) => paypalApi.getOrder(id),
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
// during the capture is not found, and stays unpaid. Strapi's update answers null then and runs no afterUpdate.
updateOrder: (id, storedPayPalOrderId, data) =>
strapi.db.query("api::order.order").update({ where: { id, paypalOrderId: storedPayPalOrderId }, data, ...orderDefaultParams }),
log: captureLog
},
{
uuid: typeof uuid === "string" ? uuid : "",
paypalOrderId: typeof paypalOrderId === "string" ? paypalOrderId : "",
now: new Date()
}
} 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 { Order } from "../../types";
import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../checkout/paypal-order";
import { describeError } from "../logging/error";
class PayPalApi {
private readonly client: Client;
@@ -24,7 +25,8 @@ class PayPalApi {
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.
buildPayPalOrderItems(order);
@@ -41,31 +43,32 @@ class PayPalApi {
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 {
id: sessionData.id,
links: sessionData.links
};
} 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;
}
}
async checkPaymentStatus(redirect: { orderID: string }) {
try {
const collect = {
id: redirect.orderID,
prefer: "return=minimal"
};
/** PayPal's order as parsed JSON in PayPal's snake_case (GET /v2/checkout/orders/:id). Throws PayPal's and network errors. */
async getOrder(id: string): Promise<unknown> {
const { body } = await this.ordersController.ordersGet({ id });
return JSON.parse(body as string);
}
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) {
+6 -3
View File
@@ -46,12 +46,15 @@ const MAX = {
};
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
// manages, and the paypalOrderId the capture writes; with values a client would send to get a paid order for less.
// Every attribute of the order that only the server writes (src/api/order/content-types/order/schema.json), among them the PayPal
// 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 = {
paymentAuthorised: true,
paymentStatus: "authorised",
paymentStatus: "captured",
paypalOrderId: "5O190127TN364715T",
paypalCaptureId: "3C679366HH908993F",
paymentCapturedAt: "2026-01-01T00:00:00.000Z",
total: 0.01,
subtotal: 0.01,
VAT: 0,