diff --git a/src/api/order/controllers/order.ts b/src/api/order/controllers/order.ts index 1cc7cec..d68a0c2 100644 --- a/src/api/order/controllers/order.ts +++ b/src/api/order/controllers/order.ts @@ -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); } }, diff --git a/src/services/PayPalApi.ts b/src/services/PayPalApi.ts index 304b392..c003e00 100644 --- a/src/services/PayPalApi.ts +++ b/src/services/PayPalApi.ts @@ -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 { + 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 { + const { body } = await this.ordersController.ordersCapture({ id, paypalRequestId: requestId, prefer: "return=representation" }); + return JSON.parse(body as string); } async handleWebhook(req: any) { diff --git a/tests/unit/customer-update.test.ts b/tests/unit/customer-update.test.ts index 9dd12af..6133477 100644 --- a/tests/unit/customer-update.test.ts +++ b/tests/unit/customer-update.test.ts @@ -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,