Checkout: only customer fields on order updates, verified PayPal capture, safe logging #6
No files matched your search
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user