From 4b663cca3acf43b0e02a1fb94b8d4f14913bfe45 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Mon, 8 Aug 2022 21:11:34 +0200 Subject: [PATCH] feat(medusa): Use transactions in CartCompletionStrategy (#1968) --- .changeset/tricky-suns-wink.md | 5 + .../api/routes/store/carts/complete-cart.ts | 6 +- .../interfaces/cart-completion-strategy.ts | 23 +- packages/medusa/src/loaders/plugins.ts | 17 + .../strategies/__tests__/cart-completion.js | 1 + .../medusa/src/strategies/cart-completion.ts | 436 ++++++++++-------- 6 files changed, 279 insertions(+), 209 deletions(-) create mode 100644 .changeset/tricky-suns-wink.md diff --git a/.changeset/tricky-suns-wink.md b/.changeset/tricky-suns-wink.md new file mode 100644 index 0000000000..ec435de4a2 --- /dev/null +++ b/.changeset/tricky-suns-wink.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +Use transactions in CartCompletionStrategy phases diff --git a/packages/medusa/src/api/routes/store/carts/complete-cart.ts b/packages/medusa/src/api/routes/store/carts/complete-cart.ts index 8d422e486a..9362885efd 100644 --- a/packages/medusa/src/api/routes/store/carts/complete-cart.ts +++ b/packages/medusa/src/api/routes/store/carts/complete-cart.ts @@ -1,5 +1,5 @@ import { EntityManager } from "typeorm"; -import { ICartCompletionStrategy } from "../../../../interfaces" +import { AbstractCartCompletionStrategy } from "../../../../interfaces" import { IdempotencyKey } from "../../../../models/idempotency-key" import { IdempotencyKeyService } from "../../../../services" @@ -54,6 +54,7 @@ import { IdempotencyKeyService } from "../../../../services" export default async (req, res) => { const { id } = req.params + const manager: EntityManager = req.scope.resolve("manager") const idempotencyKeyService: IdempotencyKeyService = req.scope.resolve( "idempotencyKeyService" ) @@ -62,7 +63,6 @@ export default async (req, res) => { let idempotencyKey: IdempotencyKey try { - const manager: EntityManager = req.scope.resolve("manager") idempotencyKey = await manager.transaction(async (transactionManager) => { return await idempotencyKeyService.withTransaction(transactionManager).initializeRequest( headerKey, @@ -80,7 +80,7 @@ export default async (req, res) => { res.setHeader("Access-Control-Expose-Headers", "Idempotency-Key") res.setHeader("Idempotency-Key", idempotencyKey.idempotency_key) - const completionStrat: ICartCompletionStrategy = req.scope.resolve( + const completionStrat: AbstractCartCompletionStrategy = req.scope.resolve( "cartCompletionStrategy" ) diff --git a/packages/medusa/src/interfaces/cart-completion-strategy.ts b/packages/medusa/src/interfaces/cart-completion-strategy.ts index f819a79c6d..c28dc849be 100644 --- a/packages/medusa/src/interfaces/cart-completion-strategy.ts +++ b/packages/medusa/src/interfaces/cart-completion-strategy.ts @@ -1,5 +1,6 @@ -import { IdempotencyKey } from "../models/idempotency-key" +import { IdempotencyKey } from "../models" import { RequestContext } from "../types/request" +import { TransactionBaseService } from "./transaction-base-service" export type CartCompletionResponse = { /** The response code for the completion request */ @@ -25,9 +26,19 @@ export interface ICartCompletionStrategy { ): Promise } -export function isCartCompletionStrategy( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - object: any -): object is ICartCompletionStrategy { - return typeof object.complete === "function" +export abstract class AbstractCartCompletionStrategy + implements ICartCompletionStrategy +{ + abstract complete( + cartId: string, + idempotencyKey: IdempotencyKey, + context: RequestContext + ): Promise +} + +export function isCartCompletionStrategy(obj: unknown): boolean { + return ( + typeof (obj as AbstractCartCompletionStrategy).complete === "function" || + obj instanceof AbstractCartCompletionStrategy + ) } diff --git a/packages/medusa/src/loaders/plugins.ts b/packages/medusa/src/loaders/plugins.ts index 182c287f78..98ecaf8ad4 100644 --- a/packages/medusa/src/loaders/plugins.ts +++ b/packages/medusa/src/loaders/plugins.ts @@ -17,6 +17,7 @@ import { EntitySchema } from "typeorm" import { AbstractTaxService, isBatchJobStrategy, + isCartCompletionStrategy, isFileService, isNotificationService, isPriceSelectionStrategy, @@ -184,6 +185,22 @@ export function registerStrategies( break } + case isCartCompletionStrategy(module.prototype): { + if (!("cartCompletionStrategy" in registeredServices)) { + container.register({ + cartCompletionStrategy: asFunction( + (cradle) => new module(cradle, pluginDetails.options) + ).singleton(), + }) + registeredServices["cartCompletionStrategy"] = file + } else { + logger.warn( + `Cannot register ${file}. A cart completion strategy is already registered` + ) + } + break + } + case isBatchJobStrategy(module.prototype): { container.registerAdd( "batchJobStrategies", diff --git a/packages/medusa/src/strategies/__tests__/cart-completion.js b/packages/medusa/src/strategies/__tests__/cart-completion.js index 328ac2b1e9..492281b5e9 100644 --- a/packages/medusa/src/strategies/__tests__/cart-completion.js +++ b/packages/medusa/src/strategies/__tests__/cart-completion.js @@ -205,6 +205,7 @@ describe("CartCompletionStrategy", () => { idempotencyKeyService: idempotencyKeyServiceMock, orderService: orderServiceMock, swapService: swapServiceMock, + manager: MockManager }) const val = await completionStrat.complete(cart.id, idempotencyKey, {}) diff --git a/packages/medusa/src/strategies/cart-completion.ts b/packages/medusa/src/strategies/cart-completion.ts index 1ffc99a066..293ed4a6eb 100644 --- a/packages/medusa/src/strategies/cart-completion.ts +++ b/packages/medusa/src/strategies/cart-completion.ts @@ -1,32 +1,48 @@ import { EntityManager } from "typeorm" import { MedusaError } from "medusa-core-utils" -import { IdempotencyKey } from "../models/idempotency-key" -import { Order } from "../models/order" +import { IdempotencyKey, Order } from "../models" import CartService from "../services/cart" import { RequestContext } from "../types/request" import OrderService from "../services/order" import IdempotencyKeyService from "../services/idempotency-key" import SwapService from "../services/swap" -import { ICartCompletionStrategy, CartCompletionResponse } from "../interfaces" +import { + CartCompletionResponse, + AbstractCartCompletionStrategy, +} from "../interfaces" -class CartCompletionStrategy implements ICartCompletionStrategy { - private idempotencyKeyService_: IdempotencyKeyService - private cartService_: CartService - private orderService_: OrderService - private swapService_: SwapService +type InjectedDependencies = { + idempotencyKeyService: IdempotencyKeyService + cartService: CartService + orderService: OrderService + swapService: SwapService + manager: EntityManager +} + +class CartCompletionStrategy extends AbstractCartCompletionStrategy { + protected manager_: EntityManager + + protected readonly idempotencyKeyService_: IdempotencyKeyService + protected readonly cartService_: CartService + protected readonly orderService_: OrderService + protected readonly swapService_: SwapService constructor({ idempotencyKeyService, cartService, orderService, swapService, - }) { + manager, + }: InjectedDependencies) { + super() + this.idempotencyKeyService_ = idempotencyKeyService this.cartService_ = cartService this.orderService_ = orderService this.swapService_ = swapService + this.manager_ = manager } async complete( @@ -47,159 +63,211 @@ class CartCompletionStrategy implements ICartCompletionStrategy { while (inProgress) { switch (idempotencyKey.recovery_point) { case "started": { - const { key, error } = await idempotencyKeyService.workStage( - idempotencyKey.idempotency_key, - async (manager: EntityManager) => { - const cart = await cartService - .withTransaction(manager) - .retrieve(id) - - if (cart.completed_at) { - return { - response_code: 409, - response_body: { - code: MedusaError.Codes.CART_INCOMPATIBLE_STATE, - message: "Cart has already been completed", - type: MedusaError.Types.NOT_ALLOWED, - }, - } - } - - await cartService.withTransaction(manager).createTaxLines(id) - - return { - recovery_point: "tax_lines_created", - } - } - ) - - if (error) { - inProgress = false - err = error - } else { - idempotencyKey = key - } - break - } - case "tax_lines_created": { - const { key, error } = await idempotencyKeyService.workStage( - idempotencyKey.idempotency_key, - async (manager: EntityManager) => { - const cart = await cartService - .withTransaction(manager) - .authorizePayment(id, { - ...context, - idempotency_key: idempotencyKey.idempotency_key, - }) - - if (cart.payment_session) { - if ( - cart.payment_session.status === "requires_more" || - cart.payment_session.status === "pending" - ) { - return { - response_code: 200, - response_body: { - data: cart, - payment_status: cart.payment_session.status, - type: "cart", - }, - } - } - } - - return { - recovery_point: "payment_authorized", - } - } - ) - - if (error) { - inProgress = false - err = error - } else { - idempotencyKey = key - } - break - } - - case "payment_authorized": { - const { key, error } = await idempotencyKeyService.workStage( - idempotencyKey.idempotency_key, - async (manager: EntityManager) => { - const cart = await cartService - .withTransaction(manager) - .retrieve(id, { - select: ["total"], - relations: ["payment", "payment_sessions"], - }) - - // If cart is part of swap, we register swap as complete - switch (cart.type) { - case "swap": { - try { - const swapId = cart.metadata?.swap_id - let swap = await swapService - .withTransaction(manager) - .registerCartCompletion(swapId as string) - - swap = await swapService - .withTransaction(manager) - .retrieve(swap.id, { relations: ["shipping_address"] }) + await this.manager_.transaction(async (transactionManager) => { + const { key, error } = await idempotencyKeyService + .withTransaction(transactionManager) + .workStage( + idempotencyKey.idempotency_key, + async (manager: EntityManager) => { + const cart = await cartService + .withTransaction(manager) + .retrieve(id) + if (cart.completed_at) { return { - response_code: 200, - response_body: { data: swap, type: "swap" }, - } - } catch (error) { - if ( - error && - error.code === MedusaError.Codes.INSUFFICIENT_INVENTORY - ) { - return { - response_code: 409, - response_body: { - message: error.message, - type: error.type, - code: error.code, - }, - } - } else { - throw error - } - } - } - // case "payment_link": - default: { - if (typeof cart.total === "undefined") { - return { - response_code: 500, + response_code: 409, response_body: { - message: "Unexpected state", + code: MedusaError.Codes.CART_INCOMPATIBLE_STATE, + message: "Cart has already been completed", + type: MedusaError.Types.NOT_ALLOWED, }, } } - if (!cart.payment && cart.total > 0) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Cart payment not authorized` - ) + await cartService.withTransaction(manager).createTaxLines(id) + + return { + recovery_point: "tax_lines_created", + } + } + ) + + if (error) { + inProgress = false + err = error + } else { + idempotencyKey = key + } + }) + break + } + case "tax_lines_created": { + await this.manager_.transaction(async (transactionManager) => { + const { key, error } = await idempotencyKeyService + .withTransaction(transactionManager) + .workStage( + idempotencyKey.idempotency_key, + async (manager: EntityManager) => { + const cart = await cartService + .withTransaction(manager) + .authorizePayment(id, { + ...context, + idempotency_key: idempotencyKey.idempotency_key, + }) + + if (cart.payment_session) { + if ( + cart.payment_session.status === "requires_more" || + cart.payment_session.status === "pending" + ) { + return { + response_code: 200, + response_body: { + data: cart, + payment_status: cart.payment_session.status, + type: "cart", + }, + } + } } - let order: Order - try { - order = await orderService - .withTransaction(manager) - .createFromCart(cart.id) - } catch (error) { - if ( - error && - error.message === "Order from cart already exists" - ) { + return { + recovery_point: "payment_authorized", + } + } + ) + + if (error) { + inProgress = false + err = error + } else { + idempotencyKey = key + } + }) + break + } + + case "payment_authorized": { + await this.manager_.transaction(async (transactionManager) => { + const { key, error } = await idempotencyKeyService + .withTransaction(transactionManager) + .workStage( + idempotencyKey.idempotency_key, + async (manager: EntityManager) => { + const cart = await cartService + .withTransaction(manager) + .retrieve(id, { + select: ["total"], + relations: ["payment", "payment_sessions"], + }) + + // If cart is part of swap, we register swap as complete + switch (cart.type) { + case "swap": { + try { + const swapId = cart.metadata?.swap_id + let swap = await swapService + .withTransaction(manager) + .registerCartCompletion(swapId as string) + + swap = await swapService + .withTransaction(manager) + .retrieve(swap.id, { + relations: ["shipping_address"], + }) + + return { + response_code: 200, + response_body: { data: swap, type: "swap" }, + } + } catch (error) { + if ( + error && + error.code === + MedusaError.Codes.INSUFFICIENT_INVENTORY + ) { + return { + response_code: 409, + response_body: { + message: error.message, + type: error.type, + code: error.code, + }, + } + } else { + throw error + } + } + } + default: { + if (typeof cart.total === "undefined") { + return { + response_code: 500, + response_body: { + message: "Unexpected state", + }, + } + } + + if (!cart.payment && cart.total > 0) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cart payment not authorized` + ) + } + + let order: Order + try { + order = await orderService + .withTransaction(manager) + .createFromCart(cart.id) + } catch (error) { + if ( + error && + error.message === "Order from cart already exists" + ) { + order = await orderService + .withTransaction(manager) + .retrieveByCartId(id, { + select: [ + "subtotal", + "tax_total", + "shipping_total", + "discount_total", + "total", + ], + relations: [ + "shipping_address", + "items", + "payments", + ], + }) + + return { + response_code: 200, + response_body: { data: order, type: "order" }, + } + } else if ( + error && + error.code === + MedusaError.Codes.INSUFFICIENT_INVENTORY + ) { + return { + response_code: 409, + response_body: { + message: error.message, + type: error.type, + code: error.code, + }, + } + } else { + throw error + } + } + order = await orderService .withTransaction(manager) - .retrieveByCartId(id, { + .retrieve(order.id, { select: [ "subtotal", "tax_total", @@ -214,51 +282,18 @@ class CartCompletionStrategy implements ICartCompletionStrategy { response_code: 200, response_body: { data: order, type: "order" }, } - } else if ( - error && - error.code === MedusaError.Codes.INSUFFICIENT_INVENTORY - ) { - return { - response_code: 409, - response_body: { - message: error.message, - type: error.type, - code: error.code, - }, - } - } else { - throw error } } - - order = await orderService - .withTransaction(manager) - .retrieve(order.id, { - select: [ - "subtotal", - "tax_total", - "shipping_total", - "discount_total", - "total", - ], - relations: ["shipping_address", "items", "payments"], - }) - - return { - response_code: 200, - response_body: { data: order, type: "order" }, - } } - } - } - ) + ) - if (error) { - inProgress = false - err = error - } else { - idempotencyKey = key - } + if (error) { + inProgress = false + err = error + } else { + idempotencyKey = key + } + }) break } @@ -268,14 +303,15 @@ class CartCompletionStrategy implements ICartCompletionStrategy { } default: - idempotencyKey = await idempotencyKeyService.update( - idempotencyKey.idempotency_key, - { - recovery_point: "finished", - response_code: 500, - response_body: { message: "Unknown recovery point" }, - } - ) + await this.manager_.transaction(async (transactionManager) => { + idempotencyKey = await idempotencyKeyService + .withTransaction(transactionManager) + .update(idempotencyKey.idempotency_key, { + recovery_point: "finished", + response_code: 500, + response_body: { message: "Unknown recovery point" }, + }) + }) break } }