fix(order): write only customer fields on an order update

PUT /orders/:uuid/cart passed the body through sanitize.contentAPI.input,
which keeps every public writable attribute, to the order update. The shop
forwards the browser's body unchanged, so a browser could set
paymentAuthorised on its own order, and the order lifecycle then generated
and mailed the invoice.

The handler now parses the body with parseCustomerUpdate and writes only
the fields it returns, plus the customer it finds or creates from the
invoice address. A body with any other field is answered 400 with the
error list, and the warning it logs names the fields, not their values.
A delivery method that does not exist is answered 400.

Once the payment is authorised, the order is locked for the customer:
the update, add-product and remove-product answer 409.

Refs libretech/mp#71
This commit is contained in:
libretech committed 2026-10-08 22:58:21 +02:00
1 parent 2992e4805e
commit 20afcd4416
1 file changed
+33 -14
+33 -14
View File
@@ -1,9 +1,15 @@
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 } from "../../../checkout/order-state";
const ORDER_LOCKED = "Order can no longer be changed";
/**
* The order controller is a funnel for the client to interact with the order service.
@@ -96,31 +102,42 @@ export default factories.createCoreController("api::order.order", ({ strapi }) =
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 });
}
const data: CustomerUpdate & { customer?: ID } = { ...update.data };
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 +148,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;
@@ -162,6 +180,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 || [])];