Checkout: only customer fields on order updates, verified PayPal capture, safe logging (#6)
build / test (push) Successful in 11s
build / build (push) Successful in 8m31s

Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #6.
This commit is contained in:
libretech authored and Michael Czechowski committed 2026-10-15 10:23:02 +02:00
1 parent 9b0c6af8b7
commit 5002cac469
24 files changed
+2911 -313

No files matched your search

+113 -55
View File
@@ -1,9 +1,25 @@
import { factories } from "@strapi/strapi";
import { sanitize } from "@strapi/utils";
import type { ID } from "@strapi/database/dist/types";
import { Order } from "../../../../types";
import { paypalApi } from "../../../services/PayPalApi";
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, missingCheckoutFields } from "../../../checkout/order-state";
import { capturePayment } from "../../../checkout/capture-payment";
import type { CaptureLog } from "../../../checkout/capture-payment";
import { CHECKOUT_NOT_READY, checkoutFailure } from "../../../checkout/paypal-order";
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.
@@ -68,6 +84,7 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
where: { id },
...orderDefaultParams
});
if (!order) return ctx.notFound("Order not found");
order.cart = order?.cart.map((item) => ({
...item,
product: {
@@ -75,7 +92,6 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
totalProductPrice: calculateTotalProductPrice(item.product)
}
}));
if (!order) return ctx.notFound("Order not found");
return await sanitize.contentAPI.output(order, strapi.getModel("api::order.order"));
},
@@ -85,6 +101,7 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
where: { uuid },
...orderDefaultParams
});
if (!order) return ctx.notFound("Order not found");
order.cart = order?.cart.map((item) => ({
...item,
product: {
@@ -92,35 +109,46 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
totalProductPrice: calculateTotalProductPrice(item.product)
}
}));
if (!order) return ctx.notFound("Order not found");
return await sanitize.contentAPI.output(order, strapi.getModel("api::order.order"));
},
/**
* The customer's checkout steps: contact details, terms, addresses and delivery method. Only these fields are written;
* payment state, totals, numbers and documents are written by the payment flow and the server alone.
*/
updateByUuid: async (ctx) => {
const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
const order = await strapi.db.query("api::order.order").findOne({ where: { uuid } });
if (!order) return ctx.notFound("Order not found");
if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED);
const sanitizedBody = await sanitize.contentAPI.input(ctx.request.body, strapi.getModel("api::order.order"));
const update = parseCustomerUpdate(ctx.request.body, new Date());
if (update.ok === false) {
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 });
}
// 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"] });
if (!delivery) {
strapi.log.warn("app:w:order-controller: Rejected order update", { order: order.id, fields: ["data.delivery"] });
return ctx.badRequest("Invalid order update", { errors: ["data.delivery: no delivery method has this id"] });
}
}
// Handle customer creation/update if structured addresses are provided
// @ts-expect-error
if (sanitizedBody?.data?.invoiceAddressStructured) {
if (data.invoiceAddressStructured) {
const address = data.invoiceAddressStructured;
const customerAddress =
// @ts-expect-error
sanitizedBody.data.invoiceAddress ||
// @ts-expect-error
`${sanitizedBody.data.invoiceAddressStructured.givenName} ${sanitizedBody.data.invoiceAddressStructured.familyName}\n${sanitizedBody.data.invoiceAddressStructured.streetAddress}\n${sanitizedBody.data.invoiceAddressStructured.postalCode} ${sanitizedBody.data.invoiceAddressStructured.addressLevel2}`;
// @ts-expect-error
sanitizedBody.data.customer = await strapi
.service("api::customer.customer")
// @ts-expect-error
.findOrCreateCustomer(customerAddress, sanitizedBody.data.invoiceAddressStructured);
data.invoiceAddress ||
`${address.givenName} ${address.familyName}\n${address.streetAddress}\n${address.postalCode} ${address.addressLevel2}`;
data.customer = await strapi.service("api::customer.customer").findOrCreateCustomer(customerAddress, address);
}
const orderUnsafe = await strapi.service("api::order.order").update(order.id, {
// @ts-expect-error
data: sanitizedBody?.data,
data,
...orderDefaultParams
});
@@ -131,6 +159,7 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
const { uuid, productId } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
const order = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Order;
if (!order) return ctx.notFound("Order not found");
if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED);
const product = parseInt(productId as string);
const count = parseInt(ctx.query.count as string) || 1;
@@ -147,8 +176,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 });
@@ -162,6 +192,7 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
const product = parseInt(productId as string);
if (!order) return ctx.notFound("Order not found");
if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED);
strapi.log.info("app:v:order-controller: - Removing product from cart", { totalBefore: order.total });
const updatedCart = [...(order.cart || [])];
@@ -179,14 +210,20 @@ 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. An order PayPal cannot take, such as one without a usable
* delivery address, is answered 400 with the reason, a cart with a deleted product 409, and a PayPal or network error 502.
*/
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"));
@@ -195,49 +232,68 @@ 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(CHECKOUT_NOT_READY, { 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) {
const failure = checkoutFailure(error);
const fields = { order: order.id, error: describeError(error) };
switch (failure.status) {
case 400:
strapi.log.warn("app:w:order-controller: The order cannot be checked out as it is", fields);
return ctx.badRequest(failure.message, { reason: failure.reason });
case 409:
strapi.log.warn("app:w:order-controller: A product in the cart is no longer available", fields);
return ctx.conflict(failure.message);
default:
strapi.log.error("app:e:order-controller: Could not create the PayPal order", fields);
return ctx.badGateway(failure.message);
}
}
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);
}
},
@@ -245,6 +301,7 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
strapi.log.info(`app:i:order-controller: Started invoice generation`);
const orderUnsafe = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Order;
if (!orderUnsafe) return ctx.notFound("Order not found");
if (!orderUnsafe.paymentAuthorised) {
return ctx.locked("Payment not authorised, cannot generate invoice");
@@ -263,6 +320,7 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order"));
strapi.log.info(`app:i:order-controller: Started delivery note generation`);
const orderUnsafe = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Order;
if (!orderUnsafe) return ctx.notFound("Order not found");
if (!orderUnsafe.paymentAuthorised) {
return ctx.locked("Payment not authorised, cannot generate delivery note");
@@ -300,7 +358,7 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
try {
return await paypalApi.handleWebhook(ctx.request.body);
} catch (error) {
strapi.log.error(`paypal-api: ${JSON.stringify({ error })}`);
strapi.log.error("app:e:order-controller: Handling the PayPal webhook failed", { error: describeError(error) });
return ctx.internalServerError();
}
}