366 lines
16 KiB
TypeScript
366 lines
16 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
}));
|