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. * From creating until finalizing an order, the controller is responsible for handling * the business logic. * * --------------------------------------- * INIT * --------------------------------------- * 1. Create order * --------------------------------------- * PRE-ORDER (must have order uuid) * --------------------------------------- * 1.1. Find order * 2. Add/remove product from order * --------------------------------------- * ORDER VALIDATION (must have at least one product) * --------------------------------------- * 3. Update order * 3.1. Contact info (email) * 3.2. Confirm terms and conditions * 3.3. Address (invoice, delivery) * 3.4. Delivery method * --------------------------------------- * ORDER PAYMENT (order must include all above properties) * --------------------------------------- * 4. Checkout order (create adyen payment session) * --------------------------------------- * POST-ORDER (payment must be authorised) * --------------------------------------- * 5. Finalize order (generate invoice, delivery note) * 6. Send invoice and delivery note via email */ export default factories.createCoreController("api::order.order", ({ strapi }) => ({ create: async (_) => { const order = await strapi.entityService.create("api::order.order", { data: {} }); strapi.log.debug(`app:d:order-controller: Order created: ${JSON.stringify({ order })}`); return await sanitize.contentAPI.output(order, strapi.getModel("api::order.order")); }, update: async (ctx) => { const { id } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); strapi.log.verbose(`app:v:order-controller: - Updating order ID${id}`, orderDefaultParams); const order = await strapi.db.query("api::order.order").findOne({ where: { id }, ...orderDefaultParams }); if (!order) return ctx.notFound("Order not found"); const sanitizedBody = await sanitize.contentAPI.input(ctx.request.body, strapi.getModel("api::order.order")); strapi.log.verbose("app:v:order-controller: - Updating order", { sanitizedBody }); const updatedOrder = await strapi.entityService.update("api::order.order", order.id, { // @ts-expect-error data: sanitizedBody?.data, ...orderDefaultParams }); strapi.log.verbose("app:v:order-controller: ✔ Updating order"); return await sanitize.contentAPI.output(updatedOrder, strapi.getModel("api::order.order")); }, findOne: async (ctx) => { const { id } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const order: Order = await strapi.db.query("api::order.order").findOne({ where: { id }, ...orderDefaultParams }); if (!order) return ctx.notFound("Order not found"); order.cart = order?.cart.map((item) => ({ ...item, product: { ...item.product, totalProductPrice: calculateTotalProductPrice(item.product) } })); return await sanitize.contentAPI.output(order, strapi.getModel("api::order.order")); }, findOneByUuid: async (ctx) => { const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const order: Order = await strapi.db.query("api::order.order").findOne({ where: { uuid }, ...orderDefaultParams }); if (!order) return ctx.notFound("Order not found"); order.cart = order?.cart.map((item) => ({ ...item, product: { ...item.product, totalProductPrice: calculateTotalProductPrice(item.product) } })); 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 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 if (data.invoiceAddressStructured) { const address = data.invoiceAddressStructured; const customerAddress = 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, { data, ...orderDefaultParams }); return await sanitize.contentAPI.output(orderUnsafe, strapi.getModel("api::order.order")); }, addProduct: async (ctx) => { 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; const cart = order.cart || []; const existingIndex = cart.findIndex((item) => item.product.id === product); if (existingIndex !== -1) { // Product already in cart cart[existingIndex].count += count; } else { // @ts-expect-error cart.push({ count, product }); } 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, paypalOrderId: null }, ...orderDefaultParams }); strapi.log.info("app:v:order-controller: ✔ Adding product to cart", { totalAfter: orderUnsafe.total }); return await sanitize.contentAPI.output(orderUnsafe, strapi.getModel("api::order.order")); }, removeProduct: async (ctx) => { const { uuid, productId } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const count = parseInt(ctx.query.count as string) || 1; const order = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Order; 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 || [])]; let existingIndex = -1; if (typeof productId === "string") { existingIndex = updatedCart.findIndex((item) => item.product.id === parseInt(productId)); } if (existingIndex === -1) return ctx.badRequest("Product not found in cart"); if (updatedCart[existingIndex].count <= count) { updatedCart.splice(existingIndex, 1); } else { 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, 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")); const order = await strapi.db.query("api::order.order").findOne({ where: { uuid }, ...orderDefaultParams }); if (!order) return ctx.notFound("Order not found"); if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED); const missing = missingCheckoutFields(order); if (missing.length > 0) return ctx.badRequest(CHECKOUT_NOT_READY, { missing }); 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); } } await strapi.db.query("api::order.order").update({ where: { id: order.id }, data: { paypalOrderId: session.id } }); return { id: session.id, links: session.links }; }, /** 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")); 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() } ); 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); } }, generateInvoice: async (ctx) => { 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"); } try { const updatedOrderUnsafe = await strapi.service("api::order.order").uploadPdf(orderUnsafe, "invoice", "Rechnung"); return await sanitize.contentAPI.output(updatedOrderUnsafe, strapi.getModel("api::order.order")); } catch (error) { strapi.log.error("app:e:order-controller: Error uploading delivery note pdf", { error }); return ctx.internalServerError("Could not upload delivery note"); } }, generateDeliveryNote: async (ctx) => { 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"); } try { const updatedOrderUnsafe = await strapi.service("api::order.order").uploadPdf(orderUnsafe, "deliveryNote", "Lieferschein"); return await sanitize.contentAPI.output(updatedOrderUnsafe, strapi.getModel("api::order.order")); } catch (error) { strapi.log.error("app:e:order-controller: Error uploading delivery note pdf", { error }); return ctx.internalServerError("Could not upload delivery note"); } }, sendInvoice: async (ctx) => { const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); strapi.log.info("app:i:order-controller: Sending invoice for order", { uuid }); try { const updatedOrderUnsafe = await strapi.service("api::order.order").sendInvoice(uuid, undefined); return await sanitize.contentAPI.output(updatedOrderUnsafe, strapi.getModel("api::order.order")); } catch (error) { strapi.log.error("app:e:order-controller: Error sending invoice", { error }); return ctx.internalServerError("Could not send invoice"); } }, sendDeliveryNote: async (ctx) => { const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); strapi.log.info("app:i:order-controller: Sending delivery note for", { uuid }); return await strapi.service("api::order.order").sendDeliveryNote(uuid, undefined); }, webhook: async (ctx) => { try { return await paypalApi.handleWebhook(ctx.request.body); } catch (error) { strapi.log.error("app:e:order-controller: Handling the PayPal webhook failed", { error: describeError(error) }); return ctx.internalServerError(); } } }));