diff --git a/.changeset/proud-dancers-film.md b/.changeset/proud-dancers-film.md new file mode 100644 index 0000000000..04cd3be3a2 --- /dev/null +++ b/.changeset/proud-dancers-film.md @@ -0,0 +1,7 @@ +--- +"@medusajs/workflows-sdk": patch +"@medusajs/core-flows": patch +"@medusajs/utils": patch +--- + +fix(core-flows): use cart_id to complete cart on payment webhook and lock cart before completion diff --git a/integration-tests/modules/__tests__/cart/store/cart.completion.ts b/integration-tests/modules/__tests__/cart/store/cart.completion.ts index 3e1dc363ff..13bacf9e77 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.completion.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.completion.ts @@ -747,6 +747,135 @@ medusaIntegrationTestRunner({ paymentSession.id ) }) + it("should complete cart when payment webhook and storefront are called in simultaneously", async () => { + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const location = await stockLocationModule.createStockLocations({ + name: "Warehouse", + }) + + const [product] = await productModule.createProducts([ + { + title: "Test product", + variants: [ + { + title: "Test variant", + manage_inventory: false, + }, + ], + }, + ]) + + const priceSet = await pricingModule.createPriceSets({ + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }) + + await pricingModule.createPricePreferences({ + attribute: "currency_code", + value: "usd", + has_tax_inclusive: true, + }) + + await remoteLink.create([ + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.PRICING]: { + price_set_id: priceSet.id, + }, + }, + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + ]) + + // create cart + const cart = await cartModuleService.createCarts({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + }) + + await addToCartWorkflow(appContainer).run({ + input: { + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + requires_shipping: false, + }, + ], + cart_id: cart.id, + }, + }) + + await createPaymentCollectionForCartWorkflow(appContainer).run({ + input: { + cart_id: cart.id, + }, + }) + + const [payCol] = await remoteQuery( + remoteQueryObjectFromString({ + entryPoint: "cart_payment_collection", + variables: { filters: { cart_id: cart.id } }, + fields: ["payment_collection_id"], + }) + ) + + const { result: paymentSession } = + await createPaymentSessionsWorkflow(appContainer).run({ + input: { + payment_collection_id: payCol.payment_collection_id, + provider_id: "pp_system_default", + context: {}, + data: {}, + }, + }) + + const [{ result: order }] = await Promise.all([ + completeCartWorkflow(appContainer).run({ + input: { + id: cart.id, + }, + }), + processPaymentWorkflow(appContainer).run({ + input: { + action: "captured", + data: { + session_id: paymentSession.id, + amount: 3000, + }, + }, + }), + ]) + + const { result: fullOrder } = await getOrderDetailWorkflow( + appContainer + ).run({ + input: { + fields: ["*"], + order_id: order.id, + }, + }) + + expect(fullOrder.payment_status).toBe("captured") + expect(fullOrder.payment_collections[0].authorized_amount).toBe(3000) + expect(fullOrder.payment_collections[0].captured_amount).toBe(3000) + expect(fullOrder.payment_collections[0].status).toBe("completed") + }) }) }) }, diff --git a/packages/core/core-flows/src/cart/steps/update-carts.ts b/packages/core/core-flows/src/cart/steps/update-carts.ts index 82e0190024..2d5962e615 100644 --- a/packages/core/core-flows/src/cart/steps/update-carts.ts +++ b/packages/core/core-flows/src/cart/steps/update-carts.ts @@ -29,7 +29,18 @@ export const updateCartsStep = createStep( async (data: UpdateCartsStepInput, { container }) => { const cartModule = container.resolve(Modules.CART) - const { selects, relations } = getSelectsAndRelationsFromObjectArray(data) + const { selects, relations } = getSelectsAndRelationsFromObjectArray(data, { + requiredFields: [ + "id", + "region_id", + "customer_id", + "sales_channel_id", + "email", + "currency_code", + "metadata", + "completed_at", + ], + }) const cartsBeforeUpdate = await cartModule.listCarts( { id: data.map((d) => d.id) }, { select: selects, relations } diff --git a/packages/core/core-flows/src/cart/workflows/complete-cart.ts b/packages/core/core-flows/src/cart/workflows/complete-cart.ts index 064ad730bd..6eb9b7b6b2 100644 --- a/packages/core/core-flows/src/cart/workflows/complete-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/complete-cart.ts @@ -24,6 +24,8 @@ import { useQueryGraphStep, useRemoteQueryStep, } from "../../common" +import { acquireLockStep } from "../../locking/acquire-lock" +import { releaseLockStep } from "../../locking/release-lock" import { addOrderTransactionStep } from "../../order/steps/add-order-transaction" import { createOrdersStep } from "../../order/steps/create-orders" import { authorizePaymentSessionStep } from "../../payment/steps/authorize-payment-session" @@ -60,7 +62,9 @@ export type CompleteCartWorkflowOutput = { id: string } -export const THREE_DAYS = 60 * 60 * 24 * 3 +const THREE_DAYS = 60 * 60 * 24 * 3 +const THIRTY_SECONDS = 30 +const TWO_MINUTES = 60 * 2 export const completeCartWorkflowId = "complete-cart" /** @@ -93,6 +97,12 @@ export const completeCartWorkflow = createWorkflow( retentionTime: THREE_DAYS, }, (input: WorkflowData) => { + acquireLockStep({ + key: input.id, + timeout: THIRTY_SECONDS, + ttl: TWO_MINUTES, + }) + const orderCart = useQueryGraphStep({ entity: "order_cart", fields: ["cart_id", "order_id"], @@ -381,6 +391,10 @@ export const completeCartWorkflow = createWorkflow( return createdOrder }) + releaseLockStep({ + key: input.id, + }) + const result = transform({ order, orderId }, ({ order, orderId }) => { return { id: order?.id ?? orderId } as CompleteCartWorkflowOutput }) diff --git a/packages/core/core-flows/src/index.ts b/packages/core/core-flows/src/index.ts index f28e17b9e7..c10412e9d7 100644 --- a/packages/core/core-flows/src/index.ts +++ b/packages/core/core-flows/src/index.ts @@ -11,6 +11,7 @@ export * from "./fulfillment" export * from "./inventory" export * from "./invite" export * from "./line-item" +export * from "./locking" export * from "./notification" export * from "./order" export * from "./payment" diff --git a/packages/core/core-flows/src/locking/acquire-lock.ts b/packages/core/core-flows/src/locking/acquire-lock.ts new file mode 100644 index 0000000000..fced4e951a --- /dev/null +++ b/packages/core/core-flows/src/locking/acquire-lock.ts @@ -0,0 +1,82 @@ +import { isDefined, Modules } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { setTimeout } from "timers/promises" + +/** + * The keys to be locked + */ +export interface AcquireLockStepInput { + key: string | string[] + timeout?: number // in seconds. Defaults to 0 + retryInterval?: number // in seconds. Defaults to 0.3 + ttl?: number // in seconds + ownerId?: string + provider?: string +} + +export const acquireLockStepId = "acquire-lock-step" +/** + * This step acquires a lock for a given key. + * + * @example + * const data = acquireLockStep({ + * "key": "my-lock-key", + * "ttl": 60 + * }) + */ +export const acquireLockStep = createStep( + acquireLockStepId, + async (data: AcquireLockStepInput, { container }) => { + const keys = Array.isArray(data.key) + ? data.key + : isDefined(data.key) + ? [data.key] + : [] + + if (!keys.length) { + return new StepResponse(void 0) + } + + const locking = container.resolve(Modules.LOCKING) + + const retryInterval = data.retryInterval ?? 0.3 + const tryUntil = Date.now() + (data.timeout ?? 0) * 1000 + + while (true) { + try { + await locking.acquire(data.key, { + expire: data.ttl, + ownerId: data.ownerId, + provider: data.provider, + }) + break + } catch (e) { + if (Date.now() >= tryUntil) { + throw e + } + } + + await setTimeout(retryInterval * 1000) + } + + return new StepResponse(void 0, { + keys, + ownerId: data.ownerId, + provider: data.provider, + }) + }, + async (data, { container }) => { + if (!data?.keys?.length) { + return + } + + const locking = container.resolve(Modules.LOCKING) + + await locking.release(data.keys, { + ownerId: data.ownerId, + provider: data.provider, + }) + + return new StepResponse() + } +) diff --git a/packages/core/core-flows/src/locking/index.ts b/packages/core/core-flows/src/locking/index.ts new file mode 100644 index 0000000000..f2ab39f887 --- /dev/null +++ b/packages/core/core-flows/src/locking/index.ts @@ -0,0 +1,2 @@ +export * from "./acquire-lock" +export * from "./release-lock" diff --git a/packages/core/core-flows/src/locking/release-lock.ts b/packages/core/core-flows/src/locking/release-lock.ts new file mode 100644 index 0000000000..3670aa0c0a --- /dev/null +++ b/packages/core/core-flows/src/locking/release-lock.ts @@ -0,0 +1,43 @@ +import { isDefined, Modules } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" + +/** + * The locked keys to be released + */ +export interface ReleaseLockStepInput { + key: string | string[] + ownerId?: string + provider?: string +} + +export const releaseLockStepId = "release-lock-step" +/** + * This step releases a lock for a given key. + * + * @example + * const data = releaseLockStep({ + * "key": "my-lock-key" + * }) + */ +export const releaseLockStep = createStep( + releaseLockStepId, + async (data: ReleaseLockStepInput, { container }) => { + const keys = Array.isArray(data.key) + ? data.key + : isDefined(data.key) + ? [data.key] + : [] + + if (!keys.length) { + return new StepResponse(true) + } + + const locking = container.resolve(Modules.LOCKING) + const released = await locking.release(keys, { + ownerId: data.ownerId, + provider: data.provider, + }) + + return new StepResponse(released) + } +) diff --git a/packages/core/core-flows/src/payment/steps/complete-cart-after-payment.ts b/packages/core/core-flows/src/payment/steps/complete-cart-after-payment.ts new file mode 100644 index 0000000000..3ee30c94ff --- /dev/null +++ b/packages/core/core-flows/src/payment/steps/complete-cart-after-payment.ts @@ -0,0 +1,31 @@ +import { Modules } from "@medusajs/framework/utils" +import { createStep } from "@medusajs/framework/workflows-sdk" +import { completeCartWorkflowId } from "../../cart/workflows/complete-cart" + +/** + * The data to complete a cart after a payment is captured. + */ +export type CompleteCartAfterPaymentStepInput = { + /** + * The ID of the cart to complete. + */ + cart_id: string +} + +export const completeCartAfterPaymentStepId = "complete-cart-after-payment-step" +/** + * This step completes a cart after a payment is captured. + */ +export const completeCartAfterPaymentStep = createStep( + completeCartAfterPaymentStepId, + async (input: CompleteCartAfterPaymentStepInput, { container }) => { + const workflowEngine = container.resolve(Modules.WORKFLOW_ENGINE) + + await workflowEngine.run(completeCartWorkflowId, { + input: { + id: input.cart_id, + }, + transactionId: input.cart_id, + }) + } +) diff --git a/packages/core/core-flows/src/payment/workflows/process-payment.ts b/packages/core/core-flows/src/payment/workflows/process-payment.ts index c188f726c6..7c8e97943a 100644 --- a/packages/core/core-flows/src/payment/workflows/process-payment.ts +++ b/packages/core/core-flows/src/payment/workflows/process-payment.ts @@ -1,9 +1,9 @@ import { WebhookActionResult } from "@medusajs/types" import { PaymentActions } from "@medusajs/utils" import { createWorkflow, when } from "@medusajs/workflows-sdk" -import { completeCartWorkflow } from "../../cart/workflows/complete-cart" import { useQueryGraphStep } from "../../common" import { authorizePaymentSessionStep } from "../steps" +import { completeCartAfterPaymentStep } from "../steps/complete-cart-after-payment" import { capturePaymentWorkflow } from "./capture-payment" /** @@ -131,13 +131,11 @@ export const processPaymentWorkflow = createWorkflow( when({ cartPaymentCollection }, ({ cartPaymentCollection }) => { return !!cartPaymentCollection.data.length }).then(() => { - completeCartWorkflow - .runAsStep({ - input: { id: cartPaymentCollection.data[0].cart_id }, - }) - .config({ - continueOnPermanentFailure: true, // Continue payment processing even if cart completion fails - }) + completeCartAfterPaymentStep({ + cart_id: cartPaymentCollection.data[0].cart_id, + }).config({ + continueOnPermanentFailure: true, // Continue payment processing even if cart completion fails + }) }) } ) diff --git a/packages/core/utils/src/common/__tests__/get-selects-and-relations-from-object-array.spec.ts b/packages/core/utils/src/common/__tests__/get-selects-and-relations-from-object-array.spec.ts index 8c9b50574d..0c3f353693 100644 --- a/packages/core/utils/src/common/__tests__/get-selects-and-relations-from-object-array.spec.ts +++ b/packages/core/utils/src/common/__tests__/get-selects-and-relations-from-object-array.spec.ts @@ -40,7 +40,7 @@ describe("getSelectsAndRelationsFromObjectArray", function () { }, ], output: { - selects: [ + selects: expect.arrayContaining([ "attr_string", "attr_boolean", "attr_null", @@ -55,16 +55,19 @@ describe("getSelectsAndRelationsFromObjectArray", function () { "attr_array.attr_object.attr_boolean", "attr_array.attr_object.attr_null", "attr_array.attr_object.attr_undefined", - ], + "required_attr", + ]), relations: ["attr_object", "attr_array", "attr_array.attr_object"], }, }, ] expectations.forEach((expectation) => { - expect(getSelectsAndRelationsFromObjectArray(expectation.input)).toEqual( - expectation.output - ) + expect( + getSelectsAndRelationsFromObjectArray(expectation.input, { + requiredFields: ["required_attr"], + }) + ).toEqual(expectation.output) }) }) }) diff --git a/packages/core/utils/src/common/get-selects-and-relations-from-object-array.ts b/packages/core/utils/src/common/get-selects-and-relations-from-object-array.ts index efac3a864d..e327167405 100644 --- a/packages/core/utils/src/common/get-selects-and-relations-from-object-array.ts +++ b/packages/core/utils/src/common/get-selects-and-relations-from-object-array.ts @@ -5,8 +5,9 @@ const KEYS_THAT_ARE_NOT_RELATIONS = ["metadata"] export function getSelectsAndRelationsFromObjectArray( dataArray: object[], - options: { objectFields: string[] } = { - objectFields: [], + options?: { + objectFields?: string[] + requiredFields?: string[] }, prefix?: string ): { @@ -15,10 +16,11 @@ export function getSelectsAndRelationsFromObjectArray( } { const selects: string[] = [] const relations: string[] = [] + const { objectFields, requiredFields } = options ?? {} for (const data of dataArray) { for (const [key, value] of Object.entries(data)) { - if (isObject(value) && !options.objectFields.includes(key)) { + if (isObject(value) && !objectFields?.includes(key)) { const res = getSelectsAndRelationsFromObjectArray( [value], options, @@ -48,7 +50,10 @@ export function getSelectsAndRelationsFromObjectArray( } } - const uniqueSelects: string[] = deduplicate(selects) + const uniqueSelects: string[] = deduplicate([ + ...selects, + ...(requiredFields ?? []), + ]) const uniqueRelations: string[] = deduplicate(relations) return { diff --git a/packages/core/workflows-sdk/src/utils/composer/create-hook.ts b/packages/core/workflows-sdk/src/utils/composer/create-hook.ts index 93e23f38fa..a188635880 100644 --- a/packages/core/workflows-sdk/src/utils/composer/create-hook.ts +++ b/packages/core/workflows-sdk/src/utils/composer/create-hook.ts @@ -116,7 +116,7 @@ export function createHook( stepName: name, input: hookInput, invokeFn, - compensateFn, + compensateFn: compensateFn ?? (() => void 0), }) if (this.hooks_.registered.includes(name)) { diff --git a/packages/core/workflows-sdk/src/utils/composer/helpers/resolve-value.ts b/packages/core/workflows-sdk/src/utils/composer/helpers/resolve-value.ts index 4d27c2c6c8..381e826868 100644 --- a/packages/core/workflows-sdk/src/utils/composer/helpers/resolve-value.ts +++ b/packages/core/workflows-sdk/src/utils/composer/helpers/resolve-value.ts @@ -1,4 +1,9 @@ -import { deepCopy, OrchestrationUtils, promiseAll } from "@medusajs/utils" +import { + deepCopy, + isDefined, + OrchestrationUtils, + promiseAll, +} from "@medusajs/utils" async function resolveProperty(property, transactionContext) { const { invoke: invokeRes } = transactionContext @@ -78,5 +83,8 @@ export async function resolveValue(input, transactionContext) { ? await resolveProperty(copiedInput, transactionContext) : await unwrapInput(copiedInput, {}) - return result && JSON.parse(JSON.stringify(result)) + const strResult = JSON.stringify(result) // Symbols return undefined + if (isDefined(strResult)) { + return JSON.parse(strResult) + } }