docs: update recipes and tutorials to support locks and idempotency (#14151)

This commit is contained in:
Shahed Nasser authored and GitHub committed 2025-12-01 09:01:25 +02:00
1 parent bbf294fc31
commit 1e2f40b623
14 files changed
+1176 -396

No files matched your search

@@ -1752,21 +1752,35 @@ The add-to-cart workflow for bundled products has the following steps:
name: "prepareBundleCartDataStep",
type: "step",
description: "Validate and prepare the items to be added to the cart.",
depth: 1,
depth: 2,
},
{
name: "acquireLockStep",
type: "step",
description: "Acquire a lock on the cart to prevent concurrent modifications",
depth: 3,
link: "/references/medusa-workflows/steps/acquireLockStep"
},
{
name: "addToCartWorkflow",
type: "step",
description: "Add the items in the bundle to the cart.",
depth: 1,
depth: 4,
link: "/references/medusa-workflows/addToCartWorkflow"
},
{
name: "useQueryGraphStep",
type: "step",
description: "Retrieve the details of the cart.",
depth: 1,
depth: 5,
link: "/references/helper-steps/useQueryGraphStep"
},
{
name: "releaseLockStep",
type: "step",
description: "Release the lock on the cart.",
depth: 6,
link: "/references/medusa-workflows/steps/releaseLockStep"
}
]
}}
@@ -1889,20 +1903,24 @@ You can now create the workflow with the custom add-to-cart logic.
To create the workflow, create the file `src/workflows/add-bundle-to-cart.ts` with the following content:
export const addBundleToCartWorkflowHighlights = [
["28", "useQueryGraphStep", "Retrieve the bundle, its items, and their products and variants."],
["44", "prepareBundleCartDataStep", "Validate and prepare the items to be added to the cart."],
["50", "addToCartWorkflow", "Add the items in the bundle to the cart."],
["57", "useQueryGraphStep", "Retrieve the updated cart."],
["30", "useQueryGraphStep", "Retrieve the bundle, its items, and their products and variants."],
["46", "prepareBundleCartDataStep", "Validate and prepare the items to be added to the cart."],
["52", "acquireLockStep", "Acquire a lock on the cart to prevent concurrent modifications."],
["58", "addToCartWorkflow", "Add the items in the bundle to the cart."],
["65", "useQueryGraphStep", "Retrieve the updated cart."],
["71", "releaseLockStep", "Release the lock on the cart."],
]
```ts title="src/workflows/add-bundle-to-cart.ts" highlights={addBundleToCartWorkflowHighlights} collapsibleLines="1-14" expandButtonLabel="Show Imports"
```ts title="src/workflows/add-bundle-to-cart.ts" highlights={addBundleToCartWorkflowHighlights} collapsibleLines="1-16" expandButtonLabel="Show Imports"
import {
createWorkflow,
transform,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import {
addToCartWorkflow,
acquireLockStep,
addToCartWorkflow,
releaseLockStep,
useQueryGraphStep,
} from "@medusajs/medusa/core-flows"
import {
@@ -1945,6 +1963,12 @@ export const addBundleToCartWorkflow = createWorkflow(
items,
} as unknown as PrepareBundleCartDataStepInput)
acquireLockStep({
key: cart_id,
timeout: 2,
ttl: 10,
})
addToCartWorkflow.runAsStep({
input: {
cart_id,
@@ -1958,6 +1982,10 @@ export const addBundleToCartWorkflow = createWorkflow(
fields: ["id", "items.*"],
}).config({ name: "refetch-cart" })
releaseLockStep({
key: cart_id,
})
return new WorkflowResponse(updatedCarts[0])
}
)
@@ -1969,8 +1997,10 @@ In the workflow, you:
- Retrieve the bundle, its items, and their products and variants using the `useQueryGraphStep`.
- Validate and prepare the items to be added to the cart using the `prepareBundleCartDataStep`.
- Acquire a lock on the cart using the `acquireLockStep`.
- Add the items to the cart using the `addToCartWorkflow`.
- Retrieve the updated cart using the `useQueryGraphStep`.
- Release the lock on the cart using the `releaseLockStep`.
Finally, you return the updated cart.
@@ -2774,20 +2804,34 @@ The workflow has the following steps:
depth: 1,
link: "/references/helper-steps/useQueryGraphStep"
},
{
name: "acquireLockStep",
type: "step",
description: "Acquire a lock on the cart to prevent concurrent modifications.",
depth: 2,
link: "/references/medusa-workflows/steps/acquireLockStep"
},
{
name: "deleteLineItemsWorkflow",
type: "workflow",
description: "Remove the items in the bundle from the cart.",
depth: 1,
depth: 3,
link: "/references/medusa-workflows/deleteLineItemsWorkflow"
},
{
name: "useQueryGraphStep",
type: "step",
description: "Retrieve the updated cart.",
depth: 1,
depth: 4,
link: "/references/helper-steps/useQueryGraphStep"
}
},
{
name: "releaseLockStep",
type: "step",
description: "Release the lock on the cart.",
depth: 5,
link: "/references/medusa-workflows/steps/releaseLockStep"
},
]
}}
hideLegend
@@ -2798,20 +2842,24 @@ Medusa provides all these steps and workflows in its `@medusajs/medusa/core-flow
Create the file `src/workflows/remove-bundle-from-cart.ts` with the following content:
export const removeBundleFromCartWorkflowHighlights = [
["19", "useQueryGraphStep", "Retrieve the details of the cart and its items."],
["33", "transform", "Prepare the IDs of the items to remove."],
["42", "deleteLineItemsWorkflow", "Remove the items in the bundle from the cart."],
["50", "useQueryGraphStep", "Retrieve the updated cart."],
["21", "useQueryGraphStep", "Retrieve the details of the cart and its items."],
["35", "transform", "Prepare the IDs of the items to remove."],
["44", "acquireLockStep", "Acquire a lock on the cart to prevent concurrent modifications."],
["50", "deleteLineItemsWorkflow", "Remove the items in the bundle from the cart."],
["58", "useQueryGraphStep", "Retrieve the updated cart."],
["69", "releaseLockStep", "Release the lock on the cart."],
]
```ts title="src/workflows/remove-bundle-from-cart.ts" collapsibleLines="1-10" expandButtonLabel="Show Imports" highlights={removeBundleFromCartWorkflowHighlights}
```ts title="src/workflows/remove-bundle-from-cart.ts" collapsibleLines="1-12" expandButtonLabel="Show Imports" highlights={removeBundleFromCartWorkflowHighlights}
import {
createWorkflow,
transform,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import {
acquireLockStep,
deleteLineItemsWorkflow,
releaseLockStep,
useQueryGraphStep,
} from "@medusajs/medusa/core-flows"
@@ -2846,6 +2894,12 @@ export const removeBundleFromCartWorkflow = createWorkflow(
}).map((item) => item!.id)
})
acquireLockStep({
key: cart_id,
timeout: 2,
ttl: 10,
})
deleteLineItemsWorkflow.runAsStep({
input: {
cart_id,
@@ -2865,6 +2919,10 @@ export const removeBundleFromCartWorkflow = createWorkflow(
},
}).config({ name: "retrieve-cart" })
releaseLockStep({
key: cart_id,
})
return new WorkflowResponse(updatedCarts[0])
}
)
@@ -2876,8 +2934,10 @@ In the workflow, you:
- Retrieve the cart and its items using the `useQueryGraphStep`.
- Use `transform` to filter the items in the cart and return only the IDs of the items that belong to the bundle.
- Acquire a lock on the cart using the `acquireLockStep` to prevent concurrent modifications.
- Remove the items from the cart using the `deleteLineItemsWorkflow`.
- Retrieve the updated cart using the `useQueryGraphStep`.
- Release the lock on the cart using the `releaseLockStep`.
Finally, you return the updated cart.
@@ -1983,6 +1983,8 @@ import {
createRemoteLinkStep,
createOrderFulfillmentWorkflow,
emitEventStep,
acquireLockStep,
releaseLockStep,
} from "@medusajs/medusa/core-flows"
import {
Modules,
@@ -1991,6 +1993,7 @@ import createDigitalProductOrderStep, {
CreateDigitalProductOrderStepInput,
} from "./steps/create-digital-product-order"
import { DIGITAL_PRODUCT_MODULE } from "../../modules/digital-product"
import digitalProductOrderOrderLink from "../../links/digital-product-order"
type WorkflowInput = {
cart_id: string
@@ -1999,6 +2002,11 @@ type WorkflowInput = {
const createDigitalProductOrderWorkflow = createWorkflow(
"create-digital-product-order",
(input: WorkflowInput) => {
acquireLockStep({
key: input.cart_id,
timeout: 30,
ttl: 120,
})
const { id } = completeCartWorkflow.runAsStep({
input: {
id: input.cart_id,
@@ -2022,19 +2030,31 @@ const createDigitalProductOrderWorkflow = createWorkflow(
},
})
const itemsWithDigitalProducts = transform({
orders,
},
(data) => {
return data.orders[0].items?.filter((item) => item?.variant?.digital_product !== undefined)
const { data: existingLinks } = useQueryGraphStep({
entity: digitalProductOrderOrderLink.entryPoint,
fields: ["digital_product_order.id"],
filters: { order_id: id },
}).config({ name: "retrieve-existing-links" })
const itemsWithDigitalProducts = transform(
{
orders,
},
(data) => {
return data.orders[0].items?.filter(
(item) => item?.variant?.digital_product !== undefined
)
}
)
const digital_product_order = when(
"create-digital-product-order-condition",
itemsWithDigitalProducts,
(itemsWithDigitalProducts) => {
return !!itemsWithDigitalProducts?.length
"create-digital-product-order-condition",
{ itemsWithDigitalProducts, existingLinks },
(data) => {
return (
!!data.itemsWithDigitalProducts?.length &&
data.existingLinks.length === 0
)
}
).then(() => {
const {
@@ -2076,6 +2096,10 @@ const createDigitalProductOrderWorkflow = createWorkflow(
return digital_product_order
})
releaseLockStep({
key: input.cart_id,
})
return new WorkflowResponse({
order: orders[0],
digital_product_order,
@@ -2088,13 +2112,18 @@ export default createDigitalProductOrderWorkflow
This creates the workflow `createDigitalProductOrderWorkflow`. It runs the following steps:
1. `completeCartWorkflow` as a step to create the Medusa order.
2. `useQueryGraphStep` to retrieve the order’s items with their associated variants and linked digital products.
3. Use `when` to check whether the order has digital products. If so:
1. `acquireLockStep` to acquire a lock on the cart ID.
2. `completeCartWorkflow` as a step to create the Medusa order.
3. `useQueryGraphStep` to retrieve the order’s items with their associated variants and linked digital products.
4. `useQueryGraphStep` to retrieve any existing links between the digital product order and the Medusa order.
- This is necessary to ensure the workflow is idempotent, meaning that if the workflow is executed multiple times for the same cart, it doesn’t create multiple digital product orders.
5. Use `transform` to filter the order’s items to only those having digital products.
6. Use `when` to check whether the order has digital products and no existing links. If so:
1. Use the `createDigitalProductOrderStep` to create the digital product order.
2. Use the `createRemoteLinkStep` to link the digital product order to the Medusa order.
3. Use the `createOrderFulfillmentWorkflow` to create a fulfillment for the digital products in the order.
4. Use the `emitEventStep` to emit a custom event.
7. `releaseLockStep` to release the lock on the cart ID.
The workflow returns the Medusa order and the digital product order, if created.
@@ -1281,47 +1281,75 @@ In this step, you’ll create a workflow that’s executed when the customer pla
depth: 1,
link: "/references/helper-steps/useQueryGraphStep"
},
{
type: "step",
name: "acquireLockStep",
description: "Acquire a lock on the cart to avoid concurrent modifications.",
depth: 2,
link: "/references/medusa-workflows/steps/acquireLockStep"
},
{
type: "step",
name: "completeCartWorkflow",
description: "Create the parent order from the cart.",
depth: 1,
depth: 3,
link: "/references/medusa-workflows/completeCartWorkflow"
},
{
type: "step",
name: "groupVendorItemsStep",
description: "Group the items by their vendor.",
depth: 1,
link: "#groupvendoritemssstep"
name: "useQueryGraphStep",
description: "Retrieve existing links between the order and variant to ensure idempotency.",
depth: 4,
link: "/references/helper-steps/useQueryGraphStep"
},
{
type: "step",
type: "workflow",
name: "getOrderDetailWorkflow",
description: "Retrieve the parent order's details.",
depth: 1,
depth: 5,
link: "/references/medusa-workflows/getOrderDetailWorkflow"
},
{
type: "step",
name: "createVendorOrdersStep",
description: "Create child orders for each vendor.",
depth: 1,
link: "#createvendorordersstep"
type: "when",
condition: "existingLinks.length === 0",
steps: [
{
type: "step",
name: "groupVendorItemsStep",
description: "Group the items by their vendor.",
depth: 4,
link: "#groupvendoritemssstep"
},
{
type: "step",
name: "createVendorOrdersStep",
description: "Create child orders for each vendor.",
depth: 6,
link: "#createvendorordersstep"
},
{
type: "step",
name: "createRemoteLinkStep",
description: "Create links between vendors and orders.",
depth: 7,
link: "/references/helper-steps/createRemoteLinkStep"
},
],
depth: 6
},
{
type: "step",
name: "createRemoteLinkStep",
description: "Create links between vendors and orders.",
depth: 1,
link: "/references/helper-steps/createRemoteLinkStep"
}
name: "releaseLockStep",
description: "Release the lock on the cart.",
depth: 7,
link: "/references/medusa-workflows/steps/releaseLockStep"
},
]
}}
hideLegend
/>
You only need to implement the third and fourth steps, as Medusa provides the rest of the steps in its `@medusajs/medusa/core-flows` package.
You only need to implement the `groupVendorItemsStep` and `createVendorOrdersStep` steps, as Medusa provides the rest of the steps in its `@medusajs/medusa/core-flows` package.
### groupVendorItemsStep
@@ -1647,17 +1675,22 @@ Now that you have all the necessary steps, you can create the workflow.
Create the workflow at the file `src/workflows/marketplace/create-vendor-orders/index.ts`:
export const createVendorOrdersWorkflowHighlights = [
["21", "useQueryGraphStep", "Retrieve the cart's details."],
["30", "completeCartWorkflow", "Create the parent order from the cart."],
["36", "groupVendorItemsStep", "Group the items by their vendor."],
["40", "getOrderDetailWorkflow", "Retrieve the parent order's details."],
["61", "createVendorOrdersStep", "Create child orders for each vendor"],
["66", "createRemoteLinkStep", "Create the links returned by the previous step."]
["25", "useQueryGraphStep", "Retrieve the cart's details."],
["34", "acquireLockStep", "Acquire a lock on the cart to avoid concurrent modifications."],
["40", "completeCartWorkflow", "Create the parent order from the cart."],
["46", "useQueryGraphStep", "Retrieve existing links between the order and variant to ensure idempotency."],
["52", "getOrderDetailWorkflow", "Retrieve the parent order's details."],
["70", "when", "Check if there are existing links to ensure idempotency."],
["76", "groupVendorItemsStep", "Group the items by their vendor."],
["83", "createVendorOrdersStep", "Create child orders for each vendor"],
["88", "createRemoteLinkStep", "Create the links returned by the previous step."],
["93", "releaseLockStep", "Release the lock on the cart."],
]
```ts title="src/workflows/marketplace/create-vendor-orders/index.ts" collapsibleLines="1-13" expandMoreLabel="Show Imports"
```ts title="src/workflows/marketplace/create-vendor-orders/index.ts" collapsibleLines="1-17" expandMoreLabel="Show Imports"
import {
createWorkflow,
when,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import {
@@ -1665,9 +1698,12 @@ import {
createRemoteLinkStep,
completeCartWorkflow,
getOrderDetailWorkflow,
acquireLockStep,
releaseLockStep,
} from "@medusajs/medusa/core-flows"
import groupVendorItemsStep from "./steps/group-vendor-items"
import groupVendorItemsStep, { GroupVendorItemsStepInput } from "./steps/group-vendor-items"
import createVendorOrdersStep from "./steps/create-vendor-orders"
import vendorOrderLink from "../../../links/vendor-order"
type WorkflowInput = {
cart_id: string
@@ -1685,16 +1721,24 @@ const createVendorOrdersWorkflow = createWorkflow(
},
})
acquireLockStep({
key: input.cart_id,
timeout: 2,
ttl: 10,
})
const { id: orderId } = completeCartWorkflow.runAsStep({
input: {
id: carts[0].id,
},
})
const { vendorsItems } = groupVendorItemsStep({
cart: carts[0],
} as unknown as GroupVendorItemsStepInput)
const { data: existingLinks } = useQueryGraphStep({
entity: vendorOrderLink.entryPoint,
fields: ["vendor.id"],
filters: { order_id: orderId },
}).config({ name: "retrieve-existing-links" })
const order = getOrderDetailWorkflow.runAsStep({
input: {
order_id: orderId,
@@ -1713,19 +1757,36 @@ const createVendorOrdersWorkflow = createWorkflow(
},
})
const {
orders: vendorOrders,
linkDefs,
} = createVendorOrdersStep({
parentOrder: order,
vendorsItems,
const vendorOrders = when(
"create-vendor-order-links",
{ existingLinks },
(data) => data.existingLinks.length === 0
).then(() => {
const { vendorsItems } = groupVendorItemsStep({
cart: carts[0],
} as unknown as GroupVendorItemsStepInput)
const {
orders: vendorOrders,
linkDefs,
} = createVendorOrdersStep({
parentOrder: order,
vendorsItems,
})
createRemoteLinkStep(linkDefs)
return vendorOrders
})
createRemoteLinkStep(linkDefs)
releaseLockStep({
key: input.cart_id,
})
return new WorkflowResponse({
parent_order: order,
vendor_orders: vendorOrders,
order,
vendorOrders,
})
}
)
@@ -1736,11 +1797,16 @@ export default createVendorOrdersWorkflow
The workflow receives the cart's ID as an input. In the workflow, you run the following steps:
1. `useQueryGraphStep` to retrieve the cart's details.
2. `completeCartWorkflow` to complete the cart and create a parent order.
3. `groupVendorItemsStep` to group the order's items by their vendor.
4. `getOrderDetailWorkflow` to retrieve the parent order's details.
5. `createVendorOrdersStep` to create child orders for each vendor's items.
6. `createRemoteLinkStep` to create the links returned by the previous step.
2. `acquireLockStep` to acquire a lock on the cart.
3. `completeCartWorkflow` to complete the cart and create a parent order.
4. `useQueryGraphStep` to retrieve existing links between the order and variant to ensure idempotency.
- This is essential, as the workflow might be executed multiple times for the same cart. So, if links already exist, you skip creating vendor orders again.
5. `getOrderDetailWorkflow` to retrieve the parent order's details.
6. Perform a condition with `when` to check if there are existing links. If not, you run the following steps:
- `groupVendorItemsStep` to group the items by their vendor.
- `createVendorOrdersStep` to create child orders for each vendor.
- `createRemoteLinkStep` to create the links returned by the previous step.
7. `releaseLockStep` to release the lock on the cart.
You return the parent and vendor orders.
@@ -744,19 +744,33 @@ The custom add-to-cart workflow will have the following steps:
description: "Get the custom price for the product variant.",
depth: 2
},
{
type: "step",
name: "acquireLockStep",
description: "Acquire a lock on the cart to prevent concurrent modifications.",
link: "/references/medusa-workflows/steps/acquireLockStep",
depth: 3
},
{
type: "workflow",
name: "addToCartWorkflow",
description: "Add the product variant to the cart with the custom price.",
link: "/references/medusa-workflows/addToCartWorkflow",
depth: 3
depth: 4
},
{
type: "step",
name: "useQueryGraphStep",
description: "Get the cart's updated data.",
link: "/references/helper-steps/useQueryGraphStep",
depth: 4
depth: 5
},
{
type: "step",
name: "releaseLockStep",
description: "Release the lock on the cart.",
link: "/references/medusa-workflows/steps/releaseLockStep",
depth: 6
}
]
}}
@@ -768,15 +782,22 @@ You already have all the necessary steps within the workflow, so you create it r
Create the file `src/workflows/custom-add-to-cart.ts` with the following content:
export const customAddToCartWorkflowHighlights = [
["17", "useQueryGraphStep", "Get the cart's region."],
["27", "getCustomPriceWorkflow", "Get the custom price for the product variant."],
["47", "addToCartWorkflow", "Add the product variant to the cart with the custom price."],
["55", "useQueryGraphStep", "Get the updated cart data."]
["22", "useQueryGraphStep", "Get the cart's region."],
["32", "getCustomPriceWorkflow", "Get the custom price for the product variant."],
["40", "acquireLockStep", "Acquire a lock on the cart to prevent concurrent modifications."],
["58", "addToCartWorkflow", "Add the product variant to the cart with the custom price."],
["66", "useQueryGraphStep", "Get the updated cart data."],
["74", "releaseLockStep", "Release the lock on the cart."]
]
```ts title="src/workflows/custom-add-to-cart.ts" highlights={customAddToCartWorkflowHighlights}
import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { addToCartWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows"
import {
addToCartWorkflow,
acquireLockStep,
releaseLockStep,
useQueryGraphStep,
} from "@medusajs/medusa/core-flows"
import { getCustomPriceWorkflow } from "./get-custom-price"
type CustomAddToCartWorkflowInput = {
@@ -808,6 +829,12 @@ export const customAddToCartWorkflow = createWorkflow(
metadata: input.item.metadata,
},
})
acquireLockStep({
key: input.cart_id,
timeout: 2,
ttl: 10,
})
const itemData = transform({
item: input.item,
@@ -837,6 +864,10 @@ export const customAddToCartWorkflow = createWorkflow(
},
}).config({ name: "refetch-cart" })
releaseLockStep({
key: input.cart_id,
})
return new WorkflowResponse({
cart: updatedCart[0],
})
@@ -850,10 +881,12 @@ In the workflow, you:
1. Retrieve the cart's region using the `useQueryGraphStep`.
2. Calculate the custom price using the `getCustomPriceWorkflow` that you created earlier.
3. Prepare the data of the item to add to the cart.
3. Acquire a lock on the cart using the `acquireLockStep` to prevent concurrent modifications.
4. Prepare the data of the item to add to the cart.
- You use the `transform` function because direct data manipulation isn't allowed in workflows. Refer to the [Data Manipulation](!docs!/learn/fundamentals/workflows/variable-manipulation) guide to learn more.
4. Add the item to the cart using the `addToCartWorkflow`.
5. Refetch the updated cart using the `useQueryGraphStep` to return the cart data in the workflow's response.
5. Add the item to the cart using the `addToCartWorkflow`.
6. Refetch the updated cart using the `useQueryGraphStep` to return the cart data in the workflow's response.
7. Release the lock on the cart using the `releaseLockStep`.
### b. Create the Add-to-Cart API Route
@@ -732,6 +732,9 @@ export const createSubscriptionWorkflowHighlights = [
import {
createWorkflow,
WorkflowResponse,
useQueryGraphStep,
acquireLockStep,
releaseLockStep,
} from "@medusajs/framework/workflows-sdk"
import {
createRemoteLinkStep,
@@ -754,6 +757,11 @@ type WorkflowInput = {
const createSubscriptionWorkflow = createWorkflow(
"create-subscription",
(input: WorkflowInput) => {
acquireLockStep({
key: input.cart_id,
timeout: 2,
ttl: 10,
})
const { id } = completeCartWorkflow.runAsStep({
input: {
id: input.cart_id,
@@ -815,14 +823,34 @@ const createSubscriptionWorkflow = createWorkflow(
},
})
const { subscription, linkDefs } = createSubscriptionStep({
cart_id: input.cart_id,
order_id: orders[0].id,
customer_id: orders[0].customer_id!,
subscription_data: input.subscription_data,
const { data: existingLinks } = useQueryGraphStep({
entity: subscriptionOrderLink.entryPoint,
fields: ["subscription.id"],
filters: { order_id: orders[0].id },
}).config({ name: "retrieve-existing-links" })
const subscription = when(
"create-subscription-condition",
{ existingLinks },
(data) => data.existingLinks.length === 0
)
.then(() => {
const { subscription, linkDefs } = createSubscriptionStep({
cart_id: input.cart_id,
order_id: orders[0].id,
customer_id: orders[0].customer_id!,
subscription_data: input.subscription_data,
})
createRemoteLinkStep(linkDefs)
return subscription
})
createRemoteLinkStep(linkDefs)
releaseLockStep({
key: input.cart_id,
})
return new WorkflowResponse({
subscription: subscription,
@@ -836,10 +864,14 @@ export default createSubscriptionWorkflow
This workflow accepts the cart’s ID, along with the subscription details. It executes the following steps:
1. `completeCartWorkflow` from `@medusajs/medusa/core-flows` that completes a cart and creates an order.
2. `useQueryGraphStep` from `@medusajs/medusa/core-flows` to retrieve the order's details. [Query](!docs!/learn/fundamentals/module-links/query) is a tool that allows you to retrieve data across modules.
3. `createSubscriptionStep`, which is the step you created previously.
4. `createRemoteLinkStep` from `@medusajs/medusa/core-flows`, which accepts links to create. These links are in the `linkDefs` array returned by the previous step.
1. [acquireLockStep](/references/medusa-workflows/steps/acquireLockStep) to acquire a lock on the cart to prevent race conditions.
2. [completeCartWorkflow](/references/medusa-workflows/completeCartWorkflow) that completes a cart and creates an order.
3. [useQueryGraphStep](/references/helper-steps/useQueryGraphStep) to retrieve the order's details. [Query](!docs!/learn/fundamentals/module-links/query) is a tool that allows you to retrieve data across modules.
4. [useQueryGraphStep](/references/helper-steps/useQueryGraphStep) again to check if a subscription already exists for the order. This is necessary to ensure idempotency in case the workflow is retried.
5. Use `when` to check if a subscription already exists for the order. If not, it executes the next steps:
1. `createSubscriptionStep`, which is the step you created previously.
2. `createRemoteLinkStep` which accepts links to create. These links are in the `linkDefs` array returned by the previous step.
6. [releaseLockStep](/references/medusa-workflows/steps/releaseLockStep) to release the lock on the cart.
The workflow returns the created subscription and order.
@@ -918,7 +950,7 @@ export const POST = async (
res.json({
type: "order",
...result,
order: result.order,
})
}
```
@@ -927,7 +959,7 @@ Since the file exports a `POST` function, you're exposing a `POST` API route at
In the route handler function, you retrieve the cart to access it's `metadata` property. If the subscription details aren't stored there, you throw an error.
Then, you use the `createSubscriptionWorkflow` you created to create the order, and return the created order and subscription in the response.
Then, you use the `createSubscriptionWorkflow` you created to create the order, and return the created order in the response.
In the next step, you'll customize the Next.js Starter Storefront, allowing you to test out the subscription feature.
@@ -965,6 +997,7 @@ In this step, you'll customize the checkout flow in the [Next.js Starter storefr
1. Add a subscription step to the checkout flow.
2. Pass the additional data that Stripe requires to later capture the payment when the subscription renews, as explained in the [Payment Flow Overview](#intermission-payment-flow-overview).
3. Change the complete cart action to use the custom API route you created in the previous step.
### Add Subscription Step
@@ -1199,6 +1232,30 @@ If you're integrating with a custom payment provider, you can instead pass the r
The payment method can now be used later to capture the payment when the subscription renews.
### Change Complete Cart Action
Finally, you need to change the complete cart action to use the custom API route you created in the previous step.
In `src/lib/data/cart.ts`, find the `placeOrder` function and change the `await sdk.store.cart.complete` call to the following:
```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="orange"
const cartRes = await sdk.client.fetch<{
type: "cart"
cart: HttpTypes.StoreCart
} | {
type: "order"
order: HttpTypes.StoreOrder
}>(
`/store/carts/${id}/subscribe`,
{
method: "POST",
headers,
}
)
```
You change the request to send a `POST` request to the `/store/carts/[id]/subscribe` endpoint you created earlier. You can keep the rest of the `placeOrder` function as is.
### Test Cart Completion and Subscription Creation
To test out the cart completion flow:
@@ -3236,7 +3236,7 @@ const handleStep2Submit = async () => {
return price
}).filter((price) => price.amount > 0), // Only include prices > 0
}))
})).filter((variant) => variant.seat_count > 0) // Only create variants for row types with seats
setIsLoading(true)
try {
@@ -3499,16 +3499,14 @@ You can edit the associated Medusa product to add images, descriptions, and othe
---
## Step 10: Validate Cart Before Checkout
## Step 10: Validate Ticket on Add to Cart
In this step, you'll add custom validation to core cart operations that ensures a seat isn't purchased more than once for the same date.
In this step, you'll add custom validation to the core add-to-cart operation that ensures a seat isn't purchased more than once for the same date.
Medusa implements cart operations in workflows. Specifically, you'll focus on the `addToCartWorkflow` and `completeCartWorkflow`. Medusa allows you to inject custom logic into workflows using [hooks](!docs!/learn/fundamentals/workflows/workflow-hooks).
Medusa implements cart operations in workflows. Specifically, you'll focus on the `addToCartWorkflow`. Medusa allows you to inject custom logic into workflows using [hooks](!docs!/learn/fundamentals/workflows/workflow-hooks).
A workflow hook is a point in a workflow where you can inject custom functionality as a step function.
#### Add to Cart Validation Hook
To consume the `validate` hook of the `addToCartWorkflow` that holds the add-to-cart logic, create the file `src/workflows/hooks/add-to-cart-validation.ts` with the following content:
```ts title="src/workflows/hooks/add-to-cart-validation.ts"
@@ -3606,91 +3604,7 @@ In the step function, you:
If the hook throws an error, the add-to-cart operation will be aborted and the error message will be returned to the client.
#### Complete Cart Validation Hook
Next, to consume the `validate` hook of the `completeCartWorkflow` that holds the checkout logic, create the file `src/workflows/hooks/complete-cart-validation.ts` with the following content:
```ts title="src/workflows/hooks/complete-cart-validation.ts"
import { completeCartWorkflow } from "@medusajs/medusa/core-flows"
import { MedusaError } from "@medusajs/framework/utils"
completeCartWorkflow.hooks.validate(
async ({ cart }, { container }) => {
const query = container.resolve("query")
const { data: items } = await query.graph({
entity: "line_item",
fields: ["id", "variant_id", "metadata", "quantity"],
filters: {
id: cart.items.map((item) => item.id).filter(Boolean) as string[],
},
})
// Get the product variant to check if it's a ticket product variant
const { data: productVariants } = await query.graph({
entity: "product_variant",
fields: ["id", "product_id", "ticket_product_variant.purchases.*"],
filters: {
id: items.map((item) => item.variant_id).filter(Boolean) as string[],
},
})
// Check for duplicate seats within the cart
const seatDateCombinations = new Set<string>()
for (const item of items) {
if (item.quantity !== 1) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"You can only purchase one ticket for a seat."
)
}
const productVariant = productVariants.find(
(variant) => variant.id === item.variant_id
)
if (!productVariant || !item.metadata?.seat_number) {continue}
if (!item.metadata?.show_date) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Show date is required for seat ${item.metadata?.seat_number} in product ${productVariant.product_id}`
)
}
// Create a unique key for seat and date combination
const seatDateKey = `${item.metadata?.seat_number}-${item.metadata?.show_date}`
// Check if this seat-date combination already exists in the cart
if (seatDateCombinations.has(seatDateKey)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Duplicate seat ${item.metadata?.seat_number} found for show date ${item.metadata?.show_date} in cart`
)
}
// Add to the set to track this combination
seatDateCombinations.add(seatDateKey)
// Check if seat has already been purchased
const existingPurchase = productVariant.ticket_product_variant?.purchases.find(
(purchase) => purchase?.seat_number === item.metadata?.seat_number
&& purchase?.show_date === item.metadata?.show_date
)
if (existingPurchase) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Seat ${item.metadata?.seat_number} has already been purchased for show date ${item.metadata?.show_date}`
)
}
}
}
)
```
Similar to the previous hook, you consume the `validate` hook of the `completeCartWorkflow` to validate that no seat is purchased more than once for the same date.
You can test out both hooks when you [customize the storefront](./storefront/page.mdx).
You can test out the hook when you [customize the storefront](./storefront/page.mdx).
---
@@ -3706,46 +3620,80 @@ The custom workflow that completes the cart has the following steps:
workflow={{
name: "completeCartWithTicketsWorkflow",
steps: [
{
type: "step",
name: "acquireLockStep",
description: "Acquire a lock on the cart to prevent concurrent modifications",
link: "/references/medusa-workflows/steps/acquireLockStep",
depth: 1,
},
{
type: "workflow",
name: "completeCartWorkflow",
description: "Complete the cart using Medusa's default completeCartWorkflow",
depth: 1,
depth: 2,
link: "/references/medusa-workflows/completeCartWorkflow"
},
{
type: "step",
name: "useQueryGraphStep",
description: "Retrieve the cart details",
depth: 2,
depth: 3,
link: "/references/helper-steps/useQueryGraphStep"
},
{
type: "step",
name: "createTicketPurchasesStep",
description: "Create ticket purchases for each ticket product variant in the cart",
depth: 3,
name: "useQueryGraphStep",
description: "Retrieve existing ticket purchases to ensure idempotency",
depth: 4,
link: "/references/helper-steps/useQueryGraphStep"
},
{
type: "step",
name: "createRemoteLinkStep",
description: "Create links between the order and ticket purchases",
depth: 4,
link: "/references/helper-steps/createRemoteLinkStep"
type: "when",
condition: "existingLinks.length === 0",
steps: [
{
type: "step",
name: "validateTicketOrderStep",
description: "Validate that the ticket order can be processed",
depth: 1,
},
{
type: "step",
name: "createTicketPurchasesStep",
description: "Create ticket purchases for each ticket product variant in the cart",
depth: 2,
},
{
type: "step",
name: "createRemoteLinkStep",
description: "Create links between the order and ticket purchases",
depth: 3,
link: "/references/helper-steps/createRemoteLinkStep"
},
],
depth: 5
},
{
type: "step",
name: "useQueryGraphStep",
description: "Retrieve the order details",
depth: 5,
depth: 6,
link: "/references/helper-steps/useQueryGraphStep"
},
{
type: "step",
name: "releaseLockStep",
description: "Release the lock on the cart",
depth: 7,
link: "/references/medusa-workflows/steps/releaseLockStep"
}
]
}}
hideLegend
/>
You only need to implement the `createTicketPurchasesStep` step, as the other steps and workflows are provided by Medusa.
You only need to implement the `createTicketPurchasesStep` and `validateTicketOrderStep` steps, as the other steps and workflows are provided by Medusa.
#### createTicketPurchasesStep
@@ -3840,18 +3788,135 @@ In the step function, you prepare the ticket purchases to be created, create the
In the compensation function, you delete the created ticket purchases if an error occurs in the workflow.
#### validateTicketOrderStep
The `validateTicketOrderStep` validates that the tickets can be purchased based on their availability.
To create the step, create the file `src/workflows/steps/validate-ticket-order.ts` with the following content:
```ts title="src/workflows/steps/validate-ticket-order.ts"
import { MedusaError } from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { cancelOrderWorkflow } from "@medusajs/medusa/core-flows"
export type ValidateTicketOrderStepInput = {
items: {
id: string
variant_id: string
metadata: Record<string, unknown>
quantity: number
variant?: {
id: string
product_id: string
ticket_product_variant?: {
purchases?: {
seat_number: string
show_date: Date
}[]
}
}
}[]
order_id: string
}
export const validateTicketOrderStep = createStep(
"validate-ticket-order",
async ({ items, order_id }: ValidateTicketOrderStepInput, { container }) => {
// Check for duplicate seats within the cart
const seatDateCombinations = new Set<string>()
for (const item of items) {
if (item.quantity !== 1) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"You can only purchase one ticket for a seat."
)
}
if (!item.variant || !item.metadata?.seat_number) {continue}
if (!item.metadata?.show_date) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Show date is required for seat ${item.metadata?.seat_number} in product ${item.variant.product_id}`
)
}
// Create a unique key for seat and date combination
const seatDateKey = `${item.metadata?.seat_number}-${item.metadata?.show_date}`
// Check if this seat-date combination already exists in the cart
if (seatDateCombinations.has(seatDateKey)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Duplicate seat ${item.metadata?.seat_number} found for show date ${item.metadata?.show_date} in cart`
)
}
// Add to the set to track this combination
seatDateCombinations.add(seatDateKey)
// Check if seat has already been purchased
const existingPurchase = item.variant.ticket_product_variant?.purchases?.find(
(purchase) => purchase?.seat_number === item.metadata?.seat_number
&& purchase?.show_date === item.metadata?.show_date
)
if (existingPurchase) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Seat ${item.metadata?.seat_number} has already been purchased for show date ${item.metadata?.show_date}`
)
}
}
return new StepResponse({ validated: true }, order_id)
},
async (order_id, { container, context }) => {
if (!order_id) {return}
cancelOrderWorkflow(container).run({
input: {
order_id,
},
context,
container,
})
}
)
```
The `validateTicketOrderStep` accepts the cart items and order ID as input.
In the step function, you validate that:
1. No seat is purchased more than once for the same date within the cart.
2. No seat has already been purchased for the same date.
If any validation fails, you throw an error to abort the workflow.
The step also has a compensation function that cancels the order if an error occurs later in the workflow. This is important to ensure that if ticket purchase creation fails, the order is not left in a completed state.
#### Custom Complete Cart Workflow
You can now create the custom workflow that completes the cart and creates ticket purchases.
Create the file `src/workflows/complete-cart-with-tickets.ts` with the following content:
```ts title="src/workflows/complete-cart-with-tickets.ts"
import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { completeCartWorkflow, createRemoteLinkStep, useQueryGraphStep } from "@medusajs/medusa/core-flows"
```ts title="src/workflows/complete-cart-with-tickets.ts" collapsibleLines="1-14" expandButtonLabel="Show Imports"
import { createWorkflow, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import {
completeCartWorkflow,
createRemoteLinkStep,
acquireLockStep,
releaseLockStep,
useQueryGraphStep,
} from "@medusajs/medusa/core-flows"
import { createTicketPurchasesStep, CreateTicketPurchasesStepInput } from "./steps/create-ticket-purchases"
import { TICKET_BOOKING_MODULE } from "../modules/ticket-booking"
import { Modules } from "@medusajs/framework/utils"
import ticketPurchaseOrderLink from "../links/ticket-purchase-order"
import { validateTicketOrderStep, ValidateTicketOrderStepInput } from "./steps/validate-ticket-order"
export type CompleteCartWithTicketsWorkflowInput = {
cart_id: string
@@ -3860,7 +3925,11 @@ export type CompleteCartWithTicketsWorkflowInput = {
export const completeCartWithTicketsWorkflow = createWorkflow(
"complete-cart-with-tickets",
(input: CompleteCartWithTicketsWorkflowInput) => {
// Step 1: Complete the cart using Medusa's workflow
acquireLockStep({
key: input.cart_id,
timeout: 2,
ttl: 10,
})
const order = completeCartWorkflow.runAsStep({
input: {
id: input.cart_id,
@@ -3876,7 +3945,9 @@ export const completeCartWithTicketsWorkflow = createWorkflow(
"items.variant.options.option.*",
"items.variant.ticket_product_variant.*",
"items.variant.ticket_product_variant.ticket_product.*",
"items.variant.ticket_product_variant.purchases.*",
"items.metadata",
"items.quantity",
],
filters: {
id: input.cart_id,
@@ -3886,31 +3957,40 @@ export const completeCartWithTicketsWorkflow = createWorkflow(
},
})
// Step 2: Create ticket purchases for ticket products
const ticketPurchases = createTicketPurchasesStep({
order_id: order.id,
cart: carts[0],
} as unknown as CreateTicketPurchasesStepInput)
const { data: existingLinks } = useQueryGraphStep({
entity: ticketPurchaseOrderLink.entryPoint,
fields: ["ticket_purchase.id"],
filters: { order_id: order.id },
}).config({ name: "retrieve-existing-links" })
// Step 3: Link ticket purchases to the order
const linkData = transform({
order,
ticketPurchases,
}, (data) => {
return data.ticketPurchases.map((purchase) => ({
[TICKET_BOOKING_MODULE]: {
ticket_purchase_id: purchase.id,
},
[Modules.ORDER]: {
order_id: data.order.id,
},
}))
when({ existingLinks }, (data) => data.existingLinks.length === 0)
.then(() => {
validateTicketOrderStep({
items: carts[0].items,
order_id: order.id,
} as unknown as ValidateTicketOrderStepInput)
const ticketPurchases = createTicketPurchasesStep({
order_id: order.id,
cart: carts[0],
} as unknown as CreateTicketPurchasesStepInput)
const linkData = transform({
order,
ticketPurchases,
}, (data) => {
return data.ticketPurchases.map((purchase) => ({
[TICKET_BOOKING_MODULE]: {
ticket_purchase_id: purchase.id,
},
[Modules.ORDER]: {
order_id: data.order.id,
},
}))
})
createRemoteLinkStep(linkData)
})
// Step 4: Create remote links
createRemoteLinkStep(linkData)
// Step 5: Fetch order details
const { data: refetchedOrder } = useQueryGraphStep({
entity: "order",
fields: [
@@ -3934,6 +4014,10 @@ export const completeCartWithTicketsWorkflow = createWorkflow(
},
}).config({ name: "refetch-order" })
releaseLockStep({
key: input.cart_id,
})
return new WorkflowResponse({
order: refetchedOrder[0],
})
@@ -3945,11 +4029,17 @@ The `completeCartWithTicketsWorkflow` accepts the cart ID as input.
In the workflow function, you:
1. Complete the cart using Medusa's `completeCartWorkflow`.
2. Retrieve the cart details using the `useQueryGraphStep`.
3. Create ticket purchases for each ticket product variant in the cart using the `createTicketPurchasesStep`.
4. Create links between the order and the created ticket purchases using the `createRemoteLinkStep`.
5. Retrieve the order details using the `useQueryGraphStep`.
1. Acquire a lock on the cart to prevent concurrent modifications using the `acquireLockStep`.
2. Complete the cart using Medusa's `completeCartWorkflow`.
3. Retrieve the cart details using the `useQueryGraphStep`.
4. Retrieve existing ticket purchases linked to the order to ensure idempotency using the `useQueryGraphStep`.
- This is important because if the workflow is retried, you don't want to create duplicate ticket purchases.
5. Use `when` to check that there are no existing links between the order and ticket purchases. If so, you:
1. Validate that the ticket order can be processed using the `validateTicketOrderStep`.
2. Create ticket purchases for each ticket product variant in the cart using the `createTicketPurchasesStep`.
3. Create links between the order and the created ticket purchases using the `createRemoteLinkStep`.
6. Retrieve the order details using the `useQueryGraphStep`.
7. Release the lock on the cart using the `releaseLockStep`.
Finally, you return the order details.