diff --git a/www/apps/book/app/learn/customization/integrate-systems/handle-event/page.mdx b/www/apps/book/app/learn/customization/integrate-systems/handle-event/page.mdx index 3ec38cd144..be3c50d50e 100644 --- a/www/apps/book/app/learn/customization/integrate-systems/handle-event/page.mdx +++ b/www/apps/book/app/learn/customization/integrate-systems/handle-event/page.mdx @@ -157,10 +157,10 @@ Learn more about compensation functions in [this chapter](../../../fundamentals/ You can now create the workflow that uses the above step. Add the workflow to the same `src/workflows/sync-brands-to-cms.ts` file: export const syncWorkflowHighlights = [ - ["19", "useQueryGraphStep", "Retrieve the brand's details."], - ["23", "id", "Filter by the brand's ID."], - ["26", "throwIfKeyNotFound", "Throw an error if a brand with the specified ID doesn't exist."], - ["30", "syncBrandToCmsStep", "Create the brand in the third-party CMS."] + ["18", "useQueryGraphStep", "Retrieve the brand's details."], + ["22", "id", "Filter by the brand's ID."], + ["25", "throwIfKeyNotFound", "Throw an error if a brand with the specified ID doesn't exist."], + ["29", "syncBrandToCmsStep", "Create the brand in the third-party CMS."] ] ```ts title="src/workflows/sync-brands-to-cms.ts" highlights={syncWorkflowHighlights} @@ -181,7 +181,6 @@ type SyncBrandToCmsWorkflowInput = { export const syncBrandToCmsWorkflow = createWorkflow( "sync-brand-to-cms", (input: SyncBrandToCmsWorkflowInput) => { - // @ts-ignore const { data: brands } = useQueryGraphStep({ entity: "brand", fields: ["*"], diff --git a/www/apps/book/app/learn/fundamentals/framework/page.mdx b/www/apps/book/app/learn/fundamentals/framework/page.mdx index fe01f64307..86d65ab2fd 100644 --- a/www/apps/book/app/learn/fundamentals/framework/page.mdx +++ b/www/apps/book/app/learn/fundamentals/framework/page.mdx @@ -903,7 +903,6 @@ type WorkflowInput = { export const sendOrderConfirmationWorkflow = createWorkflow( "send-order-confirmation", ({ id }: WorkflowInput) => { - // @ts-ignore const { data: orders } = useQueryGraphStep({ entity: "order", fields: [ diff --git a/www/apps/book/generated/edit-dates.mjs b/www/apps/book/generated/edit-dates.mjs index 64de1af14f..7990d12dee 100644 --- a/www/apps/book/generated/edit-dates.mjs +++ b/www/apps/book/generated/edit-dates.mjs @@ -84,7 +84,7 @@ export const generatedEditDates = { "app/learn/customization/extend-features/define-link/page.mdx": "2025-04-17T08:50:17.036Z", "app/learn/customization/extend-features/page.mdx": "2024-12-09T11:02:39.244Z", "app/learn/customization/extend-features/query-linked-records/page.mdx": "2025-04-18T07:41:05.912Z", - "app/learn/customization/integrate-systems/handle-event/page.mdx": "2024-12-24T15:09:24.653Z", + "app/learn/customization/integrate-systems/handle-event/page.mdx": "2025-06-26T14:26:15.310Z", "app/learn/customization/integrate-systems/page.mdx": "2024-12-09T10:40:08.528Z", "app/learn/customization/integrate-systems/schedule-task/page.mdx": "2025-01-28T16:42:42.071Z", "app/learn/customization/integrate-systems/service/page.mdx": "2024-12-09T11:02:39.594Z", @@ -117,7 +117,7 @@ export const generatedEditDates = { "app/learn/production/worker-mode/page.mdx": "2025-03-11T15:21:50.906Z", "app/learn/fundamentals/module-links/read-only/page.mdx": "2025-05-13T15:04:12.107Z", "app/learn/fundamentals/data-models/properties/page.mdx": "2025-03-18T07:57:17.826Z", - "app/learn/fundamentals/framework/page.mdx": "2025-04-25T14:26:25.000Z", + "app/learn/fundamentals/framework/page.mdx": "2025-06-26T14:26:22.120Z", "app/learn/fundamentals/api-routes/retrieve-custom-links/page.mdx": "2025-04-25T14:26:25.000Z", "app/learn/fundamentals/workflows/errors/page.mdx": "2025-04-25T14:26:25.000Z", "app/learn/fundamentals/api-routes/override/page.mdx": "2025-05-09T08:01:24.493Z", diff --git a/www/apps/book/public/llms-full.txt b/www/apps/book/public/llms-full.txt index 2eea46ba8a..e724e10296 100644 --- a/www/apps/book/public/llms-full.txt +++ b/www/apps/book/public/llms-full.txt @@ -2669,7 +2669,6 @@ type SyncBrandToCmsWorkflowInput = { export const syncBrandToCmsWorkflow = createWorkflow( "sync-brand-to-cms", (input: SyncBrandToCmsWorkflowInput) => { - // @ts-ignore const { data: brands } = useQueryGraphStep({ entity: "brand", fields: ["*"], @@ -10527,7 +10526,6 @@ type WorkflowInput = { export const sendOrderConfirmationWorkflow = createWorkflow( "send-order-confirmation", ({ id }: WorkflowInput) => { - // @ts-ignore const { data: orders } = useQueryGraphStep({ entity: "order", fields: [ @@ -33010,18 +33008,22 @@ Cloud offers a managed file storage solution with AWS S3 for your Medusa applica - Choose "Standard" for storage class - Confirm by clicking "Create bucket" 3. Configure public access: + - Make sure you have a [domain configured in your Cloudflare account](https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/). - On your bucket's dashboard, click on the Settings tab. - - Scroll down to the Public Access section, and click on "Allow Access" in the "R2.dev subdomain" card. - - Type 'allow' to confirm - - Copy the Public R2.dev Bucket URL for your `S3_FILE_URL` + - In the General Section look for Custom Domains (recommended for production use) + - Click on the Add button to add your domain name. + - Enter the domain name you want to connect to and select Continue. + - Review the new record that will be added to the DNS table and select Connect Domain. 4. Retrieve credentials: - [Go to API tokens page](https://dash.cloudflare.com/?to=/:account/r2/api-tokens): - - Select "Create API token" + - Click "Create User API token" - Edit the "R2 Token" name - Under Permissions, select Object Read & Write permission types - You can optionally specify the buckets that this API token has access to under the "Specify bucket(s)" section. - - Once done, click the "Create API Token" button. - - You'll receive an access key ID and a secret access key. Save them to use them later for the `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` environment variables. + - Once done, click the "Create User API Token" button. + - Copy the jurisdiction-specific endpoint for S3 clients to S3\_ENDPOINT into your environment variables. + - Copy the Access Key ID and Secret Access Key to the corresponding fields into your environment variables. + - Copy your custom domain to `S3_FILE_URL` with leading https:// into your environment variables. ### Supabase S3 Storage @@ -33581,6 +33583,10 @@ Medusa provides other Notification Modules that actually send notifications, suc - [Local](https://docs.medusajs.com/infrastructure-modules/notification/local/index.html.md) - [SendGrid](https://docs.medusajs.com/infrastructure-modules/notification/sendgrid/index.html.md) +- [Mailchimp](https://docs.medusajs.com/integrations/guides/mailchimp/index.html.md) +- [Resend](https://docs.medusajs.com/integrations/guides/resend/index.html.md) +- [Slack](https://docs.medusajs.com/integrations/guides/slack/index.html.md) +- [Twilio SMS](https://docs.medusajs.com/how-to-tutorials/tutorials/phone-auth#step-3-integrate-twilio-sms/index.html.md) *** @@ -38318,7 +38324,6 @@ type AddCustomToCartWorkflowInput = { export const addCustomToCartWorkflow = createWorkflow( "add-custom-to-cart", ({ cart_id, item }: AddCustomToCartWorkflowInput) => { - // @ts-ignore const { data: carts } = useQueryGraphStep({ entity: "cart", filters: { id: cart_id }, @@ -38426,7 +38431,6 @@ import { WorkflowResponse } from "@medusajs/framework/workflows-sdk" And replace the last `TODO` in the workflow with the following: ```ts title="src/workflows/add-custom-to-cart.ts" -// @ts-ignore const { data: updatedCarts } = useQueryGraphStep({ entity: "cart", filters: { id: cart_id }, @@ -39192,7 +39196,6 @@ type WorkflowInput = { export const createRequestForQuoteWorkflow = createWorkflow( "create-request-for-quote", (input: WorkflowInput) => { - // @ts-ignore const { data: carts } = useQueryGraphStep({ entity: "cart", fields: [ @@ -41037,7 +41040,6 @@ type WorkflowInput = { export const merchantRejectQuoteWorkflow = createWorkflow( "merchant-reject-quote-workflow", (input: WorkflowInput) => { - // @ts-ignore const { data: quotes } = useQueryGraphStep({ entity: "quote", fields: ["id", "status"], @@ -41309,7 +41311,6 @@ type WorkflowInput = { export const merchantSendQuoteWorkflow = createWorkflow( "merchant-send-quote-workflow", (input: WorkflowInput) => { - // @ts-ignore const { data: quotes } = useQueryGraphStep({ entity: "quote", fields: ["id", "status"], @@ -41672,7 +41673,6 @@ type WorkflowInput = { export const customerRejectQuoteWorkflow = createWorkflow( "customer-reject-quote-workflow", (input: WorkflowInput) => { - // @ts-ignore const { data: quotes } = useQueryGraphStep({ entity: "quote", fields: ["id", "status"], @@ -41864,7 +41864,6 @@ type WorkflowInput = { export const customerAcceptQuoteWorkflow = createWorkflow( "customer-accept-quote-workflow", (input: WorkflowInput) => { - // @ts-ignore const { data: quotes } = useQueryGraphStep({ entity: "quote", fields: ["id", "draft_order_id", "status"], @@ -46890,11 +46889,9 @@ const { updated_at: { $lt: oneDayAgo, }, - // @ts-ignore email: { $ne: null, }, - // @ts-ignore completed_at: null, }, pagination: { @@ -47052,6 +47049,600 @@ If you are new to Medusa, check out the [main documentation](https://docs.medusa To learn more about the commerce features that Medusa provides, check out Medusa's [Commerce Modules](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/index.html.md). +# Implement First-Purchase Discount in Medusa + +In this tutorial, you'll learn how to implement first-purchase discounts in Medusa. + +When you install a Medusa application, you get a fully-fledged commerce platform with a Framework for customization. The Medusa application's commerce features are built around [Commerce Modules](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/index.html.md), which are available out-of-the-box. These features include promotion and discount management features. + +The first-purchase discount feature encourages customers to sign up and make their first purchase by offering them a discount. In this tutorial, you'll learn how to implement this feature in Medusa. + +## Summary + +By following this tutorial, you'll learn how to: + +- Install and set up Medusa. +- Apply a first-purchase discount to a customer's cart if they are a first-time customer. +- Add custom validation to ensure the discount is only used by first-time customers. +- Customize the Next.js Starter Storefront to display a pop-up encouraging first-time customers to sign up and receive a discount. + +You can follow this tutorial whether you're new to Medusa or an advanced Medusa developer. + +![Diagram showcasing the flow of first-time purchase discounts](https://res.cloudinary.com/dza7lstvk/image/upload/v1750846212/Medusa%20Resources/first-purchase-promo-overview_jbiwa9.jpg) + +[View on Github](https://github.com/medusajs/examples/tree/main/first-purchase-discount): Find the full code for this tutorial. + +*** + +## Step 1: Install a Medusa Application + +### Prerequisites + +- [Node.js v20+](https://nodejs.org/en/download) +- [Git CLI tool](https://git-scm.com/downloads) +- [PostgreSQL](https://www.postgresql.org/download/) + +Start by installing the Medusa application on your machine with the following command: + +```bash +npx create-medusa-app@latest +``` + +First, you'll be asked for the project's name. Then, when prompted about installing the [Next.js Starter Storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md), choose "Yes." + +Afterward, the installation process will start, which will install the Medusa application in a directory with your project's name and the Next.js Starter Storefront in a separate directory named `{project-name}-storefront`. + +The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). Learn more in [Medusa's Architecture documentation](https://docs.medusajs.com/docs/learn/introduction/architecture/index.html.md). + +Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. Afterward, you can log in with the new user and explore the dashboard. + +Check out the [troubleshooting guides](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/troubleshooting/create-medusa-app-errors/index.html.md) for help. + +*** + +## Step 2: Create a First-Purchase Promotion + +Before you apply the first-purchase discount or promotion to a customer's cart, you need to create the promotion that will be applied. + +Start your Medusa application with the following command: + +```bash npm2yarn +npm run dev +``` + +Then, open the Medusa Admin dashboard at `http://localhost:9000/app` and log in with the user you created in the previous step. + +Next, click on the "Promotions" tab in the left sidebar, then click on the "Create Promotion" button to create a new promotion. + +You can customize the promotion based on your use case. For example, it can be a `10%` off the entire order, or a fixed amount off specific items. + +Make sure to set the promotion's code to `FIRST_PURCHASE`, as you'll be using this code in your Medusa customization. If you want to use a different code, make sure to update the code in the next steps accordingly. + +Refer to the [Create Promotions User Guide](https://docs.medusajs.com/user-guide/promotions/create/index.html.md) to learn how to create promotions in Medusa. + +Once you create and publish the promotion, you can proceed to the next steps. + +![First-purchase promotion in Medusa Admin](https://res.cloudinary.com/dza7lstvk/image/upload/v1750846696/Medusa%20Resources/CleanShot_2025-06-25_at_13.18.00_2x_j46emw.png) + +*** + +## Step 3: Apply the First-Purchase Discount to Cart + +In this step, you'll customize the Medusa application to automatically apply the first-purchase promotion to a cart. + +To build this feature, you need to: + +- Create a [workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md) that implements the logic to apply the first-purchase promotion to a cart. +- Execute the workflow in a [subscriber](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md) that is triggered when a cart is created, or when it's transferred to a customer. + +### a. Store the First-Purchase Promotion Code + +Since you'll refer to the first-purchase promotion code in multiple places, it's a good idea to store it as a constant in your Medusa application. + +So, create the file `src/constants.ts` with the following content: + +```ts title="src/constants.ts" +export const FIRST_PURCHASE_PROMOTION_CODE = "FIRST_PURCHASE" +``` + +You'll reference this constant in the next steps. + +### b. Create the Workflow + +Next, you'll create the workflow that implements the logic to apply the first-purchase promotion to a cart. + +A [workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md) is a series of actions, called steps, that complete a task with rollback and retry mechanisms. In Medusa, you build commerce features in workflows, then execute them in other customizations, such as subscribers, scheduled jobs, and API routes. + +The workflow you'll build will have the following steps: + +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the cart's details, including its promotions and customer. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the details of the first-purchase promotion. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the updated cart's details, including its promotions. + +Medusa provides all these steps in its `@medusajs/medusa/core-flows` package, so you can implement the workflow right away. + +To create the workflow, create the file `src/workflows/apply-first-purchase-promo.ts` with the following content: + +```ts title="src/workflows/apply-first-purchase-promo.ts" highlights={workflowHighlights} +import { createWorkflow, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { updateCartPromotionsStep, useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { FIRST_PURCHASE_PROMOTION_CODE } from "../constants" +import { PromotionActions } from "@medusajs/framework/utils" + +type WorkflowInput = { + cart_id: string +} + +export const applyFirstPurchasePromoWorkflow = createWorkflow( + "apply-first-purchase-promo", + (input: WorkflowInput) => { + const { data: carts } = useQueryGraphStep({ + entity: "cart", + fields: ["promotions.*", "customer.*", "customer.orders.*"], + filters: { + id: input.cart_id + } + }) + + const { data: promotions } = useQueryGraphStep({ + entity: "promotion", + fields: ["code"], + filters: { + code: FIRST_PURCHASE_PROMOTION_CODE + } + }).config({ name: "retrieve-promotions" }) + + when({ + carts, + promotions + }, (data) => { + return data.promotions.length > 0 && + !data.carts[0].promotions?.some((promo) => promo?.id === data.promotions[0].id) && + data.carts[0].customer !== null && + data.carts[0].customer.orders?.length === 0 + }) + .then(() => { + updateCartPromotionsStep({ + id: carts[0].id, + promo_codes: [promotions[0].code!], + action: PromotionActions.ADD + }) + }) + + // retrieve updated cart + const { data: updatedCarts } = useQueryGraphStep({ + entity: "cart", + fields: ["*", "promotions.*"], + filters: { + id: input.cart_id + } + }).config({ name: "retrieve-updated-cart" }) + + return new WorkflowResponse(updatedCarts[0]) + } +) +``` + +You create a workflow using `createWorkflow` from the Workflows SDK. It accepts the workflow's unique name as a first parameter. + +`createWorkflow` accepts as a second parameter a constructor function, which is the workflow's implementation. The function accepts as an input an object with the ID of the cart to apply the first-purchase promotion to. + +In the workflow's constructor function, you: + +- Retrieve the cart's details, including its promotions and customer, using the [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md). +- Retrieve the details of the first-purchase promotion using the `useQueryGraphStep`. + - You pass the `FIRST_PURCHASE_PROMOTION_CODE` constant to the `filters` option to retrieve the promotion. +- Use the [when-then](https://docs.medusajs.com/docs/learn/fundamentals/workflows/conditions/index.html.md) utility to only apply the promotion if the first-purchase promotion exists, the cart doesn't have the promotion, and the customer doesn't have any orders. `when` receives two parameters: + - An object to use in the condition function. + - A condition function that receives the first parameter object and returns a boolean indicating whether to execute the steps in the `then` block. +- Retrieve the updated cart's details, including its promotions, using the `useQueryGraphStep` again. + +Finally, you return a `WorkflowResponse` with the updated cart's details. + +You can't perform data manipulation in a workflow's constructor function. Instead, the Workflows SDK includes utility functions like `when` to perform typical operations that require accessing data values. Learn more about workflow constraints in the [Workflow Constraints](https://docs.medusajs.com/docs/learn/fundamentals/workflows/constructor-constraints/index.html.md) documentation. + +### c. Create the Subscriber + +Next, you'll create a subscriber that executes the workflow when a cart is created or transferred to a customer. + +A cart can be transferred to a customer when they sign up or log in, or in B2B use cases. + +A [subscriber](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md) is an asynchronous function that listens to events to perform a task. In this case, you'll create a subscriber that listens to the `cart.created` and `cart.customer_transferred` events to execute the workflow. + +To create the subscriber, create the file `src/subscribers/apply-first-purchase.ts` with the following content: + +```ts title="src/subscribers/apply-first-purchase.ts" +import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework" +import { applyFirstPurchasePromoWorkflow } from "../workflows/apply-first-purchase-promo" + +export default async function cartCreatedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ + id: string +}>) { + await applyFirstPurchasePromoWorkflow(container) + .run({ + input: { + cart_id: data.id + } + }) +} + +export const config: SubscriberConfig = { + event: ["cart.created", "cart.customer_transferred"], +} +``` + +A subscriber file must export: + +1. An asynchronous function, which is the subscriber that is executed when the event is emitted. +2. A configuration object that holds the names of the events the subscriber listens to, which are `cart.created` and `cart.customer_transferred` in this case. + +The subscriber function receives an object as a parameter that has a `container` property, which is the [Medusa container](https://docs.medusajs.com/docs/learn/fundamentals/medusa-container/index.html.md). The Medusa container holds Framework and commerce tools that you can resolve and use in your customizations. + +In the subscriber function, you execute the `applyFirstPurchasePromoWorkflow` by invoking it, passing it the Medusa container, then calling its `run` method. You pass the `cart_id` from the event payload as an input to the workflow. + +### Test it Out + +You can now test the automatic application of the first-purchase promotion to a cart. To do that, you'll use the [Next.js Starter Storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md) you installed in the first step. + +The Next.js Starter Storefront was installed in a separate directory from Medusa. The directory's name is `{your-project}-storefront`. + +So, if your Medusa application's directory is `medusa-first-promo`, you can find the storefront by going back to the parent directory and changing to the `medusa-first-promo-storefront` directory: + +```bash +cd ../medusa-first-promo-storefront # change based on your project name +``` + +First, start the Medusa application with the following command: + +```bash npm2yarn badgeLabel="Medusa Application" badgeColor="green" +npm run dev +``` + +Then, start the Next.js Starter Storefront with the following command: + +```bash npm2yarn badgeLabel="Storefront" badgeColor="blue" +npm run dev +``` + +The storefront will run at `http://localhost:8000`. Open it in your browser and click on Account at the top right to register. + +After you register, add a product to the cart, then go to the cart page. You'll find that the `FIRST_PURCHASE` promotion has been applied to the cart automatically. + +![Cart page with first-purchase promotion applied](https://res.cloudinary.com/dza7lstvk/image/upload/v1750842319/Medusa%20Resources/CleanShot_2025-06-25_at_12.02.17_2x_bbu8vt.png) + +*** + +## Step 4: Validate First-Purchase Discount Usage + +You now automatically apply the first-purchase promotion to a cart, but any customer can use the promotion code at the moment. + +So, you need to add custom validation to ensure that the first-purchase promotion is only used by first-time customers. + +In this step, you'll customize Medusa's existing workflows to validate the first-purchase promotion usage. You can do that by consuming the [workflows' hooks](https://docs.medusajs.com/docs/learn/fundamentals/workflows/workflow-hooks/index.html.md). A workflow hook is a point in a workflow where you can inject custom functionality as a step function. + +You'll consume the hooks of the following workflows: + +- [updateCartPromotionsWorkflow](https://docs.medusajs.com/references/medusa-workflows/updateCartPromotionsWorkflow/index.html.md): This workflow is used to add or remove promotions from a cart. You'll check that the customer is a first-time customer before allowing the promotion to be added. +- [completeCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/completeCartWorkflow/index.html.md): This workflow is used to complete a cart and place an order. You'll validate that the first-purchase promotion is only used by first-time customers before allowing the order to be placed. + +### a. Consume `updateCartPromotionsWorkflow.validate` Hook + +You'll start by consuming the `validate` hook of the `updateCartPromotionsWorkflow`. This hook is called before any operations are performed in the workflow. + +To consume the hook, create the file `src/workflows/hooks/validate-promotion.ts` with the following content: + +```ts title="src/workflows/hooks/validate-promotion.ts" highlights={validatePromotionHighlights} +import { + updateCartPromotionsWorkflow, +} from "@medusajs/medusa/core-flows" +import { FIRST_PURCHASE_PROMOTION_CODE } from "../../constants" +import { MedusaError } from "@medusajs/framework/utils" + +updateCartPromotionsWorkflow.hooks.validate( + (async ({ input, cart }, { container }) => { + const hasFirstPurchasePromo = input.promo_codes?.some( + (code) => code === FIRST_PURCHASE_PROMOTION_CODE + ) + + if (!hasFirstPurchasePromo) { + return + } + + if (!cart.customer_id) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "First purchase discount can only be applied to carts with a customer" + ) + } + const query = container.resolve("query") + + const { data: [customer] } = await query.graph({ + entity: "customer", + fields: ["orders.*", "has_account"], + filters: { + id: cart.customer_id + } + }) + + if (!customer.has_account || (customer?.orders?.length || 0) > 0) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "First purchase discount can only be applied to carts with no previous orders" + ) + } + }) +) +``` + +You consume a workflow's hook by calling the `hooks` property of the workflow, then calling the hook you want to consume. In this case, you call the `validate` hook of the `updateCartPromotionsWorkflow`. + +The `validate` hook receives a step function as a parameter. The function receives two parameters: + +- The hook's input, which differs based on the workflow. In this case, it receives the following properties: + - `input`: The input of the `updateCartPromotionsWorkflow`, which includes the `promo_codes` to add or remove from the cart. + - `cart`: The cart being updated. +- The hook or step context object. Most notably, it has a `container` property, which is the Medusa container. + +In the step function, you check if the `FIRST_PURCHASE_PROMOTION_CODE` is being applied to the cart. If so, you validate that: + +- The cart is associated with a customer. +- The customer has an account. +- The customer has no previous orders. + +If any of these validations fail, you throw a `MedusaError` with the appropriate error message. This will prevent the promotion from being applied to the cart. + +To retrieve the customer's details, you use [Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md). Query allows you to retrieve data across modules in your Medusa application. + +### b. Consume `completeCartWorkflow.validate` Hook + +Next, you'll consume the `validate` hook of the `completeCartWorkflow`. This workflow is used to complete a cart and place an order. You'll validate that the first-purchase promotion is only used by first-time customers before allowing the order to be placed. + +In the same `src/workflows/hooks/validate-promotion.ts` file, add the following import at the top of the file: + +```ts title="src/workflows/hooks/validate-promotion.ts" +import { + completeCartWorkflow +} from "@medusajs/medusa/core-flows" +``` + +Then, consume the hook at the end of the file: + +```ts title="src/workflows/hooks/validate-promotion.ts" highlights={validateCartCompletionHighlights} +completeCartWorkflow.hooks.validate( + (async ({ input, cart }, { container }) => { + const hasFirstPurchasePromo = cart.promotions?.some( + (promo) => promo?.code === FIRST_PURCHASE_PROMOTION_CODE + ) + + if (!hasFirstPurchasePromo) { + return + } + + if (!cart.customer_id) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "First purchase discount can only be applied to carts with a customer" + ) + } + + const query = container.resolve("query") + + const { data: [customer] } = await query.graph({ + entity: "customer", + fields: ["orders.*", "has_account"], + filters: { + id: cart.customer_id + } + }) + + if (!customer.has_account || (customer?.orders?.length || 0) > 0) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "First purchase discount can only be applied to carts with no previous orders" + ) + } + }) +) +``` + +You consume the `validate` hook of the `completeCartWorkflow` in the same way as the previous hook. The step function receives the cart being completed as an input. + +In the step function, you check if the `FIRST_PURCHASE_PROMOTION_CODE` is applied to the cart. If so, you validate that: + +- The cart is associated with a customer. +- The customer has an account. +- The customer has no previous orders. + +If any of these validations fail, you throw a `MedusaError` with the appropriate error message. This will prevent the order from being placed if the first-purchase promotion is used by a customer who is not a first-time customer. + +### Test it Out + +To test the custom validation, start the Medusa application and the Next.js Starter Storefront as you did in the previous steps. + +Then, register a new customer in the storefront, and place an order. The first-purchase promotion will be applied to the cart automatically and the order will be placed successfully. + +Try to place another order with the same customer. The first-purchase promotion will not be automatically applied to the cart. If you also try to apply the first-purchase promotion manually, you'll receive an error message indicating that the promotion can only be applied to first-time customers. + +*** + +## Step 5: Show Discount Pop-Up in Storefront + +The first-time purchase promotion is now fully functional. However, you need to inform first-time customers about the discount and encourage them to sign up. + +To do that, you'll customize the Next.js Starter Storefront to show a pop-up when a first-time customer visits the storefront. + +### a. Create the Pop-Up Component + +You'll first create the pop-up component that will be displayed to first-time customers. + +Create the file `src/modules/common/components/discount-popup/index.tsx` with the following content: + +```tsx title="src/modules/common/components/discount-popup/index.tsx" badgeLabel="Storefront" badgeColor="blue" +"use client" + +import { Button, Heading, Text } from "@medusajs/ui" +import Modal from "@modules/common/components/modal" +import useToggleState from "@lib/hooks/use-toggle-state" +import { useEffect } from "react" +import LocalizedClientLink from "@modules/common/components/localized-client-link" + +const DISCOUNT_POPUP_KEY = "discount_popup_shown" + +const DiscountPopup = () => { + const { state, open, close } = useToggleState(false) + + useEffect(() => { + // Check if the popup has been shown before + const hasBeenShown = localStorage.getItem(DISCOUNT_POPUP_KEY) + + if (!hasBeenShown) { + open() + // Mark as shown + localStorage.setItem(DISCOUNT_POPUP_KEY, "true") + } + }, [open]) + + return ( + +
+ {/* Decorative elements */} +
+
+ +
+ {/* Sale tag */} +
+ SAVE 10% +
+ + + Limited Time Offer! + + +
+
+
10%
+
OFF YOUR FIRST ORDER
+
+ % +
+
+
+
+
+ + +
+ + Sign up now to receive an exclusive 10% discount on your first purchase. Join our community of satisfied customers! + + +
+ + + + + +
+ +
+ *Discount applies to your first order only +
+
+
+
+ ) +} + +export default DiscountPopup +``` + +This component uses the `Modal` component that is already available in the Next.js Starter Storefront. It displays a pop-up with a discount offer and two buttons: one to register and save the discount, and another to close the pop-up. + +The pop-up will only be shown to first-time customers. Once the pop-up is shown, a `discount_popup_shown` key is stored in the local storage to prevent it from being shown again. + +### b. Add the Pop-Up to Layout + +To ensure that the pop-up is displayed when the customer visits the storefront, you need to add the `DiscountPopup` component to the main layout of the Next.js Starter Storefront. + +In `src/app/[countryCode]/(main)/layout.tsx`, add the following import at the top of the file: + +```tsx title="src/app/[countryCode]/(main)/layout.tsx" badgeLabel="Storefront" badgeColor="blue" +import DiscountPopup from "@modules/common/components/discount-popup" +``` + +Then, in the return statement of the `PageLayout` component, add the `DiscountPopup` component before rendering `props.children`: + +```tsx title="src/app/[countryCode]/(main)/layout.tsx" badgeLabel="Storefront" badgeColor="blue" +<> + {/* ... */} + {!customer && } + {props.children} + {/* ... */} + +``` + +Notice that you only display the pop-up if the customer is not logged in. This way, the pop-up will only be shown to first-time customers. + +### c. Show Registration Form Before Login + +If you go to the `/account` page in the Next.js Starter Storefront as a guest customer, you'll see the login form. However, in this case, you want to show the registration form first instead. + +To change this behavior, in `src/modules/account/templates/login-template.tsx`, change the default value of `currentView` to `"register"`: + +```tsx title="src/modules/account/templates/login-template.tsx" badgeLabel="Storefront" badgeColor="blue" +const [currentView, setCurrentView] = useState("register") +``` + +This way, when a guest customer visits the `/account` page, they will see the registration form instead of the login form. + +### Test it Out + +To test the pop-up, start the Medusa application and the Next.js Starter Storefront as you did in the previous steps. + +Then, open the storefront in your browser. If you're a first-time customer, you'll see the discount pop-up encouraging you to sign up and receive the first-purchase discount. + +If you don't see the pop-up, make sure that you're logged out. + +![Discount pop-up in the Next.js Starter Storefront](https://res.cloudinary.com/dza7lstvk/image/upload/v1750844087/Medusa%20Resources/CleanShot_2025-06-25_at_12.34.35_2x_f1f5jh.png) + +*** + +## Next Steps + +You've now implemented the first-purchase discount feature in Medusa. You can add more features to build customer loyalty, such as a [loyalty points system](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/how-to-tutorials/tutorials/loyalty-points/index.html.md) or [product reviews](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/how-to-tutorials/tutorials/product-reviews/index.html.md). + +If you're new to Medusa, check out the [main documentation](https://docs.medusajs.com/docs/learn/index.html.md), where you'll get a more in-depth understanding of all the concepts you've used in this guide and more. + +To learn more about the commerce features that Medusa provides, check out Medusa's [Commerce Modules](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/index.html.md). + +### Troubleshooting + +If you encounter issues during your development, check out the [troubleshooting guides](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/troubleshooting/index.html.md). + +### Getting Help + +If you encounter issues not covered in the troubleshooting guides: + +1. Visit the [Medusa GitHub repository](https://github.com/medusajs/medusa) to report issues or ask questions. +2. Join the [Medusa Discord community](https://discord.gg/medusajs) for real-time support from community members. + + # Implement Loyalty Points System in Medusa In this tutorial, you'll learn how to implement a loyalty points system in Medusa. @@ -47737,7 +48328,6 @@ type WorkflowInput = { export const handleOrderPointsWorkflow = createWorkflow( "handle-order-points", ({ order_id }: WorkflowInput) => { - // @ts-ignore const { data: orders } = useQueryGraphStep({ entity: "order", fields: [ @@ -48232,7 +48822,6 @@ const fields = [ export const applyLoyaltyOnCartWorkflow = createWorkflow( "apply-loyalty-on-cart", (input: WorkflowInput) => { - // @ts-ignore const { data: carts } = useQueryGraphStep({ entity: "cart", fields, @@ -48369,7 +48958,6 @@ updateCartsStep([ ]) // retrieve cart with updated promotions -// @ts-ignore const { data: updatedCarts } = useQueryGraphStep({ entity: "cart", fields, @@ -48558,7 +49146,6 @@ const fields = [ export const removeLoyaltyFromCartWorkflow = createWorkflow( "remove-loyalty-from-cart", (input: WorkflowInput) => { - // @ts-ignore const { data: carts } = useQueryGraphStep({ entity: "cart", fields, @@ -48606,7 +49193,6 @@ export const removeLoyaltyFromCartWorkflow = createWorkflow( ]) // retrieve cart with updated promotions - // @ts-ignore const { data: updatedCarts } = useQueryGraphStep({ entity: "cart", fields, @@ -51268,7 +51854,6 @@ export const createReviewWorkflow = createWorkflow( "create-review", (input: CreateReviewInput) => { // Check product exists - // @ts-ignore useQueryGraphStep({ entity: "product", fields: ["id"], @@ -51283,7 +51868,6 @@ export const createReviewWorkflow = createWorkflow( // Create the review const review = createReviewStep(input) - // @ts-ignore return new WorkflowResponse({ review, }) @@ -52230,7 +52814,6 @@ export const GET = async ( entity: "review", filters: { product_id: id, - // @ts-ignore status: "approved", }, ...req.queryConfig, @@ -52855,7 +53438,6 @@ type ReorderWorkflowInput = { export const reorderWorkflow = createWorkflow( "reorder", ({ order_id }: ReorderWorkflowInput) => { - // @ts-ignore const { data: orders } = useQueryGraphStep({ entity: "order", fields: [ @@ -52991,7 +53573,6 @@ Finally, you need to retrieve the cart's details and return them as the workflow Replace the `TODO` in the workflow with the following: ```ts title="src/workflows/reorder.ts" highlights={workflowHighlights4} -// @ts-ignore const { data: carts } = useQueryGraphStep({ entity: "cart", fields: [ @@ -54547,7 +55128,6 @@ type SyncProductsWorkflowInput = { export const syncProductsWorkflow = createWorkflow( "sync-products", ({ filters, limit, offset }: SyncProductsWorkflowInput) => { - // @ts-ignore const { data, metadata } = useQueryGraphStep({ entity: "product", fields: ["id", "title", "description", "handle", "thumbnail", "categories.*", "tags.*"], @@ -54556,7 +55136,6 @@ export const syncProductsWorkflow = createWorkflow( skip: offset, }, filters: { - // @ts-ignore status: "published", ...filters, }, @@ -56556,7 +57135,6 @@ type WorkflowInput = { export const createProductsContentfulWorkflow = createWorkflow( { name: "create-products-contentful-workflow" }, (input: WorkflowInput) => { - // @ts-ignore const { data } = useQueryGraphStep({ entity: "product", fields: [ @@ -60235,7 +60813,6 @@ import { export const sendNewProductsNewsletter = createWorkflow( "send-new-products-newsletter", (input) => { - // @ts-ignore const { data: products } = useQueryGraphStep({ entity: "product", fields: [ @@ -61517,7 +62094,6 @@ type WorkflowInput = { export const sendOrderConfirmationWorkflow = createWorkflow( "send-order-confirmation", ({ id }: WorkflowInput) => { - // @ts-ignore const { data: orders } = useQueryGraphStep({ entity: "order", fields: [ @@ -62309,7 +62885,6 @@ export const syncStep = createStep( fields: [ "id", "title", - // @ts-ignore "sanity_product.*", ], filters, @@ -63941,7 +64516,6 @@ type WorkflowInput = { export const trackOrderPlacedWorkflow = createWorkflow( "track-order-placed", ({ id }: WorkflowInput) => { - // @ts-ignore const { data: orders } = useQueryGraphStep({ entity: "order", fields: [ @@ -66243,7 +66817,6 @@ type WorkflowInput = { export const orderPlacedNotificationWorkflow = createWorkflow( "order-placed-notification", ({ id }: WorkflowInput) => { - // @ts-ignore const { data: orders } = useQueryGraphStep({ entity: "order", fields: [ @@ -72087,6 +72660,12 @@ Download this reference as an OpenApi YAML file. You can import this file to too - [POST /admin/fulfillments](https://docs.medusajs.com/api/admin#fulfillments_postfulfillments) - [POST /admin/fulfillments/{id}/cancel](https://docs.medusajs.com/api/admin#fulfillments_postfulfillmentsidcancel) - [POST /admin/fulfillments/{id}/shipment](https://docs.medusajs.com/api/admin#fulfillments_postfulfillmentsidshipment) +- [GET /admin/gift-cards](https://docs.medusajs.com/api/admin#gift-cards_getgiftcards) +- [POST /admin/gift-cards](https://docs.medusajs.com/api/admin#gift-cards_postgiftcards) +- [GET /admin/gift-cards/{id}](https://docs.medusajs.com/api/admin#gift-cards_getgiftcardsid) +- [POST /admin/gift-cards/{id}](https://docs.medusajs.com/api/admin#gift-cards_postgiftcardsid) +- [POST /admin/gift-cards/{id}/redeem](https://docs.medusajs.com/api/admin#gift-cards_postgiftcardsidredeem) +- [POST /admin/gift-cards/{id}/transfer](https://docs.medusajs.com/api/admin#gift-cards_postgiftcardsidtransfer) - [GET /admin/inventory-items](https://docs.medusajs.com/api/admin#inventory-items_getinventoryitems) - [POST /admin/inventory-items](https://docs.medusajs.com/api/admin#inventory-items_postinventoryitems) - [POST /admin/inventory-items/location-levels/batch](https://docs.medusajs.com/api/admin#inventory-items_postinventoryitemslocationlevelsbatch) @@ -72177,6 +72756,8 @@ Download this reference as an OpenApi YAML file. You can import this file to too - [POST /admin/products/export](https://docs.medusajs.com/api/admin#products_postproductsexport) - [POST /admin/products/import](https://docs.medusajs.com/api/admin#products_postproductsimport) - [POST /admin/products/import/{transaction_id}/confirm](https://docs.medusajs.com/api/admin#products_postproductsimporttransaction_idconfirm) +- [POST /admin/products/imports](https://docs.medusajs.com/api/admin#products_postproductsimports) +- [POST /admin/products/imports/{transaction_id}/confirm](https://docs.medusajs.com/api/admin#products_postproductsimportstransaction_idconfirm) - [GET /admin/products/{id}](https://docs.medusajs.com/api/admin#products_getproductsid) - [POST /admin/products/{id}](https://docs.medusajs.com/api/admin#products_postproductsid) - [DELETE /admin/products/{id}](https://docs.medusajs.com/api/admin#products_deleteproductsid) @@ -72273,6 +72854,10 @@ Download this reference as an OpenApi YAML file. You can import this file to too - [POST /admin/stock-locations/{id}/fulfillment-providers](https://docs.medusajs.com/api/admin#stock-locations_poststocklocationsidfulfillmentproviders) - [POST /admin/stock-locations/{id}/fulfillment-sets](https://docs.medusajs.com/api/admin#stock-locations_poststocklocationsidfulfillmentsets) - [POST /admin/stock-locations/{id}/sales-channels](https://docs.medusajs.com/api/admin#stock-locations_poststocklocationsidsaleschannels) +- [GET /admin/store-credit-accounts](https://docs.medusajs.com/api/admin#store-credit-accounts_getstorecreditaccounts) +- [POST /admin/store-credit-accounts](https://docs.medusajs.com/api/admin#store-credit-accounts_poststorecreditaccounts) +- [GET /admin/store-credit-accounts/{id}](https://docs.medusajs.com/api/admin#store-credit-accounts_getstorecreditaccountsid) +- [GET /admin/store-credit-accounts/{id}/transactions](https://docs.medusajs.com/api/admin#store-credit-accounts_getstorecreditaccountsidtransactions) - [GET /admin/stores](https://docs.medusajs.com/api/admin#stores_getstores) - [GET /admin/stores/{id}](https://docs.medusajs.com/api/admin#stores_getstoresid) - [POST /admin/stores/{id}](https://docs.medusajs.com/api/admin#stores_poststoresid) @@ -72289,7 +72874,9 @@ Download this reference as an OpenApi YAML file. You can import this file to too - [GET /admin/tax-regions/{id}](https://docs.medusajs.com/api/admin#tax-regions_gettaxregionsid) - [POST /admin/tax-regions/{id}](https://docs.medusajs.com/api/admin#tax-regions_posttaxregionsid) - [DELETE /admin/tax-regions/{id}](https://docs.medusajs.com/api/admin#tax-regions_deletetaxregionsid) +- [GET /admin/transaction-groups](https://docs.medusajs.com/api/admin#transaction-groups_gettransactiongroups) - [POST /admin/uploads](https://docs.medusajs.com/api/admin#uploads_postuploads) +- [POST /admin/uploads/presigned-urls](https://docs.medusajs.com/api/admin#uploads_postuploadspresignedurls) - [GET /admin/uploads/{id}](https://docs.medusajs.com/api/admin#uploads_getuploadsid) - [DELETE /admin/uploads/{id}](https://docs.medusajs.com/api/admin#uploads_deleteuploadsid) - [GET /admin/users](https://docs.medusajs.com/api/admin#users_getusers) @@ -72330,12 +72917,15 @@ Download this reference as an OpenApi YAML file. You can import this file to too - [POST /store/carts/{id}](https://docs.medusajs.com/api/store#carts_postcartsid) - [POST /store/carts/{id}/complete](https://docs.medusajs.com/api/store#carts_postcartsidcomplete) - [POST /store/carts/{id}/customer](https://docs.medusajs.com/api/store#carts_postcartsidcustomer) +- [POST /store/carts/{id}/gift-cards](https://docs.medusajs.com/api/store#carts_postcartsidgiftcards) +- [DELETE /store/carts/{id}/gift-cards](https://docs.medusajs.com/api/store#carts_deletecartsidgiftcards) - [POST /store/carts/{id}/line-items](https://docs.medusajs.com/api/store#carts_postcartsidlineitems) - [POST /store/carts/{id}/line-items/{line_id}](https://docs.medusajs.com/api/store#carts_postcartsidlineitemsline_id) - [DELETE /store/carts/{id}/line-items/{line_id}](https://docs.medusajs.com/api/store#carts_deletecartsidlineitemsline_id) - [POST /store/carts/{id}/promotions](https://docs.medusajs.com/api/store#carts_postcartsidpromotions) - [DELETE /store/carts/{id}/promotions](https://docs.medusajs.com/api/store#carts_deletecartsidpromotions) - [POST /store/carts/{id}/shipping-methods](https://docs.medusajs.com/api/store#carts_postcartsidshippingmethods) +- [POST /store/carts/{id}/store-credits](https://docs.medusajs.com/api/store#carts_postcartsidstorecredits) - [POST /store/carts/{id}/taxes](https://docs.medusajs.com/api/store#carts_postcartsidtaxes) - [GET /store/collections](https://docs.medusajs.com/api/store#collections_getcollections) - [GET /store/collections/{id}](https://docs.medusajs.com/api/store#collections_getcollectionsid) @@ -72349,6 +72939,12 @@ Download this reference as an OpenApi YAML file. You can import this file to too - [GET /store/customers/me/addresses/{address_id}](https://docs.medusajs.com/api/store#customers_getcustomersmeaddressesaddress_id) - [POST /store/customers/me/addresses/{address_id}](https://docs.medusajs.com/api/store#customers_postcustomersmeaddressesaddress_id) - [DELETE /store/customers/me/addresses/{address_id}](https://docs.medusajs.com/api/store#customers_deletecustomersmeaddressesaddress_id) +- [POST /store/gift-card-invitations/{code}/accept](https://docs.medusajs.com/api/store#gift-card-invitations_postgiftcardinvitationscodeaccept) +- [POST /store/gift-card-invitations/{code}/reject](https://docs.medusajs.com/api/store#gift-card-invitations_postgiftcardinvitationscodereject) +- [GET /store/gift-cards](https://docs.medusajs.com/api/store#gift-cards_getgiftcards) +- [GET /store/gift-cards/{id}](https://docs.medusajs.com/api/store#gift-cards_getgiftcardsid) +- [POST /store/gift-cards/{id}/invitation](https://docs.medusajs.com/api/store#gift-cards_postgiftcardsidinvitation) +- [POST /store/gift-cards/{id}/redeem](https://docs.medusajs.com/api/store#gift-cards_postgiftcardsidredeem) - [GET /store/orders](https://docs.medusajs.com/api/store#orders_getorders) - [GET /store/orders/{id}](https://docs.medusajs.com/api/store#orders_getordersid) - [POST /store/orders/{id}/transfer/accept](https://docs.medusajs.com/api/store#orders_postordersidtransferaccept) @@ -72373,6 +72969,8 @@ Download this reference as an OpenApi YAML file. You can import this file to too - [POST /store/returns](https://docs.medusajs.com/api/store#returns_postreturns) - [GET /store/shipping-options](https://docs.medusajs.com/api/store#shipping-options_getshippingoptions) - [POST /store/shipping-options/{id}/calculate](https://docs.medusajs.com/api/store#shipping-options_postshippingoptionsidcalculate) +- [GET /store/store-credit-accounts](https://docs.medusajs.com/api/store#store-credit-accounts_getstorecreditaccounts) +- [GET /store/store-credit-accounts/{id}](https://docs.medusajs.com/api/store#store-credit-accounts_getstorecreditaccountsid) # Alert @@ -78996,3 +79594,16940 @@ const Box = ({ className, children, mt }: BoxProps) => { In the above example the utility is used to apply a base style, a margin top that is dependent on the `mt` prop and a custom className. The Box component accepts a `className` prop that is merged with the other classNames, and the underlying usage of `tw-merge` ensures that all Tailwind CSS classes are merged without style conflicts. + + +# B2B Recipe + +This recipe provides the general steps to implement a B2B store with Medusa. + +Medusa has a ready-to-use B2B starter that you install and use in [this GitHub repository](https://github.com/medusajs/b2b-starter-medusa). + +## Overview + +In a B2B store, you provide different types of customers with relevant pricing, products, shopping experience, and more. + +Medusa’s Commerce Modules, including [Sales Channel](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/sales-channel/index.html.md), [Customer](../../commerce-modules/), and [Pricing](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/pricing/index.html.md) modules enable this setup out-of-the-box: + +- **Sales Channel**: Use sales channels to set product availability per channel. In this case, create a B2B sales channel that includes only B2B products. +- **Customer**: Use customer groups to organize your customers into different groups. Then, you can apply different prices for each group. +- **Pricing**: Use price lists to set different prices for each B2B customer group, among other conditions. + +In addition, Medusa’s extensible architecture and Framework for customization allow you to scope existing and custom featuers to specific customer groups or sales channels. + +[Visionary: Frictionless B2B ecommerce with Medusa](https://medusajs.com/blog/visionary/) + +*** + +## Create B2B Sales Channel + +Sales channls allow you to set product availability per channel. For B2B use cases, you can create a B2B sales channel that includes only B2B products. + +Then, on the storefront, your retrieve only the B2B products for B2B customers, which is explained more in the next section. + +You can create a sales channel through the Medusa Admin or Admin REST APIs. + +- [Using Medusa Admin](https://docs.medusajs.com/user-guide/settings/sales-channels/index.html.md): Create the sales channel using the Medusa Admin. +- [Using Admin API](https://docs.medusajs.com/api/admin#sales-channels_postsaleschannels): Create the sales channel using the REST APIs. + +*** + +## Create a Publishable API Key + +A publishable API key allows you to specify the context of client requests: + +- You associate the publishable API key with one or more sales channels, such as the B2B sales channel. +- In a client such as a storefront, you pass the publishable API key in the header of your requests. + +So, if you use the publishable API key associated with the B2B sales channel in your storefront, the Medusa server will only return products that are available in the B2B sales channel. + +You can create a publishable API key through the Medusa Admin or the Admin REST APIs, then associate it with the B2B sales channel. Then, you can use this key when developing your B2B storefront. + +### Create Publishable API Key + +- [Using Medusa Admin](https://docs.medusajs.com/user-guide/settings/developer/publishable-api-keys/index.html.md): Create the API key using the Medusa Admin. +- [Using Admin API](https://docs.medusajs.com/api/admin#api-keys_postapikeys): Create the API key using the REST APIs. + +### Associate Key with Sales Channel + +- [Using Medusa Admin](https://docs.medusajs.com/user-guide/settings/developer/publishable-api-keys#manage-publishable-api-keys-sales-channels/index.html.md): Associate the key with the sales channel using the Medusa Admin. +- [Using Admin API](https://docs.medusajs.com/api/admin#api-keys_postapikeysidsaleschannels): Associate the key with the sales channel using the REST APIs. + +*** + +## Add Products to B2B Sales Channel + +You can manage products to be available in specific sales channels. For B2B, this allows you to add products that are only available to B2B customers. + +You can create new products or add existing ones to the B2B sales channel using the Medusa Admin or Admin REST APIs. + +### Create Products + +- [Using Medusa Admin](https://docs.medusajs.com/user-guide/products/create/index.html.md): Create the products using the Medusa Admin. +- [Using Admin API](https://docs.medusajs.com/api/admin#products_postproducts): Create the products using the REST APIs. + +### Add Products to Sales Channel + +- [Using Medusa Admin](https://docs.medusajs.com/user-guide/settings/sales-channels#manage-products-in-sales-channel/index.html.md): Create the products using the Medusa Admin. +- [Using Admin API](https://docs.medusajs.com/api/admin#sales-channels_postsaleschannelsidproductsbatchadd): Add the products to the sales channel using the REST APIs. + +*** + +## Add B2B Customers and Groups + +Customer groups allow you to organize your customers into different groups. Then, you can apply different prices for each group. + +This is useful for B2B sales, as you often negotiate special prices with each customer or company. + +You can create a customer group for each B2B company, then add customers of that company to the group. + +### Create Customers + +- [Using Medusa Admin](https://docs.medusajs.com/user-guide/customers/manage/index.html.md): Create customers using the Medusa Admin. +- [Using Admin API](https://docs.medusajs.com/api/admin#customers_postcustomers): Create customers using the REST APIs. + +### Assign Customers to Groups + +- [Using Medusa Admin](https://docs.medusajs.com/user-guide/customers/manage#manage-customers-groups/index.html.md): Assign customer to groups using the Medusa Admin. +- [Using Admin API](https://docs.medusajs.com/api/admin#customer-groups_postcustomergroupsidcustomersbatch): Assign customer to groups using the REST APIs. + +### Flexible Customizations: Create Custom Module + +B2B use cases often require more complex customer management, such as managing roles in a company with employees having different privileges. + +For more complex use cases, you can create a custom module that introduces data models like `Company`, `Employee`, and other relevant models. + +Then, you can link those companies to existing customers and groups, allowing you to benefit from existing features like price lists for specific customer groups. + +- [Create Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md): Learn how to create a module. +- [Define Module Links](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md): Define links between data models. + +*** + +## Create B2B Price List + +Price lists allow you to set different prices for each customer group, among other conditions. They're useful to override prices for custom use cases. + +For B2B use cases, you can use price lists to set different prices for each B2B customer group. Then, B2B customers can see different prices on the storefront based on their group. + +You can create a price list using the Medusa Admin or the Admin REST APIs. Make sure to set the B2B customer group(s) as a condition. + +- [Using Medusa Admin](https://docs.medusajs.com/user-guide/price-lists/create/index.html.md): Create price list using the Medusa Admin. +- [Using Admin API](https://docs.medusajs.com/api/admin#price-lists_postpricelists): Create price list using the REST APIs. + +*** + +## Customize Medusa Admin + +Based on your use case, you may need to customize the Medusa Admin to add new widgets or pages. + +For example, you may want to add a page to manage companies and their employees, or you may want to add a widget to show the company associated with a customer group. + +The Medusa Admin is an extensible application within your Medusa application. You can customize it by: + +- **Widgets**: Adding widgets to existing pages, such as the customer group page. +- **UI Routes**: Adding new pages to the Medusa Admin, such as a page to manage companies and employees. +- **Settings Pages**: Adding new pages to the Medusa Admin settings, such as a page to manage company settings. + +- [Create Admin Widget](https://docs.medusajs.com/docs/learn/fundamentals/admin/widgets/index.html.md): Add widgets into existing admin pages. +- [Create Admin UI Routes](https://docs.medusajs.com/docs/learn/fundamentals/admin/ui-routes/index.html.md): Add new pages to your Medusa Admin. + +[Create Admin Setting Page](https://docs.medusajs.com/docs/learn/fundamentals/admin/ui-routes#create-settings-page/index.html.md): Add new page to the Medusa Admin settings. + +*** + +## Customize or Build Storefront + +Medusa provides a Next.js Starter Storefront to use with your application. You can customize it to for your B2B use case, such as adding a login page for B2B customers or expanding the profile page to show the company associated with the customer. + +Alternatively, you can build your own storefront using the Medusa APIs. This headless approach gives you the flexibility to build a custom storefront without limitations on which tech stack you use, or the design of the storefront. + +In your storefront, you can use the publishable API key you associated with your B2B sales channel to ensure only B2B products are retrieved. + +- [Next.js Starter Storefront](https://docs.medusajs.com/nextjs-starter/index.html.md): Learn how to install and customize the Next.js Starter Storefront. +- [Storefront Development](https://docs.medusajs.com/storefront-development/index.html.md): Find guides to build your own storefront. + +[Use Publishable API Keys](https://docs.medusajs.com/api/store#publishable-api-key): Learn how to use the publishable API key in client requests. + + +# Implement Bundled Products in Medusa + +In this tutorial, you'll learn how to implement bundled products in Medusa. + +When you install a Medusa application, you get a fully-fledged commerce platform with a Framework for customization. The Medusa application's commerce features are built around [Commerce Modules](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/index.html.md), which are available out-of-the-box. + +Medusa natively supports [inventory kits](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/inventory/inventory-kit/index.html.md), which can be used to create bundled products. However, inventory kits don't support all features of bundled products, such as fulfilling the products in the bundle separately. + +In this tutorial, you'll use Medusa's customizable Framework to implement bundled products. By building the bundled products feature, you can expand on it based on what's necessary for your use case. + +You can follow this tutorial whether you're new to Medusa or an advanced Medusa developer. + +## Summary + +By following this tutorial, you'll learn how to: + +- Install and set up a Medusa application. +- Define models for bundled products. +- Link bundled products to Medusa's existing product model, allowing you to benefit from existing product features. +- Customize the add-to-cart flow to support bundled products. +- Customize the Next.js Starter Storefront to display bundled products. + +![Diagram illustrating the bundled products architecture](https://res.cloudinary.com/dza7lstvk/image/upload/v1745855513/Medusa%20Resources/bundled-products-overview_r5zejm.jpg) + +- [Bundled Products Repository](https://github.com/medusajs/examples/tree/main/bundled-products): Find the full code for this guide in this repository. +- [OpenApi Specs for Postman](https://res.cloudinary.com/dza7lstvk/raw/upload/v1746024108/OpenApi/Bundled_Products_vloupx.yaml): Import this OpenApi Specs file into tools like Postman. + +*** + +## Step 1: Install a Medusa Application + +### Prerequisites + +- [Node.js v20+](https://nodejs.org/en/download) +- [Git CLI tool](https://git-scm.com/downloads) +- [PostgreSQL](https://www.postgresql.org/download/) + +Start by installing the Medusa application on your machine with the following command: + +```bash +npx create-medusa-app@latest +``` + +You'll first be asked for the project's name. Then, when asked whether you want to install the [Next.js Starter Storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md), choose Yes. + +Afterward, the installation process will start, which will install the Medusa application in a directory with your project's name, and the Next.js Starter Storefront in a separate directory with the `{project-name}-storefront` name. + +The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). Learn more in [Medusa's Architecture documentation](https://docs.medusajs.com/docs/learn/introduction/architecture/index.html.md). + +Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. Afterward, you can log in with the new user and explore the dashboard. + +Check out the [troubleshooting guides](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/troubleshooting/create-medusa-app-errors/index.html.md) for help. + +*** + +## Step 2: Create Bundled Product Module + +In Medusa, you can build custom features in a [module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md). A module is a reusable package with functionalities related to a single feature or domain. Medusa integrates the module into your application without implications or side effects on your setup. + +In the module, you define the data models necessary for a feature and the logic to manage these data models. Later, you can build commerce flows around your module. + +In this step, you'll build a Bundled Product Module that defines the necessary data models to store and manage bundled products. + +Refer to the [Modules documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md) to learn more. + +### Create Module Directory + +Modules are created under the `src/modules` directory of your Medusa application. So, create the directory `src/modules/bundled-product`. + +### Create Data Models + +A data model represents a table in the database. You create data models using Medusa's Data Model Language (DML). It simplifies defining a table's columns, relations, and indexes with straightforward methods and configurations. + +Refer to the [Data Models documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules#1-create-data-model/index.html.md) to learn more. + +For the Bundled Product Module, you need to define two data models: + +- `Bundle` for the bundle itself. +- `BundleItem` for the items in the bundle. + +To create the `Bundle` data model, create the file `src/modules/bundled-product/models/bundle.ts` with the following content: + +```ts title="src/modules/bundled-product/models/bundle.ts" highlights={bundleHighlights} +import { model } from "@medusajs/framework/utils" +import { BundleItem } from "./bundle-item" + +export const Bundle = model.define("bundle", { + id: model.id().primaryKey(), + title: model.text(), + items: model.hasMany(() => BundleItem, { + mappedBy: "bundle", + }), +}) +``` + +You define the `Bundle` data model using the `model.define` method of the DML. It accepts the data model's table name as a first parameter, and the model's schema object as a second parameter. + +The `Bundle` data model has the following properties: + +- `id`: A unique ID for the bundle. +- `title`: The bundle's title. +- `items`: A one-to-many relation to the `BundleItem` data model, which you'll create next. + +Learn more about defining data model properties in the [Property Types documentation](https://docs.medusajs.com/docs/learn/fundamentals/data-models/properties/index.html.md). + +To create the `BundleItem` data model, create the file `src/modules/bundled-product/models/bundle-item.ts` with the following content: + +```ts title="src/modules/bundled-product/models/bundle-item.ts" highlights={bundleItemHighlights} +import { model } from "@medusajs/framework/utils" +import { Bundle } from "./bundle" + +export const BundleItem = model.define("bundle_item", { + id: model.id().primaryKey(), + quantity: model.number().default(1), + bundle: model.belongsTo(() => Bundle, { + mappedBy: "items", + }), +}) +``` + +The `BundleItem` data model has the following properties: + +- `id`: A unique ID for the bundle item. +- `quantity`: The quantity of the item in the bundle. It defaults to `1`. +- `bundle`: A many-to-one relation to the `Bundle` data model, which you defined earlier. + +Learn more about defining data model relations in the [Relations documentation](https://docs.medusajs.com/docs/learn/fundamentals/data-models/relationships/index.html.md). + +### Create Module's Service + +You now have the necessary data models in the Bundled Product Module, but you'll need to manage their records. You do this by creating a service in the module. + +A service is a TypeScript or JavaScript class that the module exports. In the service's methods, you can connect to the database, allowing you to manage your data models, or connect to a third-party service, which is useful if you're integrating with external services. + +Refer to the [Module Service documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules#2-create-service/index.html.md) to learn more. + +To create the Bundled Product Module's service, create the file `src/modules/bundled-product/service.ts` with the following content: + +```ts title="src/modules/bundled-product/service.ts" +import { MedusaService } from "@medusajs/framework/utils" +import { Bundle } from "./models/bundle" +import { BundleItem } from "./models/bundle-item" + +export default class BundledProductModuleService extends MedusaService({ + Bundle, + BundleItem, +}) { +} +``` + +The `BundledProductModuleService` extends `MedusaService` from the Modules SDK which generates a class with data-management methods for your module's data models. This saves you time on implementing Create, Read, Update, and Delete (CRUD) methods. + +So, the `BundledProductModuleService` class now has methods like `createBundles` and `retrieveBundleItem`. + +Find all methods generated by the `MedusaService` in [the Service Factory reference](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/service-factory-reference/index.html.md). + +### Export Module Definition + +The final piece to a module is its definition, which you export in an `index.ts` file at its root directory. This definition tells Medusa the name of the module and its service. + +So, create the file `src/modules/bundled-product/index.ts` with the following content: + +```ts title="src/modules/bundled-product/index.ts" +import { Module } from "@medusajs/framework/utils" +import BundledProductsModuleService from "./service" + +export const BUNDLED_PRODUCT_MODULE = "bundledProduct" + +export default Module(BUNDLED_PRODUCT_MODULE, { + service: BundledProductsModuleService, +}) +``` + +You use the `Module` function from the Modules SDK to create the module's definition. It accepts two parameters: + +1. The module's name, which is `bundledProduct`. The name can only contain alphanumeric characters and underscores. +2. An object with a required property `service` indicating the module's service. + +You also export the module's name as `BUNDLED_PRODUCT_MODULE` so you can reference it later. + +### Add Module to Medusa's Configurations + +Once you finish building the module, add it to Medusa's configurations to start using it. + +In `medusa-config.ts`, add a `modules` property and pass an array with your custom module: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // ... + modules: [ + { + resolve: "./src/modules/bundled-product", + }, + ], +}) +``` + +Each object in the `modules` array has a `resolve` property, whose value is either a path to the module's directory, or an `npm` package’s name. + +### Generate Migrations + +Since data models represent tables in the database, you define how they're created in the database with migrations. A migration is a TypeScript or JavaScript file that defines database changes made by a module. + +Refer to the [Migrations documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules#5-generate-migrations/index.html.md) to learn more. + +Medusa's CLI tool can generate the migrations for you. To generate a migration for the Bundled Product Module, run the following command in your Medusa application's directory: + +```bash +npx medusa db:generate bundledProduct +``` + +The `db:generate` command of the Medusa CLI accepts the name of the module to generate the migration for. You'll now have a `migrations` directory under `src/modules/bundled-product` that holds the generated migration. + +Then, to reflect these migrations on the database, run the following command: + +```bash +npx medusa db:migrate +``` + +The tables for the `Bundle` and `BundleItem` data models are now created in the database. + +*** + +## Step 3: Link Bundles to Medusa Products + +Medusa integrates modules into your application without implications or side effects by isolating modules from one another. This means you can't directly create relationships between data models in your module and data models in other modules. + +Instead, Medusa provides the mechanism to define links between data models, and retrieve and manage linked records while maintaining module isolation. Links are useful to define associations between data models in different modules, or extend a model in another module to associate custom properties with it. + +Refer to the [Module Isolation documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules/isolation/index.html.md) to learn more. + +In this step, you'll define a link between: + +- The `Bundle` data model in the Bundled Product Module and the `Product` data model in the Products Module. This link will allow you to benefit from existing product features, like prices, sales channels, and more. +- The `BundleItem` data model in the Bundled Product Module and the `Product` data model in the Products Module. This link will allow you to associate a bundle item with an existing product, where the customer chooses from their variants when purchasing the bundle. + +Refer to the [Product Module's data models reference](https://docs.medusajs.com/references/product/models/index.html.md) to learn more about available data models in the Products Module. + +### Bundle \<> Product Link + +You can define links between data models in a TypeScript or JavaScript file under the `src/links` directory. + +So, to define the link between a bundle and a product, create the file `src/links/bundle-product.ts` with the following content: + +```ts title="src/links/bundle-product.ts" +import { defineLink } from "@medusajs/framework/utils" +import ProductModule from "@medusajs/medusa/product" +import BundledProductsModule from "../modules/bundled-product" + +export default defineLink( + BundledProductsModule.linkable.bundle, + ProductModule.linkable.product +) +``` + +You define a link using the `defineLink` function from the Modules SDK. It accepts two parameters: + +1. An object indicating the first data model part of the link. A module has a special `linkable` property that contains link configurations for its data models. So, you can pass the link configurations for the `Bundle` data model from the Bundled Product module. +2. An object indicating the second data model part of the link. You pass the linkable configurations of the Product Module's `Product` data model. + +You'll later learn how to query and manage the linked records. + +### BundleItem \<> Product Link + +Next, you'll define the link between the `BundleItem` data model and the `Product` data model. Create the file `src/links/bundle-item-product.ts` with the following content: + +```ts title="src/links/bundle-item-product.ts" +import { defineLink } from "@medusajs/framework/utils" +import ProductModule from "@medusajs/medusa/product" +import BundledProductsModule from "../modules/bundled-product" + +export default defineLink( + { + linkable: BundledProductsModule.linkable.bundleItem, + isList: true, + }, + ProductModule.linkable.product +) +``` + +You define the link in the same way as the previous one, but you pass an object with a `isList` property set to `true` for the first parameter. This indicates that the link is a one-to-many relation, meaning that a product can be linked to multiple bundle items. + +### Sync Links to Database + +Medusa creates a table in the database for each link you define. So, you must run the migrations again to create the necessary tables: + +```bash +npx medusa db:migrate +``` + +This will create tables for both links in the database. The tables will later store the IDs of the linked records. + +Refer to the [Module Links](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md) documentation to learn more about defining links and link tables. + +*** + +## Step 4: Create Bundled Product Workflow + +You're now ready to start implementing bundled-product features. The first one you'll implement is the ability to create a bundled product. + +To build custom commerce features in Medusa, you create a [workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md). A workflow is a series of queries and actions, called steps, that complete a task. By using workflows, you can track their executions' progress, define roll-back logic, and configure other advanced features. + +So, in this section, you'll learn how to create a workflow that creates a bundled product. Later, you'll execute this workflow in an API route. + +Learn more about workflows in the [Workflows documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md). + +The workflow will have the following steps: + +- [createBundleStep](#createBundleStep): Create a bundle +- [createBundleItemStep](#createBundleItemStep): Create the bundle items +- [createProductsWorkflow](https://docs.medusajs.com/references/medusa-workflows/createProductsWorkflow/index.html.md): Create the Medusa product associated with the bundle +- [createRemoteLinkStep](https://docs.medusajs.com/references/helper-steps/createRemoteLinkStep/index.html.md): Create the link between the bundle and the Medusa product +- [createRemoteLinkStep](https://docs.medusajs.com/references/helper-steps/createRemoteLinkStep/index.html.md): Create the link between the bundle items and the Medusa products +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the created bundle and its items. + +You only need to implement the first two steps, as Medusa provides the rest in its `@medusajs/medusa/core-flows` package. + +### createBundleStep + +The first step of the workflow creates a bundle using the Bundled Product Module's service. + +To create the step, create the file `src/workflows/steps/create-bundle.ts` with the following content: + +```ts title="src/workflows/steps/create-bundle.ts" highlights={createBundleStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import BundledProductModuleService from "../../modules/bundled-product/service" +import { BUNDLED_PRODUCT_MODULE } from "../../modules/bundled-product" + +type CreateBundleStepInput = { + title: string +} + +export const createBundleStep = createStep( + "create-bundle", + async ({ title }: CreateBundleStepInput, { container }) => { + const bundledProductModuleService: BundledProductModuleService = + container.resolve(BUNDLED_PRODUCT_MODULE) + + const bundle = await bundledProductModuleService.createBundles({ + title, + }) + + return new StepResponse(bundle, bundle.id) + }, + async (bundleId, { container }) => { + if (!bundleId) { + return + } + const bundledProductModuleService: BundledProductModuleService = + container.resolve(BUNDLED_PRODUCT_MODULE) + + await bundledProductModuleService.deleteBundles(bundleId) + } +) +``` + +You create a step with `createStep` from the Workflows SDK. It accepts two parameters: + +1. The step's unique name, which is `create-bundle`. +2. An async function that receives two parameters: + - The step's input, which is in this case an object with the bundle's properties. + - An object that has properties including the [Medusa container](https://docs.medusajs.com/docs/learn/fundamentals/medusa-container/index.html.md), which is a registry of Framework and commerce tools that you can access in the step. + +In the step function, you resolve the Bundled Product Module's service from the Medusa container using its `resolve` method, passing it the module's name as a parameter. + +Then, you create the bundle using the `createBundles` method. As you remember, the Bundled Product Module's service extends the `MedusaService` which generates data-management methods for you. + +A step function must return a `StepResponse` instance. The `StepResponse` constructor accepts two parameters: + +1. The step's output, which is the bundle created. +2. Data to pass to the step's compensation function. + +Learn more about creating a step in the [Workflow documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md). + +#### Compensation Function + +The compensation function undoes the actions performed in a step. Then, if an error occurs during the workflow's execution, the compensation functions of executed steps are called to roll back the changes. This mechanism ensures data consistency in your application, especially as you integrate external systems. + +The compensation function accepts two parameters: + +1. The data passed from the step in the second parameter of `StepResponse`, which in this case is the ID of the created bundle. +2. An object that has properties including the [Medusa container](https://docs.medusajs.com/docs/learn/fundamentals/medusa-container/index.html.md). + +In the compensation function, you resolve the Bundled Product Module's service from the Medusa container and call the `deleteBundles` method to delete the bundle created in the step. + +Refer to the [Compensation Function documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/compensation-function/index.html.md) to learn more. + +### createBundleItemStep + +Next, you'll create the second step that creates the items in the bundle. + +To create the step, create the file `src/workflows/steps/create-bundle-items.ts` with the following content: + +```ts title="src/workflows/steps/create-bundle-items.ts" highlights={createBundleItemsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { BUNDLED_PRODUCT_MODULE } from "../../modules/bundled-product" +import BundledProductModuleService from "../../modules/bundled-product/service" + +type CreateBundleItemsStepInput = { + bundle_id: string + items: { + quantity: number + }[] +} + +export const createBundleItemsStep = createStep( + "create-bundle-items", + async ({ bundle_id, items }: CreateBundleItemsStepInput, { container }) => { + const bundledProductModuleService: BundledProductModuleService = + container.resolve(BUNDLED_PRODUCT_MODULE) + + const bundleItems = await bundledProductModuleService.createBundleItems( + items.map((item) => ({ + bundle_id, + quantity: item.quantity, + })) + ) + + return new StepResponse(bundleItems, bundleItems.map((item) => item.id)) + }, + async (itemIds, { container }) => { + if (!itemIds?.length) { + return + } + + const bundledProductModuleService: BundledProductModuleService = + container.resolve(BUNDLED_PRODUCT_MODULE) + + await bundledProductModuleService.deleteBundleItems(itemIds) + } +) +``` + +This step accepts the bundle ID and an array of bundle items to create. + +In the step, you resolve the Bundled Product Module's service to create the bundle items. Then, you return the created bundle items. + +You also pass the IDs of the created bundle items to the compensation function. In the compensation function, you delete the bundle items created in the step. + +### Create Workflow + +Now that you have all the necessary steps, you can create the workflow. + +To create the workflow, create the file `src/workflows/create-bundled-product.ts` with the following content: + +```ts title="src/workflows/create-bundled-product.ts" highlights={createBundledProductWorkflowHighlights} +import { CreateProductWorkflowInputDTO } from "@medusajs/framework/types" +import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { createBundleStep } from "./steps/create-bundle" +import { createBundleItemsStep } from "./steps/create-bundle-items" +import { createProductsWorkflow, createRemoteLinkStep, useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { BUNDLED_PRODUCT_MODULE } from "../modules/bundled-product" +import { Modules } from "@medusajs/framework/utils" + +export type CreateBundledProductWorkflowInput = { + bundle: { + title: string + product: CreateProductWorkflowInputDTO + items: { + product_id: string + quantity: number + }[] + } +} + +export const createBundledProductWorkflow = createWorkflow( + "create-bundled-product", + ({ bundle: bundleData }: CreateBundledProductWorkflowInput) => { + const bundle = createBundleStep({ + title: bundleData.title, + }) + + const bundleItems = createBundleItemsStep({ + bundle_id: bundle.id, + items: bundleData.items, + }) + + const bundleProduct = createProductsWorkflow.runAsStep({ + input: { + products: [bundleData.product], + }, + }) + + createRemoteLinkStep([{ + [BUNDLED_PRODUCT_MODULE]: { + bundle_id: bundle.id, + }, + [Modules.PRODUCT]: { + product_id: bundleProduct[0].id, + }, + }]) + + const bundleProducttemLinks = transform({ + bundleData, + bundleItems, + }, (data) => { + return data.bundleItems.map((item, index) => ({ + [BUNDLED_PRODUCT_MODULE]: { + bundle_item_id: item.id, + }, + [Modules.PRODUCT]: { + product_id: data.bundleData.items[index].product_id, + }, + })) + }) + + createRemoteLinkStep(bundleProducttemLinks).config({ + name: "create-bundle-product-items-links", + }) + + // retrieve bundled product with items + const { data } = useQueryGraphStep({ + entity: "bundle", + fields: ["*", "items.*"], + filters: { + id: bundle.id, + }, + }) + + return new WorkflowResponse(data[0]) + } +) +``` + +You create a workflow using `createWorkflow` from the Workflows SDK. It accepts the workflow's unique name as a first parameter. + +It accepts as a second parameter a constructor function, which is the workflow's implementation. The function can accept input, which in this case is an object holding the details of the bundle to create. + +In the workflow's constructor function, you: + +1. Create the bundle using the `createBundleStep`. +2. Create the bundle items using the `createBundleItemsStep`. +3. Create the Medusa product associated with the bundle using the `createProductsWorkflow`. +4. Create a link between the bundle and the Medusa product using the `createRemoteLinkStep`. + - To create a link, you pass an array of objects. The keys of each object are the module names, and the values are objects with the IDs of the records to link. +5. Use `transform` to prepare the data to link bundle items to products. + - You must use the `transform` function whenever you want to manipulate data in a workflow, as Medusa creates an internal representation of the workflow when the application starts, not when the workflow is executed. Learn more in the [Transform Data documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/variable-manipulation/index.html.md). +6. Create a link between the bundle items and the Medusa products using the `createRemoteLinkStep`. +7. Retrieve the bundle and its items using the `useQueryGraphStep`. + - `useQueryGraphStep` uses [Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md), which allows you to retrieve data across modules. + +A workflow must return an instance of `WorkflowResponse`. The `WorkflowResponse` constructor accepts the workflow's output as a parameter, which is the created bundle. + +You'll test out this API route in a later step when you customize the Medusa Admin dashboard. + +*** + +## Step 5: Create Bundled Product API Route + +Now that you have the logic to create a bundled product, you need to expose it so that frontend clients, such as the Medusa Admin, can use it. You do this by creating an [API route](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). + +An API Route is an endpoint that exposes commerce features to external applications and clients, such as admin dashboards or storefronts. You'll create an API route at the path `/admin/bundled-products` that executes the workflow from the previous step. + +Refer to the [API Routes documentation](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md) to learn more. + +### Implement API Route + +An API route is created in a `route.ts` file under a sub-directory of the `src/api` directory. The path of the API route is the file's path relative to `src/api`. + +So, to create an API route at the path `/admin/bundled-products`, create the file `src/api/admin/bundled-products/route.ts` with the following content: + +API routes starting with `/admin` are protected by default. So, only authenticated admin users can access them. + +```ts title="src/api/admin/bundled-products/route.ts" highlights={bundledProductsRouteHighlights} +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { z } from "zod" +import { + AdminCreateProduct, +} from "@medusajs/medusa/api/admin/products/validators" +import { + createBundledProductWorkflow, + CreateBundledProductWorkflowInput, +} from "../../../workflows/create-bundled-product" + +export const PostBundledProductsSchema = z.object({ + title: z.string(), + product: AdminCreateProduct(), + items: z.array(z.object({ + product_id: z.string(), + quantity: z.number(), + })), +}) + +type PostBundledProductsSchema = z.infer + +export async function POST( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) { + const { + result: bundledProduct, + } = await createBundledProductWorkflow(req.scope) + .run({ + input: { + bundle: req.validatedBody, + } as CreateBundledProductWorkflowInput, + }) + + res.json({ + bundled_product: bundledProduct, + }) +} +``` + +You first define a validation schema with [Zod](https://zod.dev/). You'll use this schema in a bit to enforce validation on requests sent to this API route. + +Since you export a `POST` route handler function, you expose a `POST` API route at `/admin/bundled-products`. The route handler function accepts two parameters: + +1. A request object with details and context on the request, such as body parameters or authenticated customer details. +2. A response object to manipulate and send the response. + +`AuthenticatedMedusaRequest` accepts the request body's type as a type argument. + +In the route handler function, you execute the `createBundledProductWorkflow` by invoking it, passing it the Medusa container (which is available on the `scope` property of the request object), then calling its `run` method. + +You pass the request body parameters as an input to the workflow. + +Finally, you return the created bundle in the response. + +### Add Validation Middleware + +Now that you have the API route, you need to enforce validation on requests send to the route. You can do this with a [middleware](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/middlewares/index.html.md). + +A middleware is a function executed when a request is sent to an API Route. It's executed before the route handler. + +Learn more in the [Middlewares documentation](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/middlewares/index.html.md). + +Middlewares are created in the `src/api/middlewares.ts` file. So create the file `src/api/middlewares.ts` with the following content: + +```ts title="src/api/middlewares.ts" highlights={middlewaresHighlights} +import { + defineMiddlewares, + validateAndTransformBody, +} from "@medusajs/framework/http" +import { PostBundledProductsSchema } from "./admin/bundled-products/route" + +export default defineMiddlewares({ + routes: [ + { + matcher: "/admin/bundled-products", + methods: ["POST"], + middlewares: [ + validateAndTransformBody(PostBundledProductsSchema), + ], + }, + ], +}) +``` + +To export the middlewares, you use the `defineMiddlewares` function. It accepts an object having a `routes` property, whose value is an array of middleware route objects. Each middleware route object has the following properties: + +- `method`: The HTTP methods the middleware applies to, which is in this case `POST`. +- `matcher`: The path of the route the middleware applies to. +- `middlewares`: An array of middleware functions to apply to the route. + - You apply the `validateAndTransformBody` that validates that the request body parameters match the Zod schema passed as a parameter. + +The create bundled product route is now ready for use. You'll use it in an upcoming step when you customize the Medusa Admin dashboard. + +*** + +## Step 6: Retrieve Bundles API Route + +Before you start customizing the Medusa Admin, you need an API route that retrieves all bundles. You'll use this API route to show the bundles in a table on the Medusa Admin dashboard. + +To create the API route, add the following at the end of `src/api/admin/bundled-products/route.ts`: + +```ts title="src/api/admin/bundled-products/route.ts" highlights={getBundledProductsRouteHighlights} +export async function GET( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) { + const query = req.scope.resolve("query") + + const { + data: bundledProducts, + metadata: { count, take, skip } = {}, + } = await query.graph({ + entity: "bundle", + ...req.queryConfig, + }) + + res.json({ + bundled_products: bundledProducts, + count: count || 0, + limit: take || 15, + offset: skip || 0, + }) +} +``` + +Since you export a `GET` route handler function, you expose a `GET` API route at `/admin/bundled-products`. + +In the route handler, you resolve [Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md) from the Medusa container. Then, you call its `graph` method to retrieve the bundles. + +Notice that you pass to `query.graph` the `req.queryConfig` object. This object contains default query configurations related to pagination and the fields to be retrieved. You'll learn how to set the query configurations in a bit. + +Finally, you return the bundles in the response with pagination parameters. + +### Add Query Configurations + +In the API route, you use the Query configurations to determine the fields to retrieve and pagination parameters. These can be configured in a middleware, allowing you to set the default value, but also allowing clients to modify them. + +To add the query configurations, add a new middleware object in `src/api/middlewares.ts`: + +```ts title="src/api/middlewares.ts" highlights={getBundledProductsMiddlewareHighlights} +// other imports... +import { validateAndTransformQuery } from "@medusajs/framework/http" +import { createFindParams } from "@medusajs/medusa/api/utils/validators" + +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/admin/bundled-products", + methods: ["GET"], + middlewares: [ + validateAndTransformQuery(createFindParams(), { + defaults: [ + "id", + "title", + "product.*", + "items.*", + "items.product.*", + ], + isList: true, + defaultLimit: 15, + }), + ], + }, + ], +}) +``` + +You apply the `validateAndTransformQuery` middleware on `GET` requests to `/admin/bundled-products`. It accepts the following parameters: + +1. A Zod schema to validate query parameters. You use Medusa's `createFindParams` function, which creates a Zod schema containing the following query parameters: + - `fields`: The fields to retrieve in a bundle. + - `limit`: The maximum number of bundles to retrieve. + - `offset`: The number of bundles to skip before retrieving the bundles. + - `order`: The fields to sort the result by. +2. An object of Query configurations that you accessed in the API route handler using `req.queryConfig`. It accepts the following parameters: + - `defaults`: The default fields and relations to retrieve. You retrieve the bundle, its linked product, and its items with their linked products. + - `isList`: Whether the API route returns a list of items. + - `defaultLimit`: The default number of items to retrieve in a page. + +Your API route is now ready for use. You'll test it out in the next step as you customize the Medusa Admin dashboard. + +*** + +## Step 7: Add Bundles Page to Medusa Admin + +Now that you have the necessary routes for admin users to manage and view bundled products, you'll customize the Medusa Admin to allow admin users to use these features. + +You can add a new page to the Medusa Admin dashboard using a [UI route](https://docs.medusajs.com/docs/learn/fundamentals/admin/ui-routes/index.html.md). A UI route is a React component that specifies the content to be shown in a new page in the Medusa Admin dashboard. + +You'll create a UI route to display the list of bundled products in the Medusa Admin. Later, you'll add a form to create a bundled product. + +Learn more in the [UI Routes documentation](https://docs.medusajs.com/docs/learn/fundamentals/admin/ui-routes/index.html.md). + +### Initialize JS SDK + +Medusa provides a [JS SDK](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/js-sdk/index.html.md) that you can use to send requests to the Medusa server from any client application, including your Medusa Admin customizations. + +The JS SDK is installed by default in your Medusa application. To configure it, create the file `src/admin/lib/sdk.ts` with the following content: + +```ts title="src/admin/lib/sdk.ts" +import Medusa from "@medusajs/js-sdk" + +export const sdk = new Medusa({ + baseUrl: "http://localhost:9000", + debug: process.env.NODE_ENV === "development", + auth: { + type: "session", + }, +}) +``` + +You create an instance of the JS SDK using the `Medusa` class from the JS SDK. You pass it an object having the following properties: + +- `baseUrl`: The base URL of the Medusa server. +- `debug`: A boolean indicating whether to log debug information into the console. +- `auth`: An object specifying the authentication type. When using the JS SDK for admin customizations, you use the `session` authentication type. + +### Create UI Route + +UI routes are created under the `src/admin/routes` directory in a `page.tsx` file. The file's path, relative to `src/admin/routes`, is used as the page's path in the Medusa Admin dashboard. + +So, to create a new page that shows the list of bundled products, create the file `src/admin/routes/bundled-products/page.tsx` with the following content: + +```tsx title="src/admin/routes/bundled-products/page.tsx" highlights={bundledProductsPageHighlights} +import { defineRouteConfig } from "@medusajs/admin-sdk" +import { CubeSolid } from "@medusajs/icons" + +const BundledProductsPage = () => { + // TODO add implementation +} + +export const config = defineRouteConfig({ + label: "Bundled Products", + icon: CubeSolid, +}) + +export default BundledProductsPage +``` + +In a UI route's file, you must export: + +1. A React component that defines the page's content. You'll add the content in a bit. +2. A configuration object that indicates the title and icon used in the sidebar for the page. + +Next, you'll use the [DataTable](https://docs.medusajs.com/ui/components/data-table/index.html.md) component from Medusa UI to show the list of bundled products in a table. + +Add the following before the `BundledProductsPage` component: + +```tsx title="src/admin/routes/bundled-products/page.tsx" highlights={bundledProductsPageHighlights2} +import { + Container, + Heading, + DataTable, + useDataTable, + createDataTableColumnHelper, + DataTablePaginationState, +} from "@medusajs/ui" +import { useQuery } from "@tanstack/react-query" +import { useMemo, useState } from "react" +import { sdk } from "../../lib/sdk" +import { Link } from "react-router-dom" + +type BundledProduct = { + id: string + title: string + product: { + id: string + } + items: { + id: string + product: { + id: string + title: string + } + quantity: number + }[] + created_at: Date + updated_at: Date +} + +const columnHelper = createDataTableColumnHelper() + +const columns = [ + columnHelper.accessor("id", { + header: "ID", + }), + columnHelper.accessor("title", { + header: "Title", + }), + columnHelper.accessor("items", { + header: "Items", + cell: ({ row }) => { + return row.original.items.map((item) => ( +
+ + {item.product.title} + {" "} + x {item.quantity} +
+ )) + }, + }), + columnHelper.accessor("product", { + header: "Product", + cell: ({ row }) => { + return ( + + View Product + + ) + }, + }), +] + +const limit = 15 +``` + +You define the table's columns using `createDataTableColumnHelper` from Medusa UI. The table has the following columns: + +- `ID`: The ID of the bundle. +- `Title`: The title of the bundle. +- `Items`: The items in the bundle. You show the title and quantity of each associated product with a link to its page. +- `Product`: A link to the Medusa product associated with the bundle. + +You also define a `limit` constant that indicates the maximum number of bundles to retrieve in a page. + +Learn more about the `createDataTableColumnHelper` function in the [DataTable documentation](https://docs.medusajs.com/ui/components/data-table#columns-preparation/index.html.md). + +Next, replace the `BundledProductsPage` with the following implementation: + +```tsx title="src/admin/routes/bundled-products/page.tsx" highlights={bundledProductsPageHighlights3} +const BundledProductsPage = () => { + const [pagination, setPagination] = useState({ + pageSize: limit, + pageIndex: 0, + }) + + const offset = useMemo(() => { + return pagination.pageIndex * limit + }, [pagination]) + + const { data, isLoading } = useQuery<{ + bundled_products: BundledProduct[] + count: number + }>({ + queryKey: ["bundled-products", offset, limit], + queryFn: () => sdk.client.fetch("/admin/bundled-products", { + method: "GET", + query: { + limit, + offset, + }, + }), + }) + + const table = useDataTable({ + columns, + data: data?.bundled_products ?? [], + isLoading, + pagination: { + state: pagination, + onPaginationChange: setPagination, + }, + rowCount: data?.count ?? 0, + }) + + return ( + + + + Bundled Products + + + + + + ) +} +``` + +In the component, you define a state variable `pagination` to manage the pagination state of the table, and a memoized variable `offset` to calculate the number of items to skip before retrieving the bundles based on the current page. + +Then, you use the `useQuery` hook from [Tanstack (React) Query](https://tanstack.com/query/latest) to retrieve the bundles from the API route. Tanstack Query is a data-fetching library with features like caching, pagination, and background updates. + +In the query function of `useQuery`, you use the JS SDK to send a `GET` request to `/admin/bundled-products` of the Medusa server. You pass the `limit` and `offset` query parameters to support paginating the bundles. + +Next, you initialize a table instance using the `useDataTable` hook from Medusa UI. Finally, you render the table in the page. + +### Test it Out + +To test out the UI route, start the Medusa application by running the following command: + +```bash npm2yarn +npm run dev +``` + +Then, open the Medusa Admin dashboard in your browser at `http://localhost:9000/app` and log in. + +After you log in, you'll see a new "Bundled Products" item in the sidebar. Click on it to open the Bundled Products page. + +The table will be empty as you haven't added any bundled products yet. You'll add the form to create a bundled product next. + +![Bundled Product page with empty table](https://res.cloudinary.com/dza7lstvk/image/upload/v1745919655/Medusa%20Resources/Screenshot_2025-04-29_at_12.30.30_PM_nvsezf.png) + +*** + +## Step 8: Create Bundled Product Form + +In this step, you'll add a form that allows admin users to create a bundled product. The form will be shown in a modal when the user clicks on a "Create" button in the Bundled Products page. + +The form will have the following fields: + +- The title of the bundle. +- For each bundle item, a selector to choose the associated product, and a quantity input field. + +### Create Form Component + +To create the component that shows the form, create the file `src/admin/components/create-bundled-product.tsx` with the following content: + +```tsx title="src/admin/components/create-bundled-product.tsx" highlights={createBundledProductComponentHighlights} collapsibleLines="1-13" expandButtonLabel="Show Imports" +import { + Button, + FocusModal, + Heading, + Input, + Label, + Select, + toast, +} from "@medusajs/ui" +import { useState, useRef, useCallback, useMemo } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { sdk } from "../lib/sdk" +import { HttpTypes } from "@medusajs/framework/types" + +const CreateBundledProduct = () => { + const [open, setOpen] = useState(false) + const [title, setTitle] = useState("") + const [items, setItems] = useState<{ + product_id: string | undefined + quantity: number + }[]>([ + { + product_id: undefined, + quantity: 1, + }, + ]) + // TODO fetch products +} +export default CreateBundledProduct +``` + +You create a `CreateBundledProduct` component that defines the following state variables: + +- `open`: A boolean indicating whether the modal is open or closed. +- `title`: The title of the bundle. +- `items`: An array of objects representing the items in the bundle. Each object has the following properties: + - `product`: The ID of the product. + - `quantity`: The quantity of the product in the bundle. + +### Fetch Products in Form Component + +Next, you need to retrieve the list of products in Medusa to show them in a selector input. Replace the `TODO` in the `CreateBundledProduct` with the following: + +```tsx title="src/admin/components/create-bundled-product.tsx" highlights={createBundledProductComponentHighlights2} +const [products, setProducts] = useState([]) +const productsLimit = 15 +const [currentProductPage, setCurrentProductPage] = useState(0) +const [productsCount, setProductsCount] = useState(0) +const hasNextPage = useMemo(() => { + return productsCount ? productsCount > productsLimit : true +}, +[productsCount, productsLimit]) +const queryClient = useQueryClient() +useQuery({ + queryKey: ["products"], + queryFn: async () => { + const { products, count } = await sdk.admin.product.list({ + limit: productsLimit, + offset: currentProductPage * productsLimit, + }) + setProductsCount(count) + setProducts((prev) => [...prev, ...products]) + return products + }, + enabled: hasNextPage, +}) + +const fetchMoreProducts = () => { + if (!hasNextPage) { + return + } + setCurrentProductPage(currentProductPage + 1) +} + +// TODO add creation logic +``` + +You define new state variables to store the products, the current page of products, and the total number of products. + +You also define a `hasNextPage` memoized variable to determine whether there are more products to load. + +Then, you use the `useQuery` hook from Tanstack Query to retrieve the products from the Medusa server. You call the `sdk.admin.product.list` method to retrieve the products, passing it the `limit` and `offset` query parameters. + +Lastly, you define a `fetchMoreProducts` function that increments the current page of products, which triggers retrieving more products. You'll call this function whenever the user scrolls to the end of the products list. + +### Add Creation Logic to Form Component + +Next, you'll define the logic to create the bundled product in the Medusa server once the user submits the form. + +Replace the new `TODO` with the following: + +```tsx title="src/admin/components/create-bundled-product.tsx" highlights={createBundledProductComponentHighlights3} +const { + mutateAsync: createBundledProduct, + isPending: isCreating, +} = useMutation({ + mutationFn: async (data: Record) => { + await sdk.client.fetch("/admin/bundled-products", { + method: "POST", + body: data, + }) + }, +}) + +const handleCreate = async () => { + try { + await createBundledProduct({ + title, + product: { + title, + options: [ + { + title: "Default", + values: ["default"], + }, + ], + status: "published", + variants: [ + { + title, + // You can set prices in the product's page + prices: [], + options: { + Default: "default", + }, + manage_inventory: false, + }, + ], + }, + items: items.map((item) => ({ + product_id: item.product_id, + quantity: item.quantity, + })), + }) + setOpen(false) + toast.success("Bundled product created successfully") + queryClient.invalidateQueries({ + queryKey: ["bundled-products"], + }) + setTitle("") + setItems([{ product_id: undefined, quantity: 1 }]) + } catch (error) { + toast.error("Failed to create bundled product") + } +} +``` + +You first define a mutation using the `useMutation` hook from Tanstack Query. The mutation is used to create the bundled product by sending a `POST` request to the `/admin/bundled-products` API route. + +Then, you define a `handleCreate` function that will be called when the user submits the form. In this function, you: + +- Create the bundled product using the `createBundledProduct` mutation. You pass it the details of the bundle, its product, and its items. + - Notice that you don't set the prices. You can use custom logic to set the prices, or [set the price](https://docs.medusajs.com/user-guide/products/variants#edit-product-variant-prices/index.html.md) from the bundle's associated product page. +- Close the modal and show a success message using the `toast` component from Medusa UI. + +### Add Component for Each Item in the Form + +Before adding the UI for the form, you'll add a component that renders the form fields for each item in the bundle. You'll later render this as part of the form UI. + +In the same file, add the following after the `CreateBundledProduct` component: + +```tsx title="src/admin/components/create-bundled-product.tsx" highlights={createBundledProductComponentHighlights4} +type BundledProductItemProps = { + item: { + product_id: string | undefined, + quantity: number, + } + index: number + setItems: React.Dispatch> + products: HttpTypes.AdminProduct[] | undefined + fetchMoreProducts: () => void + hasNextPage: boolean +} + +const BundledProductItem = ({ + item, + index, + setItems, + products, + fetchMoreProducts, + hasNextPage, +}: BundledProductItemProps) => { + const observer = useRef( + new IntersectionObserver( + (entries) => { + if (!hasNextPage) { + return + } + const first = entries[0] + if (first.isIntersecting) { + fetchMoreProducts() + } + }, + { threshold: 1 } + ) + ) + + const lastOptionRef = useCallback( + (node: HTMLDivElement) => { + if (!hasNextPage) { + return + } + if (observer.current) { + observer.current.disconnect() + } + if (node) { + observer.current.observe(node) + } + }, + [hasNextPage] + ) + + return ( +
+ Item {index + 1} + +
+ + + setItems((items) => + items.map((item, i) => { + return i === index + ? { ...item, quantity: parseInt(e.target.value) } + : item + }) + ) + } + /> +
+
+ ) +} +``` + +You define a `BundledProductItem` component that accepts the following props: + +- `item`: The item in the bundle as stored in the `items` state variable. +- `index`: The index of the item in the `items` state variable. +- `setItems`: The state setter function to update the `items` state variable. +- `products`: The list of products retrieved from the Medusa server. +- `fetchMoreProducts`: The function to fetch more products when the user scrolls to the end of the list. +- `hasNextPage`: A boolean indicating whether there are more products to load. + +In the component, you render the selector field using the [Select](https://docs.medusajs.com/ui/components/select/index.html.md) component from Medusa UI. You show the products as options in the select, and update the product ID in the `items` state variable whenever the user selects a product. + +You also observe the last option in the list of products using the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). This allows you to fetch more products when the user scrolls to the end of the list. + +Finally, you render an input field for the quantity of the item in the bundle. You update the quantity in the `items` state variable whenever the user changes it. + +### Add Form UI + +Now that you have the component to render each item in the bundle, you can add the form UI in the `CreateBundledProduct` component. + +In `CreateBundledProduct`, add the following `return` statement + +```tsx title="src/admin/components/create-bundled-product.tsx" +return ( + + + + + + +
+ Create Bundled Product +
+
+ +
+
+
+ + setTitle(e.target.value)} + /> +
+
+ Bundle Items + {items.map((item, index) => ( + + ))} + +
+
+
+
+ +
+ + +
+
+
+
+) +``` + +You use the [FocusModal](https://docs.medusajs.com/ui/components/focus-modal/index.html.md) component from Medusa UI to show the form in a modal. The modal is opened when the "Create" button is clicked. + +In the modal, you show an input field for the bundle title, and you show the list of bundle items using the `BundledProductItem` component. You also add a button to add new items to the bundle. + +Finally, you show a "Create Bundle" button that calls the `handleCreate` function when clicked to create the bundle. + +### Add Form to Bundled Products Page + +Now that the form component is ready, you'll add it to the Bundled Products page. This will show the button to open the modal with the form. + +In `src/admin/routes/bundled-products/page.tsx`, add the following import at the top of the file: + +```tsx title="src/admin/routes/bundled-products/page.tsx" +import CreateBundledProduct from "../../components/create-bundled-product" +``` + +Then, in the `DataTable.Toolbar` component, add the `CreateBundledProduct` component after the heading: + +```tsx title="src/admin/routes/bundled-products/page.tsx" highlights={[["3"]]} + + Bundled Products + + +``` + +This will show the button to open the form at the right side of the page's header. + +### Test it Out + +To test out the form, start the Medusa application by running the following command: + +```bash npm2yarn +npm run dev +``` + +Then, open the Medusa Admin dashboard in your browser at `http://localhost:9000/app`, log in, and open the Bundled Products page. + +Before creating the bundle, you may want to create the products in that bundle first. For example, if you're creating a "Camera Bundle", create "Camera" and "Camera Bag" products first. + +You'll see a new "Create" button at the top right. Click on it to open the modal with the form. + +![Create button shown at the top right of the Bundled Products page](https://res.cloudinary.com/dza7lstvk/image/upload/v1745922803/Medusa%20Resources/Screenshot_2025-04-29_at_1.32.36_PM_yoo23i.png) + +In the modal: + +- Enter a title for the bundle. This title will also be used to create the associated product. +- For each item: + - Select a product from the dropdown. You can scroll to the end of the list to load more products. + - Enter a quantity for the item. + - To add a new item, click on the "Add Item" button. +- Once you're done, click on the "Create Bundle" button to create the bundle. + +![Create bundled product form](https://res.cloudinary.com/dza7lstvk/image/upload/v1745923393/Medusa%20Resources/Screenshot_2025-04-29_at_1.42.12_PM_mdyzsi.png) + +After you create the bundle, the modal will close, and you can see the bundle in the table. + +### Edit Associated Product + +Once you have a bundle, you can go to its associated product page using the "View Product" link in the table. + +In the associated product's page, you should: + +- Set the sales channel that the product is available in to ensure it's available for sale. +- Set the shipping profile the product belongs to. This will allow customers to select the appropriate shipping option for the bundle during checkout. +- You can optionally edit other product details, such as the title, description, and images. + +Learn more about editing a product in the [User Guide](https://docs.medusajs.com/user-guide/products/edit/index.html.md) + +![Associated product page](https://res.cloudinary.com/dza7lstvk/image/upload/v1745923661/Medusa%20Resources/Screenshot_2025-04-29_at_1.46.52_PM_iuplxc.png) + +*** + +## Step 9: Add Bundled Product to Cart + +Now that you have bundled products, you need to support adding them to the cart. + +In the storefront, when the customer adds the bundle to the cart, they'll select the variant for each item. For example, they can choose a "Black" or "Blue" camera bag. + +So, you need to build a flow that adds the chosen product variants of the bundle's items to the cart. You'll add the variants with their default price and the quantity specified in the bundle. + +You can customize this logic to fit your needs, such as adding the bundle as a single item in the cart with its total price, or setting custom price for each of the items. + +To implement the add-to-cart logic for bundled products, you will: + +- Create a workflow that implements the logic. +- Execute the workflow in an API route for storefronts. + +### Create Workflow + +The add-to-cart workflow for bundled products has the following steps: + +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the details of a bundle, its items, and their products and variants. +- [prepareBundleCartDataStep](#prepareBundleCartDataStep): Validate and prepare the items to be added to the cart. +- [addToCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/addToCartWorkflow/index.html.md): Add the items in the bundle to the cart. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the details of the cart. + +You only need to implement the second step, as the other steps are provided by Medusa's `@medusajs/medusa/core-flows` package. + +#### a. prepareBundleCartDataStep + +The second step of the workflow validates that the customer chose valid variants for each bundle item, and returns the items to be added to the cart. + +To create the step, create the file `src/workflows/steps/prepare-bundle-cart-data.ts` with the following content: + +```ts title="src/workflows/steps/prepare-bundle-cart-data.ts" highlights={prepareBundleCartDataStepHighlights} collapsibleLines="1-6" expandButtonLabel="Show Imports" +import { InferTypeOf, ProductDTO } from "@medusajs/framework/types" +import { Bundle } from "../../modules/bundled-product/models/bundle" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { MedusaError } from "@medusajs/framework/utils" +import { BundleItem } from "../../modules/bundled-product/models/bundle-item" + +type BundleItemWithProduct = InferTypeOf & { + product: ProductDTO +} + +export type PrepareBundleCartDataStepInput = { + bundle: InferTypeOf & { + items: BundleItemWithProduct[] + } + quantity: number + items: { + item_id: string + variant_id: string + }[] +} + +export const prepareBundleCartDataStep = createStep( + "prepare-bundle-cart-data", + async ({ bundle, quantity, items }: PrepareBundleCartDataStepInput) => { + const bundleItems = bundle.items.map((item: BundleItemWithProduct) => { + const selectedItem = items.find((i) => i.item_id === item.id) + if (!selectedItem) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `No variant selected for bundle item ${item.id}` + ) + } + const variant = item.product.variants.find((v) => + v.id === selectedItem.variant_id + ) + if (!variant) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Variant ${ + selectedItem.variant_id + } is invalid for bundle item ${item.id}` + ) + } + return { + variant_id: selectedItem.variant_id, + quantity: item.quantity * quantity, + metadata: { + bundle_id: bundle.id, + quantity: quantity, + }, + } + }) + + return new StepResponse(bundleItems) + } +) +``` + +The step receives as an input the bundle's details, the quantity of the bundle to add to the cart, and the selected variants for each item in the bundle. + +In the step, you throw an error if an item in the bundle doesn't have a selected variant, or if the selected variant is invalid for that item. + +Otherwise, you return an array of objects representing the items to be added to the cart. Each object has the following properties: + +- `variant_id`: The ID of the selected variant to add to the cart. +- `quantity`: The quantity of the variant to add to the cart. This is calculated by multiplying the quantity of the item in the bundle with the quantity of the bundle to add to the cart. +- `metadata`: A line item in the cart has a `metadata` property that can be used to store custom key-value pairs. You store in it the ID of the bundle and its quantity that was added to the cart. This will be useful later when you want to retrieve the item's bundle. + +#### Using Custom Prices + +If you want to add the items to the cart with custom prices, you can modify the returned object in the loop to include a `unit_price` property. For example: + +```ts highlights={[["4"]]} +return { + variant_id: selectedItem.variant_id, + quantity: item.quantity * quantity, + unit_price: 100, + metadata: { + bundle_id: bundle.id, + quantity: quantity, + }, +} +``` + +The item will then be added to the cart with that price. Note that the currency is based on the cart's currency. + +For example, if the cart's currency is `usd`, then you're adding an item to the cart at the price `$100`. + +#### b. Implement the Workflow + +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: + +```ts title="src/workflows/add-bundle-to-cart.ts" highlights={addBundleToCartWorkflowHighlights} collapsibleLines="1-14" expandButtonLabel="Show Imports" +import { + createWorkflow, + transform, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { + addToCartWorkflow, + useQueryGraphStep, +} from "@medusajs/medusa/core-flows" +import { + prepareBundleCartDataStep, + PrepareBundleCartDataStepInput, +} from "./steps/prepare-bundle-cart-data" + +type AddBundleToCartWorkflowInput = { + cart_id: string + bundle_id: string + quantity: number + items: { + item_id: string + variant_id: string + }[] +} + +export const addBundleToCartWorkflow = createWorkflow( + "add-bundle-to-cart", + ({ cart_id, bundle_id, quantity, items }: AddBundleToCartWorkflowInput) => { + const { data } = useQueryGraphStep({ + entity: "bundle", + fields: [ + "id", + "items.*", + "items.product.*", + "items.product.variants.*", + ], + filters: { + id: bundle_id, + }, + options: { + throwIfKeyNotFound: true, + }, + }) + + const itemsToAdd = prepareBundleCartDataStep({ + bundle: data[0], + quantity, + items, + } as unknown as PrepareBundleCartDataStepInput) + + addToCartWorkflow.runAsStep({ + input: { + cart_id, + items: itemsToAdd, + }, + }) + + const { data: updatedCarts } = useQueryGraphStep({ + entity: "cart", + filters: { id: cart_id }, + fields: ["id", "items.*"], + }).config({ name: "refetch-cart" }) + + return new WorkflowResponse(updatedCarts[0]) + } +) +``` + +The workflow accepts as an input the cart's ID, the bundle's ID, the quantity of the bundle to add to the cart, and the selected variants for each item in the bundle. + +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`. +- Add the items to the cart using the `addToCartWorkflow`. +- Retrieve the updated cart using the `useQueryGraphStep`. + +Finally, you return the updated cart. + +### Create API Route + +You'll now create the API route that exposes the workflow's functionalities to storefronts. + +To create the API route, create the file `src/api/store/carts/[id]/line-item-bundles/route.ts` with the following content: + +```ts title="src/api/store/carts/[id]/line-item-bundles/route.ts" collapsibleLines="1-6" expandButtonLabel="Show Imports" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { z } from "zod" +import { + addBundleToCartWorkflow, +} from "../../../../../workflows/add-bundle-to-cart" + +export const PostCartsBundledLineItemsSchema = z.object({ + bundle_id: z.string(), + quantity: z.number().default(1), + items: z.array(z.object({ + item_id: z.string(), + variant_id: z.string(), + })), +}) + +type PostCartsBundledLineItemsSchema = z.infer< + typeof PostCartsBundledLineItemsSchema +> + +export async function POST( + req: MedusaRequest, + res: MedusaResponse +) { + const { result: cart } = await addBundleToCartWorkflow(req.scope) + .run({ + input: { + cart_id: req.params.id, + bundle_id: req.validatedBody.bundle_id, + quantity: req.validatedBody.quantity || 1, + items: req.validatedBody.items, + }, + }) + + res.json({ + cart, + }) +} +``` + +You first define a Zod schema to validate the request body. The schema has the following properties: + +- `bundle_id`: The ID of the bundle to add to the cart. +- `quantity`: The quantity of the bundle to add to the cart. This is optional and defaults to `1`. +- `items`: An array of objects representing the selected variants for each item in the bundle. Each object has the following properties: + - `item_id`: The ID of the item in the bundle. + - `variant_id`: The ID of the selected variant for that item. + +Then, you export a `POST` route handler, which exposes a `POST` API route at `/store/carts/:id/line-item-bundles`. + +In the route handler, you execute the `addBundleToCartWorkflow` workflow. Finally, you return the cart's details in the response. + +### Add Validation Middleware + +Lastly, you need to add the middleware that enforces the validation of incoming request bodies. + +In `src/api/middlewares.ts`, add a new middleware object to the `routes` array: + +```ts title="src/api/middlewares.ts" +// other imports... +import { + PostCartsBundledLineItemsSchema, +} from "./store/carts/[id]/line-item-bundles/route" + +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/store/carts/:id/line-item-bundles", + methods: ["POST"], + middlewares: [ + validateAndTransformBody(PostCartsBundledLineItemsSchema), + ], + }, + ], +}) +``` + +This middleware will validate the request body against the `PostCartsBundledLineItemsSchema` schema before executing the route handler. + +You can now use the API route to add bundles to the cart. You'll test it out in the upcoming sections when you customize the Next.js Starter Storefront. + +*** + +## Step 10: Retrieve Bundled Product API Route + +Before customizing the storefront, you'll create an API route to retrieve the details of a bundled product. This will be useful to show the bundle's details in the storefront. + +To create the API route, create the file `src/api/store/bundle-products/[id]/route.ts` with the following content: + +```ts title="src/api/store/bundle-products/[id]/route.ts" highlights={bundleProductsRouteHighlights} +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { QueryContext } from "@medusajs/framework/utils" + +export async function GET( + req: MedusaRequest, + res: MedusaResponse +) { + const { id } = req.params + const query = req.scope.resolve("query") + const { currency_code, region_id } = req.query + + const { data } = await query.graph({ + entity: "bundle", + fields: [ + "*", + "items.*", + "items.product.*", + "items.product.options.*", + "items.product.options.values.*", + "items.product.variants.*", + "items.product.variants.calculated_price.*", + "items.product.variants.options.*", + ], + filters: { + id, + }, + context: { + items: { + product: { + variants: { + calculated_price: QueryContext({ + region_id, + currency_code, + }), + }, + }, + }, + }, + + }, { + throwIfKeyNotFound: true, + }) + + res.json({ + bundle_product: data[0], + }) +} +``` + +You export a `GET` route handler, which exposes a `GET` API route at `/store/bundle-products/:id`. + +In the route handler, you resolve [Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md) from the Medusa container. + +Then, you use Query to retrieve the bundle with its items and their products, variants, and options. These are useful to display to the customer the options for each product to select from, which will result in selecting a variant for a bundle item. + +To retrieve the correct price for each variant, you also pass a [Query Context](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query-context/index.html.md) with the region ID and currency code that are passed as query parameters. This ensures that the prices are shown accurately to the customer. + +Refer to the [Get Product Variant Prices](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/product/guides/price/index.html.md) guide to learn more about how to retrieve the prices of a product variant. + +Finally, you return the bundle's details in the response. + +You'll use this API route next as you customize the storefront. + +*** + +## Step 11: Show Bundled Product Details in Storefront + +In this step, you'll customize the [Next.js Starter Storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md) you installed with the Medusa application to show a bundled product's items. + +The Next.js Starter Storefront was installed in a separate directory from Medusa. The directory's name is `{your-project}-storefront`. + +So, if your Medusa application's directory is `medusa-bundled-products`, you can find the storefront by going back to the parent directory and changing to the `medusa-bundled-products-storefront` directory: + +```bash +cd ../medusa-bundled-products-storefront # change based on your project name +``` + +### Add Function to Retrieve Bundled Product + +You'll start by adding a server action function that retrieves the details of a bundled product. + +In `src/lib/data/products.ts`, add the following at the end of the file: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" highlights={getBundleProductHighlights} +export type BundleProduct = { + id: string + title: string + product: { + id: string + thumbnail: string + title: string + handle: string + } + items: { + id: string + title: string + product: HttpTypes.StoreProduct + }[] +} + +export const getBundleProduct = async (id: string, { + currency_code, + region_id, +}: { + currency_code?: string + region_id?: string +}) => { + const headers = { + ...(await getAuthHeaders()), + } + + return sdk.client.fetch<{ + bundle_product: BundleProduct + }>(`/store/bundle-products/${id}`, { + method: "GET", + headers, + query: { + currency_code, + region_id, + }, + }) +} +``` + +You define a `BundledProduct` type that represents the structure of a bundled product. + +Then, you define a `getBundleProduct` function that retrieves the bundle's details from the API route you created in the previous step. + +### Retrieve Bundle with Product + +Since a bundle is linked to a Medusa product, you can modify the request that retrieves the Medusa product to retrieve its associated bundle, if there's any. + +By retrieving the bundle's details, you can check which Medusa product is a bundled product, then retrieve its full bundle details. + +To retrieve a product's bundle details, first, change the signature of the `listProducts` function in `src/lib/data/products.ts` to the following: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" highlights={listProductsHighlights} +export const listProducts = async ({ + pageParam = 1, + queryParams, + countryCode, + regionId, +}: { + pageParam?: number + queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams + countryCode?: string + regionId?: string +}): Promise<{ + response: { products: (HttpTypes.StoreProduct & { + bundle?: Omit + })[]; count: number } + nextPage: number | null + queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams +}> => { + // ... +} +``` + +You modify the response type to possibly include the bundle details (without the `items`) in each product. + +Next, find the `sdk.client.fetch` call in `listProducts` and replace the type argument of `fetch` with the following: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" highlights={fetchProductsHighlight} +return sdk.client + .fetch<{ products: (HttpTypes.StoreProduct & { + bundle?: Omit + })[]; count: number }>( + // ... + ) +``` + +This will ensure that the response from the API route is typed correctly. + +Then, in `src/app/[countryCode]/(main)/products/[handle]/page.tsx`, add the following import at the top of the file: + +```ts title="src/app/[countryCode]/(main)/products/[handle]/page.tsx" badgeLabel="Storefront" badgeColor="blue" +import { getBundleProduct } from "@lib/data/products" +``` + +After that, in the `ProductPage` component in the same file, find the declaration of `pricedProduct` and update the query parameters passed to `listProducts`: + +```ts title="src/app/[countryCode]/(main)/products/[handle]/page.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["5"]]} +const pricedProduct = await listProducts({ + countryCode: params.countryCode, + queryParams: { + handle: params.handle, + fields: "*bundle", + }, +}).then(({ response }) => response.products[0]) +``` + +You add the `fields` query parameter an set it to `*bundle`. This will ensure that the bundle details are included in the retrieved product objects. + +Next, after the `if` condition that checks if `pricedProduct` isn't `undefined`, add the following code: + +```ts title="src/app/[countryCode]/(main)/products/[handle]/page.tsx" badgeLabel="Storefront" badgeColor="blue" +const bundleProduct = pricedProduct.bundle ? + await getBundleProduct(pricedProduct.bundle.id, { + currency_code: region.currency_code, + region_id: region.id, + }) : null +``` + +This will retrieve the full bundled product details if the product is associated with a bundle. + +### Add Bundle to Cart Function + +Next, you'll add a function that adds the bundle to the cart using the API route you created in the previous step. + +In `src/lib/data/cart.ts`, add the following function at the end of the file: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="blue" +export async function addBundleToCart({ + bundleId, + quantity, + countryCode, + items, +}: { + bundleId: string + quantity: number + countryCode: string + items: { + item_id: string + variant_id: string + }[] +}) { + if (!bundleId) { + throw new Error("Missing bundle ID when adding to cart") + } + + const cart = await getOrSetCart(countryCode) + + if (!cart) { + throw new Error("Error retrieving or creating cart") + } + + const headers = { + ...(await getAuthHeaders()), + } + + await sdk.client.fetch( + `/store/carts/${cart.id}/line-item-bundles`, + { + method: "POST", + body: { + bundle_id: bundleId, + quantity, + items, + }, + headers, + }) + .then(async () => { + const cartCacheTag = await getCacheTag("carts") + revalidateTag(cartCacheTag) + + const fulfillmentCacheTag = await getCacheTag("fulfillment") + revalidateTag(fulfillmentCacheTag) + }) + .catch(medusaError) +} +``` + +You define the `addBundleToCart` function that sends a `POST` request to the API route you created in the previous step. + +The request body includes the bundle ID, quantity, and selected variants for each item in the bundle. + +### Show Bundle Item Selection Actions + +You'll now add a component that shows for bundled product their items and allow the customer to select the product variant for each item, then add it to the cart. + +#### a. Add Bundle Actions Component + +To create the component, create the file `src/modules/products/components/bundle-actions/index.tsx` with the following content: + +```tsx title="src/modules/products/components/bundle-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={bundleActionsComponentHighlights} collapsibleLines="1-13" expandButtonLabel="Show Imports" +"use client" + +import { addBundleToCart } from "@lib/data/cart" +import { HttpTypes } from "@medusajs/types" +import { Button } from "@medusajs/ui" +import OptionSelect from "@modules/products/components/product-actions/option-select" +import { BundleProduct } from "@lib/data/products" +import { isEqual } from "lodash" +import { useParams } from "next/navigation" +import { useEffect, useMemo, useState } from "react" +import ProductPrice from "../product-price" +import Thumbnail from "../thumbnail" + +type BundleActionsProps = { + bundle: BundleProduct +} + +const optionsAsKeymap = ( + variantOptions: HttpTypes.StoreProductVariant["options"] +) => { + return variantOptions?.reduce((acc: Record, varopt: any) => { + acc[varopt.option_id] = varopt.value + return acc + }, {}) +} + +export default function BundleActions({ + bundle, +}: BundleActionsProps) { + const [productOptions, setProductOptions] = useState< + Record> + >({}) + const [isAdding, setIsAdding] = useState(false) + const countryCode = useParams().countryCode as string + + // TODO retrieve and set selected variants and options +} +``` + +First, you define an `optionsAsKeymap` function that converts the product variant options into a key-value map. This is useful to later compare the selected options with the available options. + +Then, you define the `BundleActions` component that accepts a `bundle` prop. In the component, you define: + +- `productOptions`: A state variable that stores the selected options for each product in the bundle. The key is the product ID, and the value is a key-value map of the selected options. +- `isAdding`: A state variable that indicates whether the bundle is being added to the cart. +- `countryCode`: The country code from the URL parameters. + +#### b. Selected Variants and Options Logic + +Next, you'll add the logic to retrieve and set the selected variants and options for each product in the bundle. + +In `BundleActions`, replace the `TODO` with the following: + +```tsx title="src/modules/products/components/bundle-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={bundleActionsComponentHighlights2} +// For each product, if it has only 1 variant, preselect it +useEffect(() => { + const initialOptions: Record> = {} + bundle.items.forEach((item) => { + if (item.product.variants?.length === 1) { + const variantOptions = optionsAsKeymap(item.product.variants[0].options) + initialOptions[item.product.id] = variantOptions ?? {} + } else { + initialOptions[item.product.id] = {} + } + }) + setProductOptions(initialOptions) +}, [bundle.items]) + +const selectedVariants = useMemo(() => { + return bundle.items.map((item) => { + if (!item.product.variants || item.product.variants.length === 0) {return undefined} + + return item.product.variants.find((v) => { + const variantOptions = optionsAsKeymap(v.options) + return isEqual(variantOptions, productOptions[item.product.id]) + }) + }) +}, [bundle.items, productOptions]) + +const setOptionValue = (productId: string, optionId: string, value: string) => { + setProductOptions((prev) => ({ + ...prev, + [productId]: { + ...prev[productId], + [optionId]: value, + }, + })) +} + +const allVariantsSelected = useMemo(() => { + return selectedVariants.every((v) => v !== undefined) +}, [selectedVariants]) + +// TODO handle add to cart +``` + +In the `useEffect` hook, you check if each product in the bundle has only one variant. If it does, you preselect that variant's options. This ensures the customer doesn't need to select the options if there's only one variant available. + +Then, you define a `selectedVariants` variable that stores the selected variants for each product in the bundle. A selected variant is inferred if all options of a product are selected. + +You also define a `setOptionValue` function that updates the selected options for a product. You'll trigger this function when the customer selects an option. + +Finally, you define an `allVariantsSelected` variable that indicates whether all variants are selected. + +#### c. Handle Add to Cart + +Next, you'll add a function that is triggered when the add-to-cart button is clicked. + +Replace the `TODO` in the `BundleActions` component with the following code: + +```tsx title="src/modules/products/components/bundle-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" +const handleAddToCart = async () => { + if (!allVariantsSelected) {return} + + setIsAdding(true) + await addBundleToCart({ + bundleId: bundle.id, + quantity: 1, + countryCode, + items: bundle.items.map((item, index) => ({ + item_id: item.id, + variant_id: selectedVariants[index]?.id ?? "", + })), + }) + setIsAdding(false) +} +``` + +The `handleAddToCart` function adds the bundle to the cart if all variants have been selected. It uses the `addBundleToCart` function you created in the previous step. + +#### d. Customize the ProductPrice Component + +Before you render the component in `BundleActions`, you'll make a small adjustment to the `ProductPrice` component to allow passing a CSS class. + +In `src/modules/products/components/product-price/index.tsx`, add a `className` prop to the `ProductPrice` component: + +```tsx title="src/modules/products/components/product-price/index.tsx" badgeLabel="Storefront" badgeColor="blue" +export default function ProductPrice({ + // ... + className, +}: { + // ... + className?: string +}) { + // ... +} +``` + +Then, in the `return` statement, pass the `className` prop in the classes of the first `span` child of the wrapper `div`: + +```tsx title="src/modules/products/components/product-price/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["5"]]} +
+ + {/* ... */} + +
+``` + +#### e. Render the Component + +Finally, you'll render the component that shows the bundle's items and allows the customer to select the product variant for each item. + +Add the following `return` statement to the `BundleActions` component: + +```tsx title="src/modules/products/components/bundle-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" +return ( +
+

Items in Bundle

+
+ {bundle.items.map((item, index) => ( +
+
+ +
+

{item.product.title}

+ +
+
+ + {(item.product.variants?.length ?? 0) > 1 && ( +
+ {(item.product.options || []).map((option) => ( +
+ + setOptionValue(item.product.id, optionId, value) + } + title={option.title ?? ""} + disabled={isAdding} + /> +
+ ))} +
+ )} +
+ ))} +
+ + +
+) +``` + +You show the bundle's items in cards. For each item, you show the product's thumbnail, title, and price. + +If a product has multiple options, you show the options as buttons that the customer can select from. + +Finally, you show an add-to-cart button that is disabled if not all items have selected variants, or if the bundle is being added to the cart. + +### Use BundleActions Component in Product Page + +You'll show the `BundleActions` component in the product page if the product is a bundled product. + +First, in `src/modules/products/templates/product-actions-wrapper/index.tsx` add the following imports at the top of the file: + +```tsx title="src/modules/products/templates/product-actions-wrapper/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { BundleProduct } from "@lib/data/products" +import BundleActions from "@modules/products/components/bundle-actions" +``` + +Then, add a `bundle` prop to the `ProductActionsWrapper` component: + +```tsx title="src/modules/products/templates/product-actions-wrapper/index.tsx" badgeLabel="Storefront" badgeColor="blue" +export default async function ProductActionsWrapper({ + // ... + bundle, +}: { + // ... + bundle?: BundleProduct +}) { + // ... +} +``` + +Finally, add the following before the `ProductActionsWrapper` component's `return` statement: + +```tsx title="src/modules/products/templates/product-actions-wrapper/index.tsx" badgeLabel="Storefront" badgeColor="blue" +if (bundle) { + return +} +``` + +This will show the `BundleActions` component if the `bundle` prop is set. + +Next, you need to pass the `bundle` prop to the `ProductActionsWrapper` component. + +In `src/modules/products/templates/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/products/templates/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { BundleProduct } from "@lib/data/products" +``` + +And pass the `bundle` prop to the `ProductTemplate` component: + +```tsx title="src/modules/products/templates/index.tsx" badgeLabel="Storefront" badgeColor="blue" +type ProductTemplateProps = { + // ... + bundle?: BundleProduct +} + +const ProductTemplate: React.FC = ({ + // ... + bundle, +}) => { + // ... +} +``` + +Then, in the `ProductTemplate` component's `return` statement, find the `ProductActionsWrapper` component and pass the `bundle` prop to it: + +```tsx title="src/modules/products/templates/index.tsx" badgeLabel="Storefront" badgeColor="blue" + +``` + +Lastly, you need to pass the `bundle` prop to the `ProductTemplate` component. + +In `src/app/[countryCode]/(main)/products/[handle]/page.tsx`, add the `bundle` prop to `ProductTemplate` in the `ProductPage`'s `return` statement: + +```tsx title="src/app/[countryCode]/(main)/products/[handle]/page.tsx" badgeLabel="Storefront" badgeColor="blue" +return ( + +) +``` + +You pass the bundle using the `bundleProduct` variable you declared earlier. + +### Test it Out + +To test it out, start the Medusa application by running the following command in the Medusa application's directory: + +```bash npm2yarn badgeLabel="Medusa application" badgeColor="green" +npm run dev +``` + +Then, start the Next.js Starter Storefront by running the following command in the storefront's directory: + +```bash npm2yarn badgeLabel="Storefront" badgeColor="blue" +npm run dev +``` + +Next, open the storefront in your browser at `http://localhost:8000`, click on Menu at the top right, then choose Store from the menu. + +This will open the product catalogue page, showing the product associated with your bundled product. + +If you can't see the product associated with your bundled product, make sure you've added it to the default sales channel (or the sales channel you use in your storefront), as explained in the [Edit Associated Product](#edit-associated-product) section. + +The items in the bundle must also be added to the same sales channel. + +![Product catalogue page with the bundled product showing](https://res.cloudinary.com/dza7lstvk/image/upload/v1745926664/Medusa%20Resources/Screenshot_2025-04-29_at_2.37.22_PM_ktk4e5.png) + +If you click on the bundled product, you can see in its details page the items in the bundle. + +![Bundled products detail page showing the items](https://res.cloudinary.com/dza7lstvk/image/upload/v1745926778/Medusa%20Resources/Screenshot_2025-04-29_at_2.39.16_PM_mskh01.png) + +Once you select the necessary options for all products in the bundle, the "Add to cart" button will be enabled. You can click on it to add the bundle's items to the cart. + +![Cart with the bundled items in it](https://res.cloudinary.com/dza7lstvk/image/upload/v1745929244/Medusa%20Resources/Screenshot_2025-04-29_at_3.20.27_PM_qnbdds.png) + +You can then place an order with the bundled items. Then, on the Medusa Admin dashboard, you can fulfill and process the items separately. + +![Example of fulfilling one item in the bundle](https://res.cloudinary.com/dza7lstvk/image/upload/v1745929701/Medusa%20Resources/Screenshot_2025-04-29_at_3.22.02_PM_nvrvvz.png) + +*** + +## Step 12: Remove Bundle from Cart + +The last functionality you'll implement is the ability to remove a bundled product from the cart. When a customer chooses to remove an item in the cart that's part of a bundle, you should remove all items in the bundle from the cart. + +To implement this, you need: + +- A workflow that implements the logic to remove a bundle's items from the cart. +- An API route that exposes the workflow's functionality to storefronts. +- A function in the storefront that calls the API route to remove the bundle from the cart. + +### Create Remove Bundle from Cart Workflow + +You'll start by creating a workflow that implements the logic to remove a bundle's items from the cart. + +The workflow has the following steps: + +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the details of the cart and its items. +- [deleteLineItemsWorkflow](https://docs.medusajs.com/references/medusa-workflows/deleteLineItemsWorkflow/index.html.md): Remove the items in the bundle from the cart. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the updated cart. + +Medusa provides all these steps and workflows in its `@medusajs/medusa/core-flows` package. So, you can create the workflow right away. + +Create the file `src/workflows/remove-bundle-from-cart.ts` with the following content: + +```ts title="src/workflows/remove-bundle-from-cart.ts" collapsibleLines="1-10" expandButtonLabel="Show Imports" highlights={removeBundleFromCartWorkflowHighlights} +import { + createWorkflow, + transform, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { + deleteLineItemsWorkflow, + useQueryGraphStep, +} from "@medusajs/medusa/core-flows" + +type RemoveBundleFromCartWorkflowInput = { + bundle_id: string + cart_id: string +} + +export const removeBundleFromCartWorkflow = createWorkflow( + "remove-bundle-from-cart", + ({ bundle_id, cart_id }: RemoveBundleFromCartWorkflowInput) => { + const { data: carts } = useQueryGraphStep({ + entity: "cart", + fields: [ + "*", + "items.*", + ], + filters: { + id: cart_id, + }, + options: { + throwIfKeyNotFound: true, + }, + }) + + const itemsToRemove = transform({ + cart: carts[0], + bundle_id, + }, (data) => { + return data.cart.items.filter((item) => { + return item?.metadata?.bundle_id === data.bundle_id + }).map((item) => item!.id) + }) + + deleteLineItemsWorkflow.runAsStep({ + input: { + cart_id, + ids: itemsToRemove, + }, + }) + + // retrieve cart again + const { data: updatedCarts } = useQueryGraphStep({ + entity: "cart", + fields: [ + "*", + "items.*", + ], + filters: { + id: cart_id, + }, + }).config({ name: "retrieve-cart" }) + + return new WorkflowResponse(updatedCarts[0]) + } +) +``` + +The workflow accepts as an input the bundle's ID and the cart's ID. + +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. +- Remove the items from the cart using the `deleteLineItemsWorkflow`. +- Retrieve the updated cart using the `useQueryGraphStep`. + +Finally, you return the updated cart. + +### Create API Route + +Next, you'll create the API route that exposes the workflow's functionality to storefronts. + +Create the file `src/api/store/carts/[id]/line-item-bundles/[bundle_id]/route.ts` with the following content: + +```ts title="src/api/store/carts/[id]/line-item-bundles/[bundle_id]/route.ts" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { + removeBundleFromCartWorkflow, +} from "../../../../../../workflows/remove-bundle-from-cart" + +export async function DELETE( + req: MedusaRequest, + res: MedusaResponse +) { + const { result: cart } = await removeBundleFromCartWorkflow(req.scope) + .run({ + input: { + cart_id: req.params.id, + bundle_id: req.params.bundle_id, + }, + }) + + res.json({ + cart, + }) +} +``` + +You export a `DELETE` route handler, which exposes a `DELETE` API route at `/store/carts/:id/line-item-bundles/:bundle_id`. + +In the route handler, you execute the `removeBundleFromCartWorkflow` workflow to delete the bundle's items from the cart. + +Finally, you return the updated cart in the response. + +### Add Remove Bundle from Cart in Storefront + +You'll now customize the storefront to add a button that removes the bundle from the cart. + +Start by adding the following function at the end of `src/lib/data/cart.ts`: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="blue" +export async function removeBundleFromCart(bundleId: string) { + const cartId = await getCartId() + const headers = { + ...(await getAuthHeaders()), + } + + await sdk.client.fetch( + `/store/carts/${cartId}/line-item-bundles/${bundleId}`, + { + method: "DELETE", + headers, + } + ) + .then(async () => { + const cartCacheTag = await getCacheTag("carts") + revalidateTag(cartCacheTag) + + const fulfillmentCacheTag = await getCacheTag("fulfillment") + revalidateTag(fulfillmentCacheTag) + }) + .catch(medusaError) +} +``` + +You define the `removeBundleFromCart` function that sends a `DELETE` request to the API route you created in the previous step. + +Next, you'll update the delete button used in the cart UI to call the `removeBundleFromCart` function when removing a bundle item from the cart. + +In `src/modules/common/components/delete-button/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/common/components/delete-button/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { removeBundleFromCart } from "@lib/data/cart" +``` + +Then, add a `bundle_id` prop to the `DeleteButton` component: + +```tsx title="src/modules/common/components/delete-button/index.tsx" badgeLabel="Storefront" badgeColor="blue" +const DeleteButton = ({ + // ... + bundle_id, +}: { + // ... + bundle_id?: string +}) => { + // ... +} +``` + +Finally, replace the `handleDelete` function in the `DeleteButton` component with the following: + +```tsx title="src/modules/common/components/delete-button/index.tsx" badgeLabel="Storefront" badgeColor="blue" +const handleDelete = async (id: string) => { + setIsDeleting(true) + if (bundle_id) { + await removeBundleFromCart(bundle_id).catch((err) => { + setIsDeleting(false) + }) + } else { + await deleteLineItem(id).catch((err) => { + setIsDeleting(false) + }) + } +} +``` + +If the `bundle_id` prop is set, the `handleDelete` function calls the `removeBundleFromCart` function. Otherwise, it calls the default `deleteLineItem` function. + +Next, you'll update the components using the `DeleteButton` component to pass the `bundle_id` prop. + +In `src/modules/cart/components/item/index.tsx`, find the `DeleteButton` component in the `return` statement and replace it with the following: + +```tsx title="src/modules/cart/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["4"]]} + + {item.metadata?.bundle_id !== undefined ? "Remove bundle" : "Remove"} + +``` + +You pass the `bundle_id` prop to the `DeleteButton` component, which is set to the item's metadata. You also change the text based on whether the item is in a bundle. + +Then, in `src/modules/layout/components/cart-dropdown/index.tsx`, find the `DeleteButton` component in the `return` statement and replace it with the following: + +```tsx title="src/modules/layout/components/cart-dropdown/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["5"]]} + + {item.metadata?.bundle_id !== undefined ? "Remove bundle" : "Remove"} + +``` + +Similarly, you pass the `bundle_id` prop to the `DeleteButton` component and change the text based on whether the item is in a bundle. + +### Test it Out + +To test it out, start the Medusa application and the Next.js Starter Storefront as you did in the previous step. + +Then, open the storefront in your browser at `http://localhost:8000`. Given you've already added a bundled product to the cart, you can now see a "Remove bundle" button next to the bundled product in the cart. + +![Cart with the Remove bundle button showing for bundle items](https://res.cloudinary.com/dza7lstvk/image/upload/v1746011200/Medusa%20Resources/Screenshot_2025-04-30_at_2.06.02_PM_cgtg45.png) + +If you click on the "Remove bundle" button for any of the bundle's items, all items in the bundle will be removed from the cart. + +*** + +## Next Steps + +Now that you have a working bundled product feature, you can customize it further to fit your use case: + +- Add API routes to update the bundled product and its items in the cart. +- Add more CRUD management features to the Bundled Products page in the Medusa Admin. +- Customize the Next.js Starter Storefront to show the bundled products together in the cart, rather than seperately. +- Use custom logic to set the price of the bundled product. + +If you're new to Medusa, check out the [main documentation](https://docs.medusajs.com/docs/learn/index.html.md), where you'll get a more in-depth learning of all the concepts you've used in this guide and more. + +To learn more about the commerce features that Medusa provides, check out Medusa's [Commerce Modules](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/index.html.md). + + +# Bundled Products Recipe + +This recipe provides the general steps to implement bundled products in your Medusa application. + +Follow the step-by-step [Bundled Products Example](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/recipes/bundled-products/examples/standard/index.html.md) to learn how to implement bundled products in your Medusa application. + +## Overview + +Bundled products allow you to group multiple products into a single bundle that customers can purchase together. By using bundled products, you can offer items at a discounted price or fulfill items within the same bundle separately, among other features. + +Medusa provides an [inventory kit](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/inventory/inventory-kit/index.html.md) feature that allows you to create bundled products. However, it doesn't support all bundled-product features. For example, you can't set a different price for the bundle, or fulfill items within the same bundle separately. + +To support more bundled-product features, you can customize the Medusa application by creating a Bundled Product Module and building flows around it. + +*** + +## Create Bundled Product Module + +Your custom features and functionalities are implemented inside modules. The module is integrated into the Medusa application without any implications on existing functionalities. + +The module will hold your custom data models and the service implementing bundled-product-related features. + +[How to Create a Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md): Learn how to create a module. + +### Create Custom Data Models + +A data model represents a table in the database. You can define in your module data models to store data related to your custom features, such as a bundled product. + +For example, you can define: + +- A `Bundle` model for the bundle itself. +- A `BundleItem` model for the items in the bundle. + +Then, you can link your custom data model to data models from other modules. For example, you can link the `BundleItem` model to the Product Module's `Product` data model. + +- [How to Create a Data Model](https://docs.medusajs.com/docs/learn/fundamentals/modules#1-create-data-model/index.html.md): Learn how to create a data model. +- [Define Module Links](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md): Define links between data models. + +### Implement Data Management Features + +Your module’s main service holds data-management and other related features. Then, in other resources, such as an API route, you can resolve the service from the Medusa container and use its functionalities. + +Medusa facilitates implementing data-management features using the service factory. Your module's main service can extend this service factory, and it generates data-management methods for your data models. + +[Service Factory](https://docs.medusajs.com/docs/learn/fundamentals/modules/service-factory/index.html.md): Learn about the service factory and how to use it. + +*** + +## Implement Workflows + +You implement the features in your use case using workflows. A workflow is a series of queries and actions, called steps, that complete a task. + +By using workflows, you benefit from features like rollback mechanism, error handling, and retrying failed steps. + +You can implement workflows that create a bundled product, add bundled product to the cart, and more. In the workflow's steps, you can resolve the Bundled Product Module's service and use its data-management methods to manage bundled products. + +Then, you can utilize these workflows in other resources, such as an API route. + +[Workflows](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md): Learn how to create a workflow. + +*** + +## Add Custom API Routes + +API routes expose your features to external applications, such as the admin dashboard or the storefront. + +You can create custom API routes that expose the features you've built as workflows. For example, you can create an API route that allows merchants to list and create bundled products. + +[API Routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md): Learn how to create an API route. + +*** + +## Manage Linked Records + +If you've defined links between data models of two modules, you can manage them through two tools: Link and Query. + +Use Link to create a link between two records, and use Query to fetch data across linked data models. + +For example, you can define a link between a `Bundle` and a `Product` from the [Product Module](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/product/index.html.md). Later, you can retrieve the product associated with the bundle using Query. + +- [How to Use Link](https://docs.medusajs.com/docs/learn/fundamentals/module-links/link/index.html.md): Learn how to link data models of different modules. +- [How to Use Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md): Learn how to fetch data across modules with Medusa's Query. + +*** + +## Customize Admin Dashboard + +You can extend the Medusa Admin to provide merchants with an interface to manage bundled products. You can inject widgets into existing pages or create new pages. + +In your customizations, you send requests to the API routes you created to manage bundled products. + +- [Create a Widget](https://docs.medusajs.com/docs/learn/fundamentals/admin/widgets/index.html.md): Learn how to create a widget in the Medusa Admin. +- [Create UI Route](https://docs.medusajs.com/docs/learn/fundamentals/admin/ui-routes/index.html.md): Learn how to create a UI route in the Medusa Admin. + +*** + +## Customize or Build Storefront + +Customers use your storefront to browse your bundled products and purchase them. You can also provide other helpful features, such as displaying the bundle's details and allowing customers to select options for the items in the bundle. + +Medusa provides a Next.js Starter Storefront with standard commerce features including listing products, placing orders, and managing accounts. You can customize the storefront and cater its functionalities to support bundled products. + +Alternatively, you can build the storefront with your preferred tech stack. + +- [Next.js Starter Storefront](https://docs.medusajs.com/nextjs-starter/index.html.md): Learn how to install and use the Next.js Starter Storefront. +- [Storefront Guides](https://docs.medusajs.com/storefront-development/index.html.md): Learn how to build a storefront for your Medusa application. + + +# Commerce Automation Recipe + +This recipe provides the general steps to implement commerce automation with Medusa. + +## Overview + +Commerce automation is essential for businesses to save costs, provide a better user experience, and avoid manual, repetitive tasks that lead to human errors. Businesses utilize automation in different domains, including marketing, customer support, and order management. + +Medusa provides the necessary architecture and tools to implement commerce automation for order management, customer service, and more. You can perform an asynchronous action when an event is triggered, schedule a job that runs at a specified interval, and more. + +*** + +## Re-Stock Notifications + +Customers may be interested in a product that is currently out of stock. Instead of losing their interest, you can allow them to subscribe to receive a notification when the product is back in stock. + +Then, you can listen to product-related events and notify subscribed customers when a product variant is back in stock. + +The following guide explains how to add restock notifications in your Medusa application: + +[Restock Notification Guide](https://docs.medusajs.com/recipes/commerce-automation/restock-notification/index.html.md): Learn how to implement restock notifications in the Medusa application. + +*** + +## Automated Customer Support + +Customer support is essential to build a store's brand and customer loyalty. However, to provide an efficient customer support, you often need to integrate with third-party services, like Zendesk, and automate customer notifications. + +### Integrate with Third-Party Services + +To provide customer support, you can Integrate with third-party services, such as ticket systems or chat bots in the storefront. + +Medusa allows you to easily integrate with third-party services by creating a custom module, then build workflows for your business logic that perform actions with the third-party service. + +This approach allows you to interact with the third-party service within custom and existing flows, while maintaining data consistency across systems. You can then execute the wokflow when an event is triggered, such as when a customer places an order or requests a return. + +- [Create Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md): Learn about how to create a custom module. +- [Create Workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md): Learn how to create a workflow. + +### Automate Customer Notifications + +You can also automate sending notifications to customers when changes happen related to their orders, returns, exchanges, and more. + +Medusa's Notification Module allows you to send notifications when an event is triggered, such as when a customer's order is updated. You can use third-party services, like [SendGrid](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/infrastructure-modules/notification/sendgrid/index.html.md), to send emails to customers. + +- [Notification Module](https://docs.medusajs.com/infrastructure-modules/notification/index.html.md): Learn about the Notification Module. +- [Create Subscriber](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md): Learn how to create a subscriber to handle events. + +*** + +## Automatic Data Synchronization + +As your commerce store grows, you'll likely need to synchronize data across different systems. For example, you need to synchronize data with an ERP system or a data warehouse. + +Refer to the [ERP](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/recipes/erp/index.html.md) recipe for a focused guide on how to integrate with an ERP system. + +To implement that, you can: + +- Create a workflow that implements the synchronization steps, along with retry and rollback logic. By using a workflow, you ensure data consistency across systems. +- Create a scheduled job that executes the workflow automatically at the specified time pattern. + +- [Create Workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md): Learn how to create a workflow. +- [Create a Scheduled Job](https://docs.medusajs.com/docs/learn/fundamentals/scheduled-jobs/index.html.md): Learn how to create a scheduled job. + +*** + +## Order Management Automation + +Medusa's architecture, Commerce Modules, and Framework for customizations facilitate automating a large amount of order management functionalities. + +For example, you can automatically: + +- Create a fulfillment when an order is placed. +- Create a refund when an item is returned. +- Send a notification when an order is shipped. + +To handle events within an order flow and automate actions, you can create a subscriber that listens to the relevant event. For example, you can create a subscriber that listens to the `order.placed` event and automatically creates a fulfillment if predefined conditions are met. + +- [Create a Subscriber](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md): Learn how to create a subscriber in Medusa. +- [Events Reference](https://docs.medusajs.com/references/events/index.html.md): Check out triggered events by each Commerce Module. + +*** + +## Automated RMA Flow + +Businesses must optimize their Return Merchandise Authorization (RMA) flow to ensure a good customer experience and service. By automating the flow, customers request to return their received items, and businesses quickly support them. + +Medusa's commerce features are geared towards automating RMA flows and ensuring a good customer experience: + +- Customers can create order returns from the storefront. Merchants then receive a notification and handle the return from the Medusa Admin. +- Merchants can make order changes and request the customer's approval for them. The customer can also send any additional payment if necessary. +- Every order-related action triggers an event, which you can listen to with a subscriber. This allows you to handle order events to automate actions. + +- [Order Module](https://docs.medusajs.com/commerce-modules/order/index.html.md): Learn about the Order Module and its features. +- [Create a Subscriber](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md): Learn how to create a subscriber in Medusa. + +*** + +## Customer Segmentation + +Businesses use customer segmentation to organize customers into different groups and then apply different price rules to these groups. + +Medusa's Commerce Modules provide the necessary features to implement this use case: + +- The Customer Module allows you to organize customers into customer groups. +- The Pricing Module allows you to specify prices based on a condition, such as the group of the customer. + +For example, to group customers with over twenty orders: + +1. Create a subscriber that listens to the `order.placed` event. +2. If the customer has more than 20 orders, add them to the VIP customer group. + +- [Customer Module](https://docs.medusajs.com/commerce-modules/customer/index.html.md): Learn about the Customer Module and its features. +- [Pricing Module](https://docs.medusajs.com/commerce-modules/pricing/index.html.md): Learn about the Pricing Module and its features. +- [Create a Subscriber](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md): Learn how to create a subscriber in Medusa. +- [Events Reference](https://docs.medusajs.com/references/events/index.html.md): Check out triggered events by each Commerce Module. + +*** + +## Marketing Automation + +In your commerce store, you may utilize marketing strategies that encourage customers to make purchases. For example, you send a newsletter when new products are added to your store. + +To do that, create a subscriber that listens to the `product.created`, and send an email to subscribed customers with tools like [SendGrid](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/infrastructure-modules/notification/sendgrid/index.html.md). + +You can also create a scheduled job that checks whether the number of new products has exceeded a set threshold, then sends out the newsletter. + +- [Create a Subscriber](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md): Learn how to create a subscriber in Medusa. +- [Scheduled Jobs](https://docs.medusajs.com/docs/learn/fundamentals/scheduled-jobs/index.html.md): Learn how to create a scheduled job in Medusa. + + +# Implement Restock Notifications in Medusa + +In this guide, you'll learn how to notify customers when a variant is restocked in Medusa. + +When you install a Medusa application, you get a fully-fledged commerce platform with a Framework for customization. The Medusa application's commerce features are built around [Commerce Modules](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/index.html.md) which are available out-of-the-box. These features include managing the inventory of product variants in different stock locations and sales channels. + +Customers browsing your store may be interested in a product that is currently out of stock. To keep the customer interested in your store and encourage them to purchase the product in the future, you can build customizations around Medusa's commerce features to subscribe customers to receive a notification when the product is restocked. + +This guide will teach you how to: + +- Install and set up Medusa. +- Implement the data model to subscribe for variant restocking. +- Add a custom endpoint to subscribe a customer to a variant's restock notification. +- Build a flow to send a notification to customers subscribed to a variant when it's restocked. + +You can follow this guide whether you're new to Medusa or an advanced Medusa developer. + +[Example Repository](https://github.com/medusajs/examples/tree/main/restock-notification): Find the full code of the guide in this repository. + +*** + +## Step 1: Install a Medusa Application + +### Prerequisites + +- [Node.js v20+](https://nodejs.org/en/download) +- [Git CLI tool](https://git-scm.com/downloads) +- [PostgreSQL](https://www.postgresql.org/download/) + +Start by installing the Medusa application on your machine with the following command: + +```bash +npx create-medusa-app@latest +``` + +You'll first be asked for the project's name. You can also optionally choose to install the [Next.js Starter Storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md). + +Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name. If you chose to install the Next.js starter, it'll be installed in a separate directory with the `{project-name}-storefront` name. + +The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). Learn more about Medusa's architecture in [this documentation](https://docs.medusajs.com/docs/learn/introduction/architecture/index.html.md). + +Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credential and submit the form. Afterwards, you can login with the new user and explore the dashboard. + +Check out the [troubleshooting guides](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/troubleshooting/create-medusa-app-errors/index.html.md) for help. + +*** + +## Step 2: Create Restock Module + +To add custom tables to the database, which are called data models, you create a module. A module is a re-usable package with functionalities related to a single feature or domain. Medusa integrates the module into your application without implications or side effects on your setup. + +In this step, you'll create a Restock Module that adds a custom data model for restock notification subscriptions. In later steps, you'll store customer subscriptions in this data model. + +Learn more about modules in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md). + +### Create Module Directory + +A module is created under the `src/modules` directory of your Medusa application. So, create the directory `src/modules/restock`. + +![Diagram showcasing the module directory to create](https://res.cloudinary.com/dza7lstvk/image/upload/v1733222736/Medusa%20Resources/restock-dir-overview-1_hiz58j.jpg) + +### Create Data Models + +A data model represents a table in the database. You create data models using Medusa's Data Model Language (DML). It simplifies defining a table's columns, relations, and indexes with straightforward methods and configurations. + +Learn more about data models in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules#1-create-data-model/index.html.md). + +In Medusa, you have sales channels that indicate the channels you sell your products through, such as online storefront or offline store. A product's variants have different inventory quantities across stock locations, which are associated with sales channels. + +![A diagram showcasing how a variant's inventory is stored across modules](https://res.cloudinary.com/dza7lstvk/image/upload/v1733224214/Medusa%20Resources/inventory-details-example_nvx4cj.jpg) + +So, a customer sees the inventory quantity of a product variant based on their sales channel. To subscribe a customer to a product variant's restock notification, you'll store the subscription in a `RestockSubscription` data model. + +You create a data model in a TypeScript or JavaScript file under the `models` directory of a module. So, create the file `src/modules/restock/models/restock-subscription.ts` with the following content: + +![The directory structure of the Restock Module after adding this model.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733224503/Medusa%20Resources/restock-dir-overview-2_chap79.jpg) + +```ts title="src/modules/restock/models/restock-subscription.ts" +import { model } from "@medusajs/framework/utils" + +const RestockSubscription = model.define("restock_subscription", { + id: model.id().primaryKey(), + variant_id: model.text(), + sales_channel_id: model.text(), + email: model.text(), + customer_id: model.text().nullable(), +}) +.indexes([ + { + on: ["variant_id", "sales_channel_id", "email"], + unique: true, + }, +]) + +export default RestockSubscription +``` + +You define the data model using DML's `define` method. It accepts two parameters: + +1. The first one is the name of the data model's table in the database. +2. The second is an object, which is the data model's schema. The schema's properties are defined using DML methods. + +In the data model, you define the following properties: + +1. `id`: A primary key ID for each record. +2. `variant_id`: The ID of a variant that customers have subscribed to. +3. `sales_channel_id`: The ID of the sales channel that this variant is out-of-stock in. +4. `email`: The email of the customer subscribed to the restock notification. +5. `customer_id`: The customer's ID in Medusa. This is nullable in case the customer is a guest. + +Learn more about data model [properties](https://docs.medusajs.com/docs/learn/fundamentals/data-models/properties/index.html.md) and [relations](https://docs.medusajs.com/docs/learn/fundamentals/data-models/relationships/index.html.md). + +You also define a unique index on the `variant_id`, `sales_channel_id`, and `email` properties using the `indexes` method. + +Learn more about data model indexes in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/data-models/index/index.html.md). + +### Create Service + +You define data-management methods of your data models in a service. A service is a TypeScript or JavaScript class that the module exports. In the service's methods, you can perform database operations. + +Learn more about services in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules#2-create-service/index.html.md). + +In this section, you'll create the Restock Module's service. Create the file `src/modules/restock/service.ts` with the following content: + +![The directory structure of the Restock Module after adding this service.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733224957/Medusa%20Resources/restock-dir-overview-4_pkypup.jpg) + +```ts title="src/modules/restock/service.ts" +import { MedusaService } from "@medusajs/framework/utils" +import RestockSubscription from "./models/restock-subscription" + +class RestockModuleService extends MedusaService({ + RestockSubscription, +}) { } + +export default RestockModuleService +``` + +The `RestockModuleService` extends `MedusaService` from the Modules SDK which generates a class with data-management methods for your module's data models. This saves you time on implementing Create, Read, Update, and Delete (CRUD) methods. + +So, the `RestockModuleService` class now has methods like `createRestockSubscriptions` and `retrieveRestockSubscription`. + +Find all methods generated by the `MedusaService` in [this reference](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/service-factory-reference/index.html.md). + +You'll use this service in a later method to store and manage restock subscriptions. + +### Export Module Definition + +The final piece to a module is its definition, which you export in an `index.ts` file at its root directory. This definition tells Medusa the name of the module and its service. + +So, create the file `src/modules/restock/index.ts` with the following content: + +![The directory structure of the Restock Module after adding the definition file.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733225036/Medusa%20Resources/restock-dir-overview-5_dcam6u.jpg) + +```ts title="src/modules/restock/index.ts" +import { Module } from "@medusajs/framework/utils" +import RestockModuleService from "./service" + +export const RESTOCK_MODULE = "restock" + +export default Module(RESTOCK_MODULE, { + service: RestockModuleService, +}) +``` + +You use the `Module` function from the Modules SDK to create the module's definition. It accepts two parameters: + +1. The module's name, which is `restock`. +2. An object with a required property `service` indicating the module's service. + +### Add Module to Medusa's Configurations + +Once you finish building the module, add it to Medusa's configurations to start using it. + +In `medusa-config.ts`, add a `modules` property and pass an array with your custom module: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // ... + modules: [ + { + resolve: "./src/modules/restock", + }, + ], +}) +``` + +Each object in the `modules` array has a `resolve` property, whose value is either a path to the module's directory, or an `npm` package’s name. + +### Generate Migrations + +Since data models represent tables in the database, you define how they're created in the database with migrations. A migration is a TypeScript or JavaScript file that defines database changes made by a module. + +Learn more about migrations in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules#5-generate-migrations/index.html.md). + +Medusa's CLI tool generates the migrations for you. To generate a migration for the Restock Module, run the following command in your Medusa application's directory: + +```bash +npx medusa db:generate restock +``` + +The `db:generate` command of the Medusa CLI accepts the name of the module to generate the migration for. You'll now have a `migrations` directory under `src/modules/restock` that holds the generated migration. + +![The directory structure of the Restock Module after generating the migration](https://res.cloudinary.com/dza7lstvk/image/upload/v1733225157/Medusa%20Resources/restock-dir-overview-6_c2z6oi.jpg) + +Then, to reflect these migrations on the database, run the following command: + +```bash +npx medusa db:migrate +``` + +The table of the Restock Module's data model are now created in the database. + +*** + +## Step 3: Link Restock Subscription to Product Variant + +Since the `RestockSubscription` data model stores the product variant's ID, you may want to retrieve the product variant's details while retrieving a restock subscription record. + +However, modules are [isolated](https://docs.medusajs.com/docs/learn/fundamentals/modules/isolation/index.html.md) to ensure they're re-usable and don't have side effects when integrated into the Medusa application. So, to build associations between modules, you define [module links](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md). A Module link associates two modules' data models while maintaining module isolation. + +In this section, you'll link the `RestockSubscription` data model to the [Product Module](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/product/index.html.md)'s `ProductVariant` data model. + +Learn more about module links in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md). + +To define a link, create the file `src/links/restock-variant.ts` with the following content: + +![The directory structure of the Medusa Application after adding this link.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733225402/Medusa%20Resources/restock-dir-overview-7_dln3fw.jpg) + +```ts title="src/links/restock-variant.ts" +import { defineLink } from "@medusajs/framework/utils" +import RestockModule from "../modules/restock" +import ProductModule from "@medusajs/medusa/product" + +export default defineLink( + { + linkable: RestockModule.linkable.restockSubscription.id, + field: "variant_id", + }, + ProductModule.linkable.productVariant, + { + readOnly: true, + } +) +``` + +You define a link using `defineLink` from the Modules SDK. It accepts three parameters: + +1. The first data model part of the link, which is the Restock Module's `restockSubscription` data model. A module has a special `linkable` property that contain link configurations for its data models. You also specify the field that points to the product variant. +2. The second data model part of the link, which is the Product Module's `productVariant` data model. +3. An object of configurations for the module link. By default, Medusa creates a table in the database to represent the link you define. However, in this guide, you only want this link to retrieve the variants associated with a subscription. So, you enable `readOnly` telling Medusa not to create a table for this link. + +In the next steps, you'll see how this link allows you to retrieve product variants' details when retrieving restock subscriptions. + +*** + +## Step 4: Create Restock Subscription Workflow + +To subscribe customers to a variant's restock notification, you need a workflow. + +A workflow is a series of queries and actions, called steps, that complete a task. You construct a workflow like you construct a function, but it's a special function that allows you to track its executions' progress, define roll-back logic, and configure other advanced features. Then, you execute the workflow from other customizations, such as in an endpoint. + +In this section, you'll create a workflow that validates that a variant is out-of-stock in the customer's sales channel, then subscribes the customer to the variant's restock notification. Later, you'll execute this workflow in an endpoint that you use in a storefront. + +Learn more about workflows in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md) + +The workflow has the following steps: + +- [validateVariantOutOfStockStep](#validatevariantoutofstockstep): Validate that the variant is out-of-stock, otherwise throw an error. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the restock subscription using Query if it exists. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the restock subscription using Query again to return it. + +The `useQueryGraphStep` is from Medusa's workflows package. So, you'll only implement the other steps. + +### validateVariantOutOfStockStep + +The second step in the workflow will validate that the variant is actually out of stock in the customer's sales channel. + +Create the file `src/workflows/create-restock-subscription/steps/validate-variant-out-of-stock.ts` with the following content: + +![The directory structure of the Medusa application after adding the step.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733227547/Medusa%20Resources/restock-dir-overview-10_g3dbi3.jpg) + +```ts title="src/workflows/create-restock-subscription/steps/validate-variant-out-of-stock.ts" +import { getVariantAvailability, MedusaError } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" + +type ValidateVariantOutOfStockStepInput = { + variant_id: string + sales_channel_id: string +} + +export const validateVariantOutOfStockStep = createStep( + "validate-variant-out-of-stock", + async ({ variant_id, sales_channel_id }: ValidateVariantOutOfStockStepInput, { container }) => { + const query = container.resolve("query") + const availability = await getVariantAvailability(query, { + variant_ids: [variant_id], + sales_channel_id, + }) + + if (availability[variant_id].availability > 0) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Variant isn't out of stock." + ) + } + } +) +``` + +This step accepts the ID of the variant and the ID of the customer's sales channel. In the step, you use the `getVariantAvailability` from the Medusa Framework to get the variant's quantity in the specified sales channels. If the variant's quantity is greater than `0`, you throw an error, stopping the workflow's execution. + +### createRestockSubscriptionStep + +In the workflow, you'll try to retrieve the restock subscription if it already exists for the same email, variant ID, and sales channel ID. If it doesn't exist, you'll use this step to create the restock subscription. + +Create the file `src/workflows/create-restock-subscription/steps/create-restock-subscription.ts` with the following content: + +![The directory structure of the Medusa application after adding the step.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733227679/Medusa%20Resources/restock-dir-overview-11_dyrpao.jpg) + +```ts title="src/workflows/create-restock-subscription/steps/create-restock-subscription.ts" highlights={createRSHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import RestockModuleService from "../../../modules/restock/service" +import { RESTOCK_MODULE } from "../../../modules/restock" + +type CreateRestockSubscriptionStepInput = { + variant_id: string + sales_channel_id: string + email: string + customer_id?: string +} + +export const createRestockSubscriptionStep = createStep( + "create-restock-subscription", + async (input: CreateRestockSubscriptionStepInput, { container }) => { + const restockModuleService: RestockModuleService = container.resolve( + RESTOCK_MODULE + ) + + const restockSubscription = await restockModuleService.createRestockSubscriptions( + input + ) + + return new StepResponse(restockSubscription, restockSubscription) + } +) +``` + +In the step, you resolve the Restock Module's service from the Medusa container. Medusa registers the service of custom and core modules in the container under the module's name. + +Then, you use the service's `createRestockSubscriptions` method, which was generated by `MedusaService`, to create the restock subscription. + +Learn more about a service's generated methods in [this reference](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/service-factory-reference/index.html.md). + +Finally, you return the created restock subscription by passing it as a first parameter to `StepResponse`. The second parameter is data passed to the compensation function, which you'll learn about next. + +#### Add Compensation Function + +A compensation function defines the rollback logic of a step, and it's only executed if an error occurs in the workflow. This eliminates data inconsistency if an error occurs and the workflow can't finish execution successfully. + +Learn more about compensation functions in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/compensation-function/index.html.md). + +Since the `createRestockSubscriptionStep` creates a restock subscription, you'll undo that in the compensation function. To add a compensation function, pass it as a third parameter to `createStep`: + +```ts title="src/workflows/create-restock-subscription/steps/create-restock-subscription.ts" +export const createOrGetRestockSubscriptionsStep = createStep( + // ... + async (restockSubscription, { container }) => { + if (!restockSubscription) { + return + } + const restockModuleService: RestockModuleService = container.resolve( + RESTOCK_MODULE + ) + + await restockModuleService.deleteRestockSubscriptions(restockSubscription.id) + } +) +``` + +The compensation function receives two parameters: + +1. The second parameter of `StepResponse`, which is the created restock subscription. +2. An object similar to the second parameter of a step function. It has a `container` property to resolve resources from the Medusa container. + +In the compensation function, you resolve the Restock Module's service from the container, then delete the created subscription using the generated `deleteRestockSubscriptions` method. + +### updateRestockSubscriptionStep + +As mentioned in the previous step, the workflow will try to retrieve the restock subscription in case it already exists. If it does, you'll run this step to update its customer ID if it wasn't previously set in the subscription. + +Create the file `src/workflows/create-restock-subscription/steps/update-restock-subscription.ts` with the following content: + +![The directory structure of the Medusa application after adding the step.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733227952/Medusa%20Resources/restock-dir-overview-12_vubtkp.jpg) + +```ts title="src/workflows/create-restock-subscription/steps/update-restock-subscription.ts" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import RestockModuleService from "../../../modules/restock/service" +import { RESTOCK_MODULE } from "../../../modules/restock" + +type UpdateRestockSubscriptionStepInput = { + id: string + customer_id?: string +} + +export const updateRestockSubscriptionStep = createStep( + "update-restock-subscription", + async ({ id, customer_id }: UpdateRestockSubscriptionStepInput, { container }) => { + const restockModuleService: RestockModuleService = container.resolve( + RESTOCK_MODULE + ) + + const oldData = await restockModuleService.retrieveRestockSubscription( + id + ) + const restockSubscription = await restockModuleService.updateRestockSubscriptions({ + id, + customer_id: oldData.customer_id || customer_id, + }) + + return new StepResponse(restockSubscription, oldData) + }, + async (restockSubscription, { container }) => { + if (!restockSubscription) { + return + } + const restockModuleService: RestockModuleService = container.resolve( + RESTOCK_MODULE + ) + + await restockModuleService.updateRestockSubscriptions(restockSubscription) + } +) +``` + +In the step, you resolve the Restock Module's service and use its generated `retrieveRestockSubscription` method to retrieve the restock subscription. You then update the subscription with the `updateRestockSubscriptions`, updating the customer ID if it wasn't set in the subscription. + +The step returns the updated restock subscription. It also passes to the compensation function the subscription's data before the update to undo the change in case an error occurs. + +### Add createRestockSubscriptionWorkflow + +You can now finally add the workflow that uses all these steps. Create the file `src/workflows/create-restock-subscription/index.ts` with the following content: + +![The directory structure of the Medusa application after adding the workflow.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733229373/Medusa%20Resources/restock-dir-overview-13_zdwawe.jpg) + +```ts title="src/workflows/create-restock-subscription/index.ts" highlights={subscriptionWorkflow1Highlights} +import { createWorkflow, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { validateVariantOutOfStockStep } from "./steps/validate-variant-out-of-stock" +import { useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { createRestockSubscriptionStep } from "./steps/create-restock-subscription" +import { updateRestockSubscriptionStep } from "./steps/update-restock-subscription" + +type CreateRestockSubscriptionWorkflowInput = { + variant_id: string + sales_channel_id: string + customer: { + email?: string + customer_id?: string + } +} + +export const createRestockSubscriptionWorkflow = createWorkflow( + "create-restock-subscription", + ({ + variant_id, + sales_channel_id, + customer, + }: CreateRestockSubscriptionWorkflowInput) => { + const customerId = transform({ + customer, + }, (data) => { + return data.customer.customer_id || "" + }) + const retrievedCustomer = when( + "retrieve-customer-by-id", + { customer }, + ({ customer }) => { + return !customer.email + } + ).then(() => { + const { data } = useQueryGraphStep({ + entity: "customer", + fields: ["email"], + filters: { id: customerId }, + options: { + throwIfKeyNotFound: true, + }, + }).config({ name: "retrieve-customer" }) + + return data + }) + + const email = transform({ + retrievedCustomer, + customer, + }, (data) => { + return data.customer?.email ?? data.retrievedCustomer?.[0].email + }) + + // TODO add more steps + } +) +``` + +You create a workflow using `createWorkflow` from the Workflows SDK. It accepts the workflow's unique name as a first parameter. + +It accepts as a second parameter a constructor function, which is the workflow's implementation. In the workflow, you: + +- Use [transform](https://docs.medusajs.com/docs/learn/fundamentals/workflows/variable-manipulation/index.html.md) from the Workflows SDK to create a `customerId` variable. Its value is either the ID of the customer passed in the workflow's input if it's not `undefined`, or an empty string. +- Use [when-then](https://docs.medusajs.com/docs/learn/fundamentals/workflows/conditions/index.html.md) from the Workflows SDK that performs steps if a condition is met. If the customer's email isn't set in the workflow's input, you retrieve the customer using `useQueryGraphStep` by its ID. +- Use `transform` again to create an `email` variable whose value is either the email passed in the workflow's input or the retrieved customer's email. + +A workflow's constructor function has some constraints in implementation, which is why you need to use `transform` for variable manipulation and `when-then` to perform steps based on a condition. Learn more about these constraints in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/constructor-constraints/index.html.md). + +Next, replace the `TODO` with the following: + +```ts title="src/workflows/create-restock-subscription/index.ts" highlights={subscriptionWorkflow2Highlights} +validateVariantOutOfStockStep({ + variant_id, + sales_channel_id, +}) + +const { data: restockSubscriptions } = useQueryGraphStep({ + entity: "restock_subscription", + fields: ["*"], + filters: { + email, + variant_id, + sales_channel_id, + }, +}).config({ name: "retrieve-subscriptions" }) + +when({ restockSubscriptions }, ({ restockSubscriptions }) => { + return restockSubscriptions.length === 0 +}) +.then(() => { + createRestockSubscriptionStep({ + variant_id, + sales_channel_id, + email, + customer_id: customer.customer_id, + }) +}) + +when({ restockSubscriptions }, ({ restockSubscriptions }) => { + return restockSubscriptions.length > 0 +}) +.then(() => { + updateRestockSubscriptionStep({ + id: restockSubscriptions[0].id, + customer_id: customer.customer_id, + }) +}) + +const { data: restockSubscription } = useQueryGraphStep({ + entity: "restock_subscription", + fields: ["*"], + filters: { + email, + variant_id, + sales_channel_id, + }, +}).config({ name: "retrieve-restock-subscription" }) + +return new WorkflowResponse( + restockSubscription +) +``` + +You add the following steps to the workflow: + +- `validateVariantOutOfStockStep` to validate that the variant is out of stock in the specified sales channel. If not, an error is thrown, halting the workflow's execution. +- `useQueryGraphStep` to retrieve the restock subscription in case it already exists. +- Use [when-then](https://docs.medusajs.com/docs/learn/fundamentals/workflows/conditions/index.html.md) to perform an action if a condition is met. + - The first when-then block checks if the restock subscription doesn't exist, then creates it using the `createRestockSubscriptionStep`. + - The second when-then block checks if the restock subscription already exists, then updates it using the `updateRestockSubscriptionStep`. +- `useQueryGraphStep` again to retrieve the restock subscription before returning it. + +Workflows must return an instance of `WorkflowResponse`, passing as a parameter the data to return to the workflow's executor. The workflow returns the restock subscription. + +You'll execute the workflow when you create the API route next. + +*** + +## Step 5: Subscribe to Restock Notifications API Route + +Now that you implemented the flow to subscribe customers to a variant's restock notifications, you'll expose this feature through an API route. + +An API Route is an endpoint that exposes commerce features to external applications and clients, such as storefronts. You'll create an API route at the path `/store/restock-subscriptions` that executes the workflow from the previous step. + +Learn more about API routes in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). + +### Implement API Route + +An API route is created in a `route.ts` file under a sub-directory of the `src/api` directory. + +The path of the API route is the file's path relative to `src/api`. So, to create the `/store/restock-subscriptions` API route, create the file `src/api/store/restock-subscriptions/route.ts` with the following content: + +![The directory structure of the Medusa application after adding the route file.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733230210/Medusa%20Resources/restock-dir-overview-16_sv7yk2.jpg) + +```ts title="src/api/store/restock-subscriptions/route.ts" highlights={routeHighlights} +import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { MedusaError } from "@medusajs/framework/utils" +import { createRestockSubscriptionWorkflow } from "../../../workflows/create-restock-subscription" + +type PostStoreCreateRestockSubscription = { + variant_id: string + email?: string + sales_channel_id?: string +} + +export async function POST( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) { + const salesChannelId = req.validatedBody.sales_channel_id || ( + req.publishable_key_context?.sales_channel_ids?.length ? + req.publishable_key_context?.sales_channel_ids[0] : undefined + ) + if (!salesChannelId) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "At least one sales channel ID is required, either associated with the publishable API key or in the request body." + ) + } + const { result } = await createRestockSubscriptionWorkflow(req.scope) + .run({ + input: { + variant_id: req.validatedBody.variant_id, + sales_channel_id: salesChannelId, + customer: { + email: req.validatedBody.email, + customer_id: req.auth_context?.actor_id, + }, + }, + }) + + return res.sendStatus(201) +} + +``` + +Since you export a `POST` function in this file, you're exposing a `POST` API route at `/store/restock-subscriptions`. The route handler function accepts two parameters: + +1. A request object with details and context on the request, such as body parameters or authenticated customer details. +2. A response object to manipulate and send the response. + +`AuthenticatedMedusaRequest` accepts the request body's type as a type argument. + +In the function, you first declare the sales channel ID either based on the parameter specified in the request body or the publishable API key's first sales channel. If the sales channel's ID is not set, an error is thrown. + +Then, you execute the `createRestockSubscriptionWorkflow` by invoking it, passing it the Medusa container which is stored in the `scope` property of the request object, and invoking its `run` method. + +The `run` method accepts an object having an `input` property, which is the input to pass to the workflow. You pass the following input: + +1. `variant_id`: The ID of the variant the customer is subscribing to. You access the request body parameters from the `validatedBody` property of the request object. +2. `sales_channel_id`: The ID of the sales channel. +3. `customer`: The subscriber customer's details: + - `email`: The email passed in the request body, if available. + - `customer_id`: The ID of the customer if they're authenticated. + +Finally, you return a `201` response code, indicating that the customer has subscribed to restock notifications of the specified variant. + +### Add Validation Schema + +The API route accepts the variant ID, and optionally the customer email and sales channel ID as request body parameters. So, you'll create a schema to validate the request body. + +In Medusa, you create validation schemas using [Zod](https://zod.dev/) in a TypeScript file under the `src/api` directory. So, create the file `src/api/store/restock-subscriptions/validators.ts` with the following content: + +![The directory structure of the Medusa application after adding the validator file.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733229734/Medusa%20Resources/restock-dir-overview-14_au0h15.jpg) + +```ts title="src/api/store/restock-subscriptions/validators.ts" +import { z } from "zod" + +export const PostStoreCreateRestockSubscription = z.object({ + variant_id: z.string(), + email: z.string().optional(), + sales_channel_id: z.string().optional(), +}) +``` + +You create an object schema with the following properties: + +- `variant_id`: A required string parameter. +- `email`: An optional string parameter. The email is optional if the customer is authenticated. +- `sales_channel_id`: An optional string parameter. By default, every route starting with `/store` must pass the publishable API key, which is linked to one or more sales channels. This parameter takes a precedence over the publishable API key's channel. + +Learn more about creating schemas in [Zod's documentation](https://zod.dev/). + +You can now replace the `PostStoreCreateRestockSubscription` type in `src/api/store/restock-subscriptions/route.ts` with the following: + +```ts title="src/api/store/restock-subscriptions/route.ts" +// ... +import { z } from "zod" +import { PostStoreCreateRestockSubscription } from "./validators" + +type PostStoreCreateRestockSubscription = z.infer< + typeof PostStoreCreateRestockSubscription +> +// ... +``` + +Next, you'll use this schema for validation. + +### Add Validation and Auth Middlewares + +To use the Zod schema for validation, you apply the `validateAndTransformBody` middleware on the `/store/restock-subscriptions` route. A middleware is a function executed before the API route when a request is sent to it. + +Learn more about middlewares in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/middlewares/index.html.md). + +To apply middlewares, create the file `src/api/middlewares.ts` with the following content: + +![The directory structure of the Medusa application after adding the middlewares file.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733229866/Medusa%20Resources/restock-dir-overview-15_xvwkc0.jpg) + +```ts title="src/api/middlewares.ts" highlights={middlewaresHighlights} +import { + authenticate, + defineMiddlewares, + validateAndTransformBody, +} from "@medusajs/framework/http" +import { + PostStoreCreateRestockSubscription, +} from "./store/restock-subscriptions/validators" + +export default defineMiddlewares({ + routes: [ + { + matcher: "/store/restock-subscriptions", + method: "POST", + middlewares: [ + authenticate("customer", ["bearer", "session"], { + allowUnauthenticated: true, + }), + validateAndTransformBody(PostStoreCreateRestockSubscription), + ], + }, + ], +}) +``` + +In this file, you export the middlewares definition using `defineMiddlewares` from the Medusa Framework. This function accepts an object having a `routes` property, which is an array of middleware configurations to apply on routes. + +You pass in the `routes` array an object having the following properties: + +- `matcher`: The route to apply the middleware on. +- `method`: The HTTP method to apply the middleware on for the specified API route. +- `middlewares`: An array of the middlewares to apply. You apply two middlewares: + - `authenticate`: A middleware that guards and attaches the logged-in customer details to the request object received by the API route handler. The middleware accepts three parameters: + - The type of user to authenticate, which is `customer`. + - The types of authentication methods allowed. + - An optional object of options. You enable the `allowUnauthenticated`, which allows both authenticated and guest customers to access the route, and attaches the authenticated customer's ID to the request object. + - `validateAndTransformBody`: A middleware to ensure the received request body is valid against the Zod schema you defined earlier. + +Any request sent to `/store/restock-subscriptions` will now automatically fail if its body parameters don't match the `PostStoreCreateRestockSubscription` validation schema. + +### Test API Route + +To test out this API route, start the Medusa application by running the following command in the root directory of the Medusa application: + +```bash npm2yarn +npm run dev +``` + +Before sending the request, you need to obtain a publishable API key. So, open the Medusa Admin at `http://localhost:9000/app` and log in with the user you created earlier. + +To access your application's API keys in the admin, go to Settings -> Publishable API Keys. You'll have an API key created by default, which is associated with the default sales channel. You can use this publishable API key in the request header. + +![In the admin, click on Publishable API key in the sidebar. A table will show your API keys and allow you to create one.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733230421/Medusa%20Resources/Screenshot_2024-12-03_at_2.53.07_PM_gau9jy.png) + +Then, to obtain an ID of a variant that's out of stock, access a product from the Products page and: + +1. Under Variants, click on the variant you want to edit its inventory quantity. + +![The variants table shows a product's variants. Click on a variant to open its details page.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733230534/Medusa%20Resources/Screenshot_2024-12-03_at_2.55.11_PM_c1ml9l.png) + +2. Under Inventory Items, click on an inventory item. + +![The inventory items table shows the variant's items. Click on an item to open its details page.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733230649/Medusa%20Resources/Screenshot_2024-12-03_at_2.57.01_PM_ccc9of.png) + +3. Under Locations, click on the third-dots icon at the right of a location, then choose Edit from the dropdown. + +![The locations are shown in a table. Click on the three-dots at a location's right side, then choose Edit from the dropdown.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733230730/Medusa%20Resources/Screenshot_2024-12-03_at_2.58.18_PM_waeepw.png) + +4. In the drawer form, enter `0` for the item's in-stock quantity. +5. Click the Save button. + +![In the drawer form, enter 0 in the In stock field, then click the Save button at the bottom.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733230822/Medusa%20Resources/Screenshot_2024-12-03_at_2.59.48_PM_vqaige.png) + +6. Go back to the variant's page and click on the icon at the right of the JSON section. + +![Click on the icon at the right of the JSON section.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733230912/Medusa%20Resources/Screenshot_2024-12-03_at_3.01.28_PM_bcau0e.png) + +7. In the JSON object, hover over the `id` field and click the copy icon. + +![Click on the copy icon next to the ID field.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733230981/Medusa%20Resources/Screenshot_2024-12-03_at_3.02.34_PM_ujyv5w.png) + +Finally, send a `POST` request to the `/store/restock-subscriptions` API route: + +```bash +curl -X POST http://localhost:9000/store/restock-subscriptions \ +-H 'x-publishable-api-key: {api_key}' \ +--data '{ + "variant_id": "{variant_id}", + "email": "customer@gmail.com" +}' +``` + +Make sure to replace `{api_key}` with the publishable API key you copied from the settings, and `{variant_id}` for the ID of the out-of-stock variant. + +You'll receive a `201` response, indicating that the guest customer with email `customer@gmail.com` is now subscribed to restock notifications for the specified variant in the first sales channel associated with the specified publishable API key. + +In the next step, you'll implement the functionality to send a notification to the variant's subscribers when it's restocked. + +*** + +## Step 6: Send Restock Notification Workflow + +After allowing customers to subscribe to a variant's restock notification, you want to implement the flow that checks the variant is restocked and sends a notification to its subscribers. + +In this step, you'll create a workflow that retrieves all restock subscriptions, checks which variants are now restocked, and sends a notification to their subscribers. + +The workflow has the following steps: + +- [getDistinctSubscriptionsStep](#getDistinctSubscriptionsStep): Retrieve restock subscriptions for distinct variant ID and sales channel pairings. +- [getRestockedStep](#getrestockedstep): Filter out the restock subscriptions to retrieve only ones for restocked variants. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve restocked subscriptions for all subscribers using Query. +- [sendRestockNotificationStep](#sendrestocknotificationstep): Send a notification to the subscribers of restock subscriptions. +- [deleteRestockSubscriptionStep](#deleterestocksubscriptionstep): Delete the restock subscriptions from the database. + +The `useQueryGraphStep` is from Medusa's workflows. So, you'll only implement the other steps. + +### Optional Prerequisite: Notification Module Provider + +Within this workflow, you'll use Medusa's [Notification Module](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/infrastructure-modules/notification/index.html.md) to send an email to the customer. + +The module delegates the email sending to a module provider, such as [SendGrid](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/infrastructure-modules/notification/sendgrid/index.html.md) or [Resend](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/integrations/guides/resend/index.html.md). You can refer to their linked guides to set up either module providers. + +Alternatively, for development and debugging purposes, you can use the default Notification Module Provider that only logs a message in the terminal instead of sending an email. To do that, add the following to the `modules` array in `medusa-config.ts`: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // ... + modules: [ + // ... + { + resolve: "@medusajs/medusa/notification", + options: { + providers: [ + { + resolve: "@medusajs/medusa/notification-local", + id: "local", + options: { + channels: ["email", "feed"], + }, + }, + ], + }, + }, + ], +}) + +``` + +## getDistinctSubscriptionsStep + +The first step is to retrieve all restock subscriptions to later check which variants have been restocked in their sales channel. However, considering there could be a lot of subscribers to the same variant and sales channel pairing, you'll retrieve subscriptions with distinct variant and sales channel ID pairings. + +Before adding the step that does this, you'll add a method in the `RestockModuleService` to retrieve the distinct records from the database. So, add the following to `src/modules/restock/service.ts`: + +```ts title="src/modules/restock/service.ts" +// other imports... +import { InjectManager, MedusaContext } from "@medusajs/framework/utils" +import { Context } from "@medusajs/framework/types" +import { EntityManager } from "@mikro-orm/knex" + +class RestockModuleService extends MedusaService({ + RestockSubscription, +}) { + // ... + @InjectManager() + async getUniqueSubscriptions( + @MedusaContext() context: Context = {} + ) { + return await context.manager?.createQueryBuilder("restock_subscription") + .select(["variant_id", "sales_channel_id"]).distinct().execute() + } +} + +export default RestockModuleService +``` + +To perform queries on the database in a method, add the `@InjectManager` decorator to the method. This will inject a [forked MikroORM entity manager](https://mikro-orm.io/docs/identity-map#forking-entity-manager) that you can use in your method. + +Methods with the `@InjectManager` decorator accept as a last parameter a context object that has the `@MedusaContext` decorator. The entity manager is injected into the `manager` property of this paramter. + +In the method, you use the `createQueryBuilder` to construct a query, passing it the name of the `RestockSubscription`'s table. You then select distinct `variant_id` and `sales_channel` pairings, and execute and return the query's result. + +You'll use this method in the step. To create the step, create the file `src/workflows/send-restock-notifications/steps/get-distinct-subscriptions.ts` with the following content: + +![Directory structure of the Medusa application after adding the step.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733399774/Medusa%20Resources/restock-dir-overview-22_kzchmm.jpg) + +```ts title="src/workflows/send-restock-notifications/steps/get-distinct-subscriptions.ts" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import RestockModuleService from "../../../modules/restock/service" +import { RESTOCK_MODULE } from "../../../modules/restock" + +export const getDistinctSubscriptionsStep = createStep( + "get-distinct-subscriptions", + async (_, { container }) => { + const restockModuleService: RestockModuleService = container.resolve( + RESTOCK_MODULE + ) + + const distinctSubscriptions = await restockModuleService.getUniqueSubscriptions() + + return new StepResponse(distinctSubscriptions) + } +) +``` + +In the step, you resolve the Restock Module's service and use the `getUniqueSubscriptions` method to retrieve the distinct subscriptions. You return those subscriptions in the `StepResponse`. + +### getRestockedStep + +The second step of the workflow receives all restock subscriptions and returns only those whose variants are restocked in the specified sales channel. + +Create the file `src/workflows/send-restock-notifications/steps/get-restocked.ts` with the following content: + +![The directory structure of the Medusa application after adding the step.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733234506/Medusa%20Resources/restock-dir-overview-17_pdtees.jpg) + +```ts title="src/workflows/send-restock-notifications/steps/get-restocked.ts" +import { getVariantAvailability, promiseAll } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" + +type GetRestockedStepInput = { + variant_id: string + sales_channel_id: string +}[] + +export const getRestockedStep = createStep( + "get-restocked", + async (input: GetRestockedStepInput, { container }) => { + const restocked: GetRestockedStepInput = [] + const query = container.resolve("query") + + await promiseAll( + input.map(async (restockSubscription) => { + const variantAvailability = await getVariantAvailability(query, { + variant_ids: [restockSubscription.variant_id], + sales_channel_id: restockSubscription.sales_channel_id, + }) + + if (variantAvailability[restockSubscription.variant_id].availability > 0) { + restocked.push(restockSubscription) + } + }) + ) + + return new StepResponse(restocked) + } +) +``` + +In this step, you loop over the restock subscriptions and use `getVariantAvailability` from the Medusa Framework to retrieve a variant's quantity in the sales channel. + +If the variant isn't out of stock, then the restock subscription is pushed into the `restocked` array, which is returned in the step's response. + +### sendRestockNotificationStep + +The third step of the workflow receives the subscriptions whose variants have been restocked to send a notification to their subscribers. + +Create the file `src/workflows/send-restock-notifications/steps/send-restock-notification.ts` with the following content: + +![The directory structure of the Medusa application after adding the step.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733234507/Medusa%20Resources/restock-dir-overview-18_uvlu0l.jpg) + +```ts title="src/workflows/send-restock-notifications/steps/send-restock-notification.ts" +import { promiseAll } from "@medusajs/framework/utils" +import { createStep } from "@medusajs/framework/workflows-sdk" +import { InferTypeOf, ProductVariantDTO } from "@medusajs/framework/types" +import RestockSubscription from "../../../modules/restock/models/restock-subscription" + +type SendRestockNotificationStepInput = (InferTypeOf & { + product_variant?: ProductVariantDTO +})[] + +export const sendRestockNotificationStep = createStep( + "send-restock-notification", + async (input: SendRestockNotificationStepInput, { container }) => { + const notificationModuleService = container.resolve("notification") + + const notificationData = input.map((subscription) => ({ + to: subscription.email, + channel: "email", + template: "variant-restock", + data: { + variant: subscription.product_variant, + }, + })) + + await notificationModuleService.createNotifications(notificationData) + } +) +``` + +This step resolves the Notification Module's service from the Medusa container and, for each subscription, sends a notification to its subscribers. + +To send a notification, you use the `createNotifications` method of the Notification Module's service. It accepts an array of notification objects, each having the following properties: + +- `to`: The email to send the notification to. +- `channel`: The channel to send the notification through, which is `email` for sending an email. +- `template`: The email template to use for this notification. +- `data`: Data to pass to the template relevant for the notification. Since the email will probably include details about the variant, you pass the variant's details. + +### deleteRestockSubscriptionStep + +The final step deletes the restock subscriptions whose subscribers have been notified. + +Create the file `src/workflows/send-restock-notifications/steps/delete-restock-subscriptions.ts` with the following content: + +![The directory structure of the Medusa application after adding the step.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733234506/Medusa%20Resources/restock-dir-overview-19_qfospx.jpg) + +```ts title="src/workflows/send-restock-notifications/steps/delete-restock-subscriptions.ts" +import { InferTypeOf } from "@medusajs/framework/types" +import RestockSubscription from "../../../modules/restock/models/restock-subscription" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import RestockModuleService from "../../../modules/restock/service" +import { RESTOCK_MODULE } from "../../../modules/restock" + +type DeleteRestockSubscriptionsStepInput = InferTypeOf[] + +export const deleteRestockSubscriptionStep = createStep( + "delete-restock-subscription", + async ( + restockSubscriptions: DeleteRestockSubscriptionsStepInput, + { container } + ) => { + const restockModuleService: RestockModuleService = container.resolve( + RESTOCK_MODULE + ) + + await restockModuleService.deleteRestockSubscriptions( + restockSubscriptions.map((subscription) => subscription.id) + ) + + return new StepResponse(undefined, restockSubscriptions) + }, + async (restockSubscriptions, { container }) => { + if (!restockSubscriptions) { + return + } + + const restockModuleService: RestockModuleService = container.resolve( + RESTOCK_MODULE + ) + + await restockModuleService.createRestockSubscriptions(restockSubscriptions) + } +) +``` + +In the step, you resolve the Restock Module's service and use its `deleteRestockSubscriptions` to delete the restock subscriptions. + +In the step's compensation, which receives the deleted restock subscriptions as a parameter, you resolve the Restock Module's service and use its `createRestockSubscriptions` to create these subscriptions again if an error occurs. + +### Implement sendRestockNotificationsWorkflow + +You can now implement the workflow that sends restock notifications using the above steps. + +Create the file `src/workflows/send-restock-notifications/index.ts` with the following content: + +![The directory structure of the Medusa application after adding the workflow.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733234507/Medusa%20Resources/restock-dir-overview-20_mcqkkx.jpg) + +```ts title="src/workflows/send-restock-notifications/index.ts" +import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { getRestockedStep } from "./steps/get-restocked" +import { sendRestockNotificationStep } from "./steps/send-restock-notification" +import { deleteRestockSubscriptionStep } from "./steps/delete-restock-subscriptions" +import { getDistinctSubscriptionsStep } from "./steps/get-distinct-subscriptions" + +export const sendRestockNotificationsWorkflow = createWorkflow( + "send-restock-notifications", + () => { + const subscriptions = getDistinctSubscriptionsStep() + + // @ts-ignore + const restockedSubscriptions = getRestockedStep(subscriptions) + + const { variant_ids, sales_channel_ids } = transform({ + restockedSubscriptions, + }, (data) => { + const filters: Record = { + variant_ids: [], + sales_channel_ids: [], + } + data.restockedSubscriptions.map((subscription) => { + filters.variant_ids.push(subscription.variant_id) + filters.sales_channel_ids.push(subscription.sales_channel_id) + }) + + return filters + }) + + const { data: restockedSubscriptionsWithEmails } = useQueryGraphStep({ + entity: "restock_subscription", + fields: ["*", "product_variant.*"], + filters: { + variant_id: variant_ids, + sales_channel_id: sales_channel_ids, + }, + }) + + // @ts-ignore + sendRestockNotificationStep(restockedSubscriptionsWithEmails) + + // @ts-ignore + deleteRestockSubscriptionStep(restockedSubscriptionsWithEmails) + + return new WorkflowResponse({ + subscriptions: restockedSubscriptionsWithEmails, + }) + } +) +``` + +This workflow has the following steps: + +1. `getDistinctSubscriptionsStep` to retrieve the restock subscriptions by distinct variant and sales channel ID pairings. +2. `getRestockedStep` to filter the subscriptions retrieved by the previous step and return only those whose variants have been restocked. +3. `useQueryGraphStep` to retrieve all subscriptions that have a restocked variant and sales channel ID pairing using [Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md). Notice that in the specified `fields` you pass `product_variant.*`, which retrieves the details of the subscription's variant from the Product Module. This is possible due to the module link you created between the `RestockSubscription` and `ProductVariant` models in an earlier step. +4. `sendRestockNotificationStep` to send the notification to the subscribers of the restocked variants. +5. `deleteRestockSubscriptionStep` to delete the restock subscriptions since their subscribers have been notified. + +The workflow returns the restocked subscriptions, which are now deleted. + +You'll execute this workflow in the next section. + +*** + +## Step 7: Send Restock Notifications Daily + +Now that you've built the flow to send restock notifications, you want to check for restocked variants and send notifications to their subscribers once a day. To do so, you'll use a scheduled job. + +A scheduled job is an asynchronous function that the Medusa application runs at the schedule you specify during the Medusa application's runtime. Scheduled jobs are useful for automating tasks at a fixed schedule. + +Learn more about scheduled jobs in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/scheduled-jobs/index.html.md). + +In this step, you'll create a scheduled job that runs once a day to execute the `sendRestockNotificationsWorkflow` from the previous step. + +A scheduled job is created in a TypeScript or JavaScript file under the `src/jobs` directory. So, create the file `src/jobs/check-restock.ts` with the following content: + +![The directory structure of the Medusa application after adding the scheduled job.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733234524/Medusa%20Resources/restock-dir-overview-21_reaqp3.jpg) + +```ts title="src/jobs/check-restock.ts" +import { + MedusaContainer, +} from "@medusajs/framework/types" +import { + sendRestockNotificationsWorkflow, +} from "../workflows/send-restock-notifications" + +export default async function myCustomJob(container: MedusaContainer) { + await sendRestockNotificationsWorkflow(container) + .run() +} + +export const config = { + name: "check-restock", + schedule: "0 0 * * *", // For debugging, change to `* * * * *` +} +``` + +In this file, you export: + +- An asynchronous function, which is the task to execute at the specified schedule. +- A configuration object having the following properties: + - `name`: A unique name for the scheduled job. + - `schedule`: A [cron expression](https://crontab.guru/) string indicating the schedule to run the job at. The specified schedule indicates that this job should run every day at midnight. + +The scheduled job function accepts the Medusa container as a parameter. In the function, you execute the `sendRestockNotificationsWorkflow` by invoking it, passing it the container, then executing its `run` method. + +### Test Scheduled Job + +To test out the scheduled job, start the Medusa application: + +```bash npm2yarn +npm run dev +``` + +Then, open the Medusa Admin again at `http://localhost:9000/app` and log in. After that: + +1. Go to the same product -> variant that you edited earlier to make out of stock. +2. On the variant's details page, click on an inventory item under the Inventory Items section. + +![The inventory items table shows the variant's items. Click on an item to open its details page.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733230649/Medusa%20Resources/Screenshot_2024-12-03_at_2.57.01_PM_ccc9of.png) + +3. On the inventory item's page, click on the three dots icon next to a location, then choose edit from the dropdown. + +![The locations are shown in a table. Click on the three-dots at a location's right side, then choose Edit from the dropdown.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733230730/Medusa%20Resources/Screenshot_2024-12-03_at_2.58.18_PM_waeepw.png) + +4. In the drawer form, enter any value greater than `0`. +5. Click the Save button. + +![In the drawer form, enter value greater than 0 in the In stock field, then click the Save button at the bottom.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733234710/Medusa%20Resources/Screenshot_2024-12-03_at_3.53.55_PM_uwt06f.png) + +With this change, the variant you previously subscribed to is now restocked. To trigger the scheduled job to run, change its `config` object to run every minute: + +```ts title="src/jobs/check-restock.ts" +// ... +export const config = { + // ... + schedule: "* * * * *", // For debugging, change to `* * * * *` +} +``` + +After the application restarts, wait for the scheduled job to execute. If you're using the default Notification Module Provider that logs notifications in the terminal, you'll see a message similar to the following: + +```bash +Attempting to send a notification to: 'customer@gmail.com' on the channel: 'email' with template: 'variant-restock' and data: '{"variant":{"id":"variant_01JE3H6WHFMJ2WS64RM2MV1CJ6",...}}' +``` + +*** + +## Next Steps + +You've now implemented restock notifications in Medusa. You can also customize the [storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md) to allow customers to subscribe to the restock notification using the new API route you added. + +If you're new to Medusa, check out the [main documentation](https://docs.medusajs.com/docs/learn/index.html.md), where you'll get a more in-depth learning of all the concepts you've used in this guide and more. + +To learn more about the commerce features that Medusa provides, check out Medusa's [Commerce Modules](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/index.html.md). + + +# Digital Products Recipe Example + +In this guide, you'll learn how to support digital products in Medusa. + +When you install a Medusa application, you get a fully-fledged commerce platform with support for customizations. Medusa provides all features related to products and managing them, and the Medusa Framework allows you to extend those features and implement your custom use case. + +You can extend Medusa's product features to support selling, storing, and fulfilling digital products. In this guide, you'll customize Medusa to add the following features: + +1. Support digital products with multiple media items. +2. Manage digital products from the admin dashboard. +3. Handle and fulfill digital product orders. +4. Allow customers to download their digital product purchases from the storefront. +5. All other commerce features that Medusa provides. + +This guide provides an example of an approach to implement digital products. You're free to choose a different approach using the Medusa Framework. + +- [Digital Products Example Repository](https://github.com/medusajs/examples/tree/main/digital-product): Find the full code for this recipe example in this repository. +- [OpenApi Specs for Postman](https://res.cloudinary.com/dza7lstvk/raw/upload/v1721654620/OpenApi/Digital_Products_Postman_vjr3jg.yml): Imported this OpenApi Specs file into tools like Postman. + +*** + +## Step 1: Install a Medusa Application + +### Prerequisites + +- [Node.js v20+](https://nodejs.org/en/download) +- [Git CLI tool](https://git-scm.com/downloads) +- [PostgreSQL](https://www.postgresql.org/download/) + +Start by installing the Medusa application on your machine with the following command: + +```bash +npx create-medusa-app@latest +``` + +You'll first be asked for the project's name. Then, when asked whether you want to install the [Next.js Starter Storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md), choose Yes. + +Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name. If you chose to install the Next.js starter, it'll be installed in a separate directory with the `{project-name}-storefront` name. + +The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). Learn more about Medusa's architecture in [this documentation](https://docs.medusajs.com/docs/learn/introduction/architecture/index.html.md). + +Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credential and submit the form. Afterwards, you can login with the new user and explore the dashboard. + +Check out the [troubleshooting guides](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/troubleshooting/create-medusa-app-errors/index.html.md) for help. + +*** + +## Step 2: Create the Digital Product Module + +Medusa creates commerce features in modules. For example, product features and data models are created in the Product Module. + +You also create custom commerce data models and features in custom modules. They're integrated into the Medusa application similar to Medusa's modules without side effects. + +So, you'll create a digital product module that holds the data models related to a digital product and allows you to manage them. + +Create the directory `src/modules/digital-product`. + +### Create Data Models + +Create the file `src/modules/digital-product/models/digital-product.ts` with the following content: + +```ts title="src/modules/digital-product/models/digital-product.ts" +import { model } from "@medusajs/framework/utils" +import DigitalProductMedia from "./digital-product-media" +import DigitalProductOrder from "./digital-product-order" + +const DigitalProduct = model.define("digital_product", { + id: model.id().primaryKey(), + name: model.text(), + medias: model.hasMany(() => DigitalProductMedia, { + mappedBy: "digitalProduct", + }), + orders: model.manyToMany(() => DigitalProductOrder, { + mappedBy: "products", + }), +}) +.cascades({ + delete: ["medias"], +}) + +export default DigitalProduct +``` + +This creates a `DigitalProduct` data model. It has many medias and orders, which you’ll create next. + +Create the file `src/modules/digital-product/models/digital-product-media.ts` with the following content: + +```ts title="src/modules/digital-product/models/digital-product-media.ts" highlights={dpmModelHighlights} +import { model } from "@medusajs/framework/utils" +import { MediaType } from "../types" +import DigitalProduct from "./digital-product" + +const DigitalProductMedia = model.define("digital_product_media", { + id: model.id().primaryKey(), + type: model.enum(MediaType), + fileId: model.text(), + mimeType: model.text(), + digitalProduct: model.belongsTo(() => DigitalProduct, { + mappedBy: "medias", + }), +}) + +export default DigitalProductMedia +``` + +This creates a `DigitalProductMedia` data model, which represents a media file that belongs to the digital product. The `fileId` property holds the ID of the uploaded file as returned by the File Module, which is explained in later sections. + +Notice that the above data model uses an enum from a `types` file. So, create the file `src/modules/digital-product/types/index.ts` with the following content: + +```ts title="src/modules/digital-product/types/index.ts" +export enum MediaType { + MAIN = "main", + PREVIEW = "preview" +} +``` + +This enum indicates that a digital product media can either be used to preview the digital product, or is the main file available on purchase. + +Next, create the file `src/modules/digital-product/models/digital-product-order.ts` with the following content: + +```ts title="src/modules/digital-product/models/digital-product-order.ts" +import { model } from "@medusajs/framework/utils" +import { OrderStatus } from "../types" +import DigitalProduct from "./digital-product" + +const DigitalProductOrder = model.define("digital_product_order", { + id: model.id().primaryKey(), + status: model.enum(OrderStatus), + products: model.manyToMany(() => DigitalProduct, { + mappedBy: "orders", + pivotTable: "digitalproduct_digitalproductorders", + }), +}) + +export default DigitalProductOrder +``` + +This creates a `DigitalProductOrder` data model, which represents an order of digital products. + +This data model also uses an enum from the `types` file. So, add the following to the `src/modules/digital-product/types/index.ts` file: + +```ts title="src/modules/digital-product/types/index.ts" +export enum OrderStatus { + PENDING = "pending", + SENT = "sent" +} +``` + +### Create Main Module Service + +Next, create the main service of the module at `src/modules/digital-product/service.ts` with the following content: + +```ts title="src/modules/digital-product/service.ts" +import { MedusaService } from "@medusajs/framework/utils" +import DigitalProduct from "./models/digital-product" +import DigitalProductOrder from "./models/digital-product-order" +import DigitalProductMedia from "./models/digital-product-media" + +class DigitalProductModuleService extends MedusaService({ + DigitalProduct, + DigitalProductMedia, + DigitalProductOrder, +}) { + +} + +export default DigitalProductModuleService +``` + +The service extends the [service factory](https://docs.medusajs.com/docs/learn/fundamentals/modules/service-factory/index.html.md), which provides basic data-management features. + +### Create Module Definition + +After that, create the module definition at `src/modules/digital-product/index.ts` with the following content: + +```ts title="src/modules/digital-product/index.ts" +import DigitalProductModuleService from "./service" +import { Module } from "@medusajs/framework/utils" + +export const DIGITAL_PRODUCT_MODULE = "digitalProductModuleService" + +export default Module(DIGITAL_PRODUCT_MODULE, { + service: DigitalProductModuleService, +}) +``` + +### Add Module to Medusa Configuration + +Finally, add the module to the list of modules in `medusa-config.ts`: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // ... + modules: [ + { + resolve: "./src/modules/digital-product", + }, + ], +}) +``` + +### Further Reads + +- [How to Create a Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md) +- [How to Create a Data Model](https://docs.medusajs.com/docs/learn/fundamentals/modules#1-create-data-model/index.html.md) + +*** + +## Step 3: Define Links + +Modules are isolated in Medusa, making them reusable, replaceable, and integrable in your application without side effects. + +So, you can't have relations between data models in modules. Instead, you define a link between them. + +Links are relations between data models of different modules that maintain the isolation between the modules. + +In this step, you’ll define links between your module’s data models and data models from Medusa’s Commerce Modules: + +1. Link between the `DigitalProduct` model and the Product Module's `ProductVariant` model. +2. Link between the `DigitalProductOrder` model and the Order Module's `Order` model. + +Start by creating the file `src/links/digital-product-variant.ts` with the following content: + +```ts title="src/links/digital-product-variant.ts" +import DigitalProductModule from "../modules/digital-product" +import ProductModule from "@medusajs/medusa/product" +import { defineLink } from "@medusajs/framework/utils" + +export default defineLink( + { + linkable: DigitalProductModule.linkable.digitalProduct, + deleteCascade: true, + }, + ProductModule.linkable.productVariant +) + +``` + +This defines a link between `DigitalProduct` and the Product Module’s `ProductVariant`. This allows product variants that customers purchase to be digital products. + +`deleteCascades` is enabled on the `digitalProduct` so that when a product variant is deleted, its linked digital product is also deleted. + +Next, create the file `src/links/digital-product-order.ts` with the following content: + +```ts title="src/links/digital-product-order.ts" +import DigitalProductModule from "../modules/digital-product" +import OrderModule from "@medusajs/medusa/order" +import { defineLink } from "@medusajs/framework/utils" + +export default defineLink( + { + linkable: DigitalProductModule.linkable.digitalProductOrder, + deleteCascade: true, + }, + OrderModule.linkable.order +) + +``` + +This defines a link between `DigitalProductOrder` and the Order Module’s `Order`. This keeps track of orders that include purchases of digital products. + +`deleteCascades` is enabled on the `digitalProductOrder` so that when a Medusa order is deleted, its linked digital product order is also deleted. + +### Further Read + +- [How to Define Module Links](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md) + +*** + +## Step 4: Run Migrations and Sync Links + +To create tables for the digital product data models in the database, start by generating the migrations for the Digital Product Module with the following command: + +```bash +npx medusa db:generate digitalProductModuleService +``` + +This generates a migration in the `src/modules/digital-product/migrations` directory. + +Then, reflect the migrations and links in the database with the following command: + +```bash +npx medusa db:migrate +``` + +*** + +## Step 5: List Digital Products Admin API Route + +To expose custom commerce features to frontend applications, such as the Medusa Admin dashboard or a storefront, you expose an endpoint by creating an API route. + +In this step, you’ll create the admin API route to list digital products. + +Create the file `src/api/admin/digital-products/route.ts` with the following content: + +```ts title="src/api/admin/digital-products/route.ts" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { + fields, + limit = 20, + offset = 0, + } = req.validatedQuery || {} + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { + data: digitalProducts, + metadata: { count, take, skip } = {}, + } = await query.graph({ + entity: "digital_product", + fields: [ + "*", + "medias.*", + "product_variant.*", + ...(fields || []), + ], + pagination: { + skip: offset, + take: limit, + }, + }) + + res.json({ + digital_products: digitalProducts, + count, + limit: take, + offset: skip, + }) +} +``` + +This adds a `GET` API route at `/admin/digital-products`. + +In the route handler, you use Query to retrieve the list of digital products and their relations. The route handler also supports pagination. + +### Test API Route + +To test out the API route, start the Medusa application: + +```bash npm2yarn +npm run dev +``` + +Then, obtain a JWT token as an admin user with the following request: + +```bash +curl -X POST 'http://localhost:9000/auth/user/emailpass' \ +-H 'Content-Type: application/json' \ +--data-raw '{ + "email": "admin@medusajs.com", + "password": "supersecret" +}' +``` + +Finally, send the following request to retrieve the list of digital products: + +```bash +curl -L 'http://localhost:9000/admin/digital-products' \ +-H 'Authorization: Bearer {token}' +``` + +Make sure to replace `{token}` with the JWT token you retrieved. + +### Further Reads + +- [How to Create an API Route](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md) +- [Learn more about Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md) + +*** + +## Step 6: Create Digital Product Workflow + +To implement and expose a feature that manipulates data, you create a workflow that uses services to implement the functionality, then create an API route that executes that workflow. + +In this step, you’ll create a workflow that creates a digital product. You’ll use this workflow in an API route in the next section. + +This workflow has the following steps: + +```mermaid +graph TD + createProductsWorkflow["createProductsWorkflow (Medusa)"] --> createDigitalProductStep + createDigitalProductStep --> createDigitalProductMediasStep + createDigitalProductMediasStep --> createRemoteLinkStep["createRemoteLinkStep (Medusa)"] +``` + +1. `createProductsWorkflow`: Create the Medusa product that the digital product is associated with its variant. Medusa provides this workflow through the `@medusajs/medusa/core-flows` package, which you can use as a step. +2. `createDigitalProductStep`: Create the digital product. +3. `createDigitalProductMediasStep`: Create the medias associated with the digital product. +4. `createRemoteLinkStep`: Create the link between the digital product and the product variant. Medusa provides this step through the `@medusajs/medusa/core-flows` package. + +You’ll implement the second and third steps. + +### createDigitalProductStep (Second Step) + +Create the file `src/workflows/create-digital-product/steps/create-digital-product.ts` with the following content: + +```ts title="src/workflows/create-digital-product/steps/create-digital-product.ts" highlights={createDpHighlights} collapsibleLines="1-7" expandMoreLabel="Show Imports" +import { + createStep, + StepResponse, +} from "@medusajs/framework/workflows-sdk" +import DigitalProductModuleService from "../../../modules/digital-product/service" +import { DIGITAL_PRODUCT_MODULE } from "../../../modules/digital-product" + +export type CreateDigitalProductStepInput = { + name: string +} + +const createDigitalProductStep = createStep( + "create-digital-product-step", + async (data: CreateDigitalProductStepInput, { container }) => { + const digitalProductModuleService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + const digitalProduct = await digitalProductModuleService + .createDigitalProducts(data) + + return new StepResponse({ + digital_product: digitalProduct, + }, { + digital_product: digitalProduct, + }) + }, + async ({ digital_product }, { container }) => { + const digitalProductModuleService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + await digitalProductModuleService.deleteDigitalProducts( + digital_product.id + ) + } +) + +export default createDigitalProductStep +``` + +This creates the `createDigitalProductStep`. In this step, you create a digital product. + +In the compensation function, which is executed if an error occurs in the workflow, you delete the digital products. + +### createDigitalProductMediasStep (Third Step) + +Create the file `src/workflows/create-digital-product/steps/create-digital-product-medias.ts` with the following content: + +```ts title="src/workflows/create-digital-product/steps/create-digital-product-medias.ts" highlights={createDigitalProductMediaHighlights} collapsibleLines="1-8" expandMoreLabel="Show Imports" +import { + createStep, + StepResponse, +} from "@medusajs/framework/workflows-sdk" +import DigitalProductModuleService from "../../../modules/digital-product/service" +import { DIGITAL_PRODUCT_MODULE } from "../../../modules/digital-product" +import { MediaType } from "../../../modules/digital-product/types" + +export type CreateDigitalProductMediaInput = { + type: MediaType + fileId: string + mimeType: string + digital_product_id: string +} + +type CreateDigitalProductMediasStepInput = { + medias: CreateDigitalProductMediaInput[] +} + +const createDigitalProductMediasStep = createStep( + "create-digital-product-medias", + async ({ + medias, + }: CreateDigitalProductMediasStepInput, { container }) => { + const digitalProductModuleService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + const digitalProductMedias = await digitalProductModuleService + .createDigitalProductMedias(medias) + + return new StepResponse({ + digital_product_medias: digitalProductMedias, + }, { + digital_product_medias: digitalProductMedias, + }) + }, + async ({ digital_product_medias }, { container }) => { + const digitalProductModuleService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + await digitalProductModuleService.deleteDigitalProductMedias( + digital_product_medias.map((media) => media.id) + ) + } +) + +export default createDigitalProductMediasStep +``` + +This creates the `createDigitalProductMediasStep`. In this step, you create medias of the digital product. + +In the compensation function, you delete the digital product medias. + +### Create createDigitalProductWorkflow + +Finally, create the file `src/workflows/create-digital-product/index.ts` with the following content: + +```ts title="src/workflows/create-digital-product/index.ts" highlights={createDpWorkflowHighlights} collapsibleLines="1-23" expandMoreLabel="Show Imports" +import { + createWorkflow, + transform, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { + CreateProductWorkflowInputDTO, +} from "@medusajs/framework/types" +import { + createProductsWorkflow, + createRemoteLinkStep, +} from "@medusajs/medusa/core-flows" +import { + Modules, +} from "@medusajs/framework/utils" +import createDigitalProductStep, { + CreateDigitalProductStepInput, +} from "./steps/create-digital-product" +import createDigitalProductMediasStep, { + CreateDigitalProductMediaInput, +} from "./steps/create-digital-product-medias" +import { DIGITAL_PRODUCT_MODULE } from "../../modules/digital-product" + +type CreateDigitalProductWorkflowInput = { + digital_product: CreateDigitalProductStepInput & { + medias: Omit[] + } + product: CreateProductWorkflowInputDTO +} + +const createDigitalProductWorkflow = createWorkflow( + "create-digital-product", + (input: CreateDigitalProductWorkflowInput) => { + const { medias, ...digitalProductData } = input.digital_product + + const product = createProductsWorkflow.runAsStep({ + input: { + products: [input.product], + }, + }) + + const { digital_product } = createDigitalProductStep( + digitalProductData + ) + + const { digital_product_medias } = createDigitalProductMediasStep( + transform({ + digital_product, + medias, + }, + (data) => ({ + medias: data.medias.map((media) => ({ + ...media, + digital_product_id: data.digital_product.id, + })), + }) + ) + ) + + createRemoteLinkStep([{ + [DIGITAL_PRODUCT_MODULE]: { + digital_product_id: digital_product.id, + }, + [Modules.PRODUCT]: { + product_variant_id: product[0].variants[0].id, + }, + }]) + + return new WorkflowResponse({ + digital_product: { + ...digital_product, + medias: digital_product_medias, + }, + }) + } +) + +export default createDigitalProductWorkflow +``` + +This creates the `createDigitalProductWorkflow`. The workflow accepts as a parameter the digital product and the Medusa product to create. + +In the workflow, you run the following steps: + +1. `createProductsWorkflow` as a step to create a Medusa product. +2. `createDigitalProductStep` to create the digital product. +3. `createDigitalProductMediasStep` to create the digital product’s medias. +4. `createRemoteLinkStep` to link the digital product to the product variant. + +You’ll test out the workflow in the next section. + +### Further Reads + +- [How to Create a Workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md) +- [What is the Compensation Function](https://docs.medusajs.com/docs/learn/fundamentals/workflows/compensation-function/index.html.md) +- [Learn more about Link functions](https://docs.medusajs.com/docs/learn/fundamentals/module-links/link/index.html.md) + +*** + +## Step 7: Create Digital Product API Route + +In this step, you’ll add the API route to create a digital product using the `createDigitalProductWorkflow`. + +In the file `src/api/admin/digital-products/route.ts` add a new route handler: + +```ts title="src/api/admin/digital-products/route.ts" +// other imports... +import { z } from "zod" +import createDigitalProductWorkflow from "../../../workflows/create-digital-product" +import { CreateDigitalProductMediaInput } from "../../../workflows/create-digital-product/steps/create-digital-product-medias" +import { createDigitalProductsSchema } from "../../validation-schemas" + +// ... + +type CreateRequestBody = z.infer< + typeof createDigitalProductsSchema +> + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { data: [shippingProfile] } = await query.graph({ + entity: "shipping_profile", + fields: ["id"], + }) + + const { result } = await createDigitalProductWorkflow( + req.scope + ).run({ + input: { + digital_product: { + name: req.validatedBody.name, + medias: req.validatedBody.medias.map((media) => ({ + fileId: media.file_id, + mimeType: media.mime_type, + ...media, + })) as Omit[], + }, + product: { + ...req.validatedBody.product, + shipping_profile_id: shippingProfile.id, + }, + }, + }) + + res.json({ + digital_product: result.digital_product, + }) +} +``` + +This adds a `POST` API route at `/admin/digital-products`. In the route handler, you first retrieve a shipping profile to associate it with the product, which is required. + +Then, you execute the `createDigitalProductWorkflow` created in the previous step, passing data from the request body as input, along with the retrieved shipping profile. + +The route handler imports a validation schema from a `validation-schema` file. So, create the file `src/api/validation-schemas.ts` with the following content: + +```ts title="src/api/validation-schemas.ts" +import { + AdminCreateProduct, +} from "@medusajs/medusa/api/admin/products/validators" +import { z } from "zod" +import { MediaType } from "../modules/digital-product/types" + +export const createDigitalProductsSchema = z.object({ + name: z.string(), + medias: z.array(z.object({ + type: z.nativeEnum(MediaType), + file_id: z.string(), + mime_type: z.string(), + })), + product: AdminCreateProduct(), +}) +``` + +This defines the expected request body schema. + +Finally, create the file `src/api/middlewares.ts` with the following content: + +```ts title="src/api/middlewares.ts" +import { + defineMiddlewares, + validateAndTransformBody, +} from "@medusajs/framework/http" +import { createDigitalProductsSchema } from "./validation-schemas" + +export default defineMiddlewares({ + routes: [ + { + matcher: "/admin/digital-products", + method: "POST", + middlewares: [ + validateAndTransformBody(createDigitalProductsSchema), + ], + }, + ], +}) +``` + +This adds a validation middleware to ensure that the body of `POST` requests sent to `/admin/digital-products` match the `createDigitalProductsSchema`. + +### Further Read + +- [How to Create a Middleware](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/middlewares/index.html.md) + +*** + +## Step 8: Upload Digital Product Media API Route + +To upload the digital product media files, use Medusa’s File Module. + +Your Medusa application uses the local file module provider by default, which uploads files to a local directory. However, you can use other file module providers, such as the [S3 module provider](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/infrastructure-modules/file/s3/index.html.md). + +In this step, you’ll create an API route for uploading preview and main digital product media files. + +Before creating the API route, install the [multer express middleware](https://expressjs.com/en/resources/middleware/multer.html) to support file uploads: + +```bash npm2yarn +npm install multer +npm install --save-dev @types/multer +``` + +Then, create the file `src/api/admin/digital-products/upload/[type]/route.ts` with the following content: + +```ts title="src/api/admin/digital-products/upload/[type]/route.ts" highlights={uploadHighlights} collapsibleLines="1-7" expandMoreLabel="Show Imports" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { uploadFilesWorkflow } from "@medusajs/medusa/core-flows" +import { MedusaError } from "@medusajs/framework/utils" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const access = req.params.type === "main" ? "private" : "public" + const input = req.files as Express.Multer.File[] + + if (!input?.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "No files were uploaded" + ) + } + + const { result } = await uploadFilesWorkflow(req.scope).run({ + input: { + files: input?.map((f) => ({ + filename: f.originalname, + mimeType: f.mimetype, + content: f.buffer.toString("binary"), + access, + })), + }, + }) + + res.status(200).json({ files: result }) +} + +``` + +This adds a `POST` API route at `/admin/digital-products/upload/[type]` where `[type]` is either `preview` or `main`. + +In the route handler, you use `uploadFilesWorkflow` from Medusa's core workflows to upload the file. If the file type is `main`, it’s uploaded with private access, as only customers who purchased it can download it. Otherwise, it’s uploaded with `public` access. + +Next, add to the file `src/api/middlewares.ts` the `multer` middleware on this API route: + +```ts title="src/api/middlewares.ts" +// other imports... +import multer from "multer" + +const upload = multer({ storage: multer.memoryStorage() }) + +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/admin/digital-products/upload**", + method: "POST", + middlewares: [ + upload.array("files"), + ], + }, + ], +}) +``` + +You’ll test out this API route in the next step as you use these API routes in the admin customizations. + +*** + +## Step 9: Add Digital Products UI Route in Admin + +The Medusa Admin is customizable, allowing you to inject widgets into existing pages or add UI routes to create new pages. + +In this step, you’ll add a UI route to the Medusa Admin that displays a list of digital products. + +Before you create the UI route, create the file `src/admin/types/index.ts` that holds the following types: + +```ts title="src/admin/types/index.ts" +import { ProductVariantDTO } from "@medusajs/framework/types" + +export enum MediaType { + MAIN = "main", + PREVIEW = "preview" +} + +export type DigitalProductMedia = { + id: string + type: MediaType + fileId: string + mimeType: string + digitalProducts?: DigitalProduct +} + +export type DigitalProduct = { + id: string + name: string + medias?: DigitalProductMedia[] + product_variant?:ProductVariantDTO +} + +``` + +These types will be used by the UI route. + +Next, create the file `src/admin/routes/digital-products/page.tsx` with the following content: + +```tsx title="src/admin/routes/digital-products/page.tsx" highlights={digitalProductPageHighlights} collapsibleLines="1-7" expandMoreLabel="Show Imports" +import { defineRouteConfig } from "@medusajs/admin-sdk" +import { PhotoSolid } from "@medusajs/icons" +import { Container, Heading, Table } from "@medusajs/ui" +import { useState } from "react" +import { Link } from "react-router-dom" +import { DigitalProduct } from "../../types" + +const DigitalProductsPage = () => { + const [digitalProducts, setDigitalProducts] = useState< + DigitalProduct[] + >([]) + // TODO fetch digital products... + + return ( + +
+ Digital Products + {/* TODO add create button */} +
+ + + + Name + Action + + + + {digitalProducts.map((digitalProduct) => ( + + + {digitalProduct.name} + + + + View Product + + + + ))} + +
+ {/* TODO add pagination component */} +
+ ) +} + +export const config = defineRouteConfig({ + label: "Digital Products", + icon: PhotoSolid, +}) + +export default DigitalProductsPage + +``` + +This creates a UI route that's displayed at the `/digital-products` path in the Medusa Admin. The UI route also adds a sidebar item with the label “Digital Products" pointing to the UI route. + +In the React component of the UI route, you just display the table of digital products. + +Next, replace the first `TODO` with the following: + +```tsx title="src/admin/routes/digital-products/page.tsx" highlights={paginationHighlights} +// other imports... +import { useMemo } from "react" + +const DigitalProductsPage = () => { + // ... + + const [currentPage, setCurrentPage] = useState(0) + const pageLimit = 20 + const [count, setCount] = useState(0) + const pagesCount = useMemo(() => { + return count / pageLimit + }, [count]) + const canNextPage = useMemo( + () => currentPage < pagesCount - 1, + [currentPage, pagesCount] + ) + const canPreviousPage = useMemo( + () => currentPage > 0, + [currentPage] + ) + + const nextPage = () => { + if (canNextPage) { + setCurrentPage((prev) => prev + 1) + } + } + + const previousPage = () => { + if (canPreviousPage) { + setCurrentPage((prev) => prev - 1) + } + } + + // TODO fetch digital products + + // ... +} +``` + +This defines the following pagination variables: + +1. `currentPage`: The number of the current page. +2. `pageLimit`: The number of digital products to show per page. +3. `count`: The total count of digital products. +4. `pagesCount`: A memoized variable that holds the number of pages based on `count` and `pageLimit`. +5. `canNextPage`: A memoized variable that indicates whether there’s a next page based on whether the current page is less than `pagesCount - 1`. +6. `canPreviousPage`: A memoized variable that indicates whether there’s a previous pages based on whether the current page is greater than `0`. +7. `nextPage`: A function that increments the `currentPage`. +8. `previousPage`: A function that decrements the `currentPage`. + +Then, replace the new `TODO fetch digital products` with the following: + +```tsx title="src/admin/routes/digital-products/page.tsx" highlights={fetchDigitalProductsHighlights} +// other imports +import { useEffect } from "react" + +const DigitalProductsPage = () => { + // ... + + const fetchProducts = () => { + const query = new URLSearchParams({ + limit: `${pageLimit}`, + offset: `${pageLimit * currentPage}`, + }) + + fetch(`/admin/digital-products?${query.toString()}`, { + credentials: "include", + }) + .then((res) => res.json()) + .then(({ + digital_products: data, + count, + }) => { + setDigitalProducts(data) + setCount(count) + }) + } + + useEffect(() => { + fetchProducts() + }, [currentPage]) + + // ... +} +``` + +This defines a `fetchProducts` function that fetches the digital products using the API route you created in step 4. You also call that function within a `useEffect` callback which is executed whenever the `currentPage` changes. + +Finally, replace the `TODO add pagination component` in the return statement with `Table.Pagination` component: + +```tsx title="src/admin/routes/digital-products/page.tsx" +return ( + + {/* ... */} + + + ) +``` + +The `Table.Pagination` component accepts as props the pagination variables you defined earlier. + +### Test UI Route + +To test the UI route out, start the Medusa application, go to `localhost:9000/app`, and log in as an admin user. + +Once you log in, you’ll find a new sidebar item, “Digital Products.” If you click on it, you’ll see the UI route you created with a table of digital products. + +### Further Reads + +- [How to Create UI Routes](https://docs.medusajs.com/docs/learn/fundamentals/admin/ui-routes/index.html.md) +- [How to Create Admin Widgets](https://docs.medusajs.com/docs/learn/fundamentals/admin/widgets/index.html.md) + +*** + +## Step 10: Add Create Digital Product Form in Admin + +In this step, you’ll add a form for admins to create digital products. The form opens in a drawer or side window from within the Digital Products UI route you created in the previous section. + +Create the file `src/admin/components/create-digital-product-form/index.tsx` with the following content: + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" +import { useState } from "react" +import { Input, Button, Select, toast } from "@medusajs/ui" +import { MediaType } from "../../types" + +type CreateMedia = { + type: MediaType + file?: File +} + +type Props = { + onSuccess?: () => void +} + +const CreateDigitalProductForm = ({ + onSuccess, +}: Props) => { + const [name, setName] = useState("") + const [medias, setMedias] = useState([]) + const [productTitle, setProductTitle] = useState("") + const [loading, setLoading] = useState(false) + + const onSubmit = async (e: React.FormEvent) => { + // TODO handle submit + } + + return ( +
+ {/* TODO show form inputs */} + +
+ ) +} + +export default CreateDigitalProductForm +``` + +This creates a React component that shows a form and handles creating a digital product on form submission. + +You currently don’t display the form. Replace the return statement with the following: + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" +return ( +
+ setName(e.target.value)} + /> +
+ Media + + {medias.map((media, index) => ( +
+ Media {index + 1} + + changeFiles( + index, + { + file: e.target.files?.[0], + } + )} + className="mt-2" + /> +
+ ))} +
+
+ Product + setProductTitle(e.target.value)} + /> +
+ +
+) +``` + +This shows input fields for the digital product and product’s names. It also shows a fieldset of media files, with the ability to add more media files on a button click. + +Add in the component the `onAddMedia` function that is triggered by a button click to add a new media: + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" +const onAddMedia = () => { + setMedias((prev) => [ + ...prev, + { + type: MediaType.PREVIEW, + }, + ]) +} +``` + +And add in the component a `changeFiles` function that saves changes related to a media in the `medias` state variable: + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" +const changeFiles = ( + index: number, + data: Partial +) => { + setMedias((prev) => [ + ...(prev.slice(0, index)), + { + ...prev[index], + ...data, + }, + ...(prev.slice(index + 1)), + ]) +} +``` + +On submission, the media files should first be uploaded before the digital product is created. + +So, add before the `onSubmit` function the following new function: + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" +const uploadMediaFiles = async ( + type: MediaType +) => { + const formData = new FormData() + const mediaWithFiles = medias.filter( + (media) => media.file !== undefined && + media.type === type + ) + + if (!mediaWithFiles.length) { + return + } + + mediaWithFiles.forEach((media) => { + if (!media.file) { + return + } + formData.append("files", media.file) + }) + + const { files } = await fetch(`/admin/digital-products/upload/${type}`, { + method: "POST", + credentials: "include", + body: formData, + }).then((res) => res.json()) + + return { + mediaWithFiles, + files, + } +} +``` + +This function accepts a type of media to upload (`preview` or `main`). In the function, you upload the files of the specified type using the API route you created in step 7. You return the uploaded files and their associated media. + +Next, you’ll implement the `onSubmit` function. Replace it with the following: + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" highlights={uploadMediaHighlights} +const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + + try { + const { + mediaWithFiles: previewMedias, + files: previewFiles, + } = await uploadMediaFiles(MediaType.PREVIEW) || {} + const { + mediaWithFiles: mainMedias, + files: mainFiles, + } = await uploadMediaFiles(MediaType.MAIN) || {} + + const mediaData: { + type: MediaType + file_id: string + mime_type: string + }[] = [] + + previewMedias?.forEach((media, index) => { + mediaData.push({ + type: media.type, + file_id: previewFiles[index].id, + mime_type: media.file!.type, + }) + }) + + mainMedias?.forEach((media, index) => { + mediaData.push({ + type: media.type, + file_id: mainFiles[index].id, + mime_type: media.file!.type, + }) + }) + + // TODO create digital product + } catch (e) { + console.error(e) + setLoading(false) + } +} +``` + +In this function, you use the `uploadMediaFiles` function to upload `preview` and `main` media files. Then, you prepare the media data that’ll be used when creating the digital product in a `mediaData` variable. + +Notice that you use the `id` of uploaded files, as returned in the response of `/admin/digital-products/upload/[type]` as the `file_id` value of the media to be created. + +Finally, replace the new `TODO` in `onSubmit` with the following: + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" +fetch(`/admin/digital-products`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + medias: mediaData, + product: { + title: productTitle, + options: [{ + title: "Default", + values: ["default"], + }], + variants: [{ + title: productTitle, + options: { + Default: "default", + }, + manage_inventory: false, + // delegate setting the prices to the + // product's page. + prices: [], + shipping_profile_id: "", + }], + }, + }), +}) +.then((res) => res.json()) +.then(({ message }) => { + if (message) { + throw message + } + onSuccess?.() +}) +.catch((e) => { + console.error(e) + toast.error("Error", { + description: `An error occurred while creating the digital product: ${e}`, + }) +}) +.finally(() => setLoading(false)) +``` + +In this snippet, you send a `POST` request to `/admin/digital-products` to create a digital product. + +You’ll make changes now to `src/admin/routes/digital-products/page.tsx` to show the form. + +First, add a new `open` state variable: + +```tsx title="src/admin/routes/digital-products/page.tsx" +const DigitalProductsPage = () => { + const [open, setOpen] = useState(false) + // ... +} +``` + +Then, replace the `TODO add create button` in the return statement to show the `CreateDigitalProductForm` component: + +```tsx title="src/admin/routes/digital-products/page.tsx" +// other imports... +import { Drawer } from "@medusajs/ui" +import CreateDigitalProductForm from "../../components/create-digital-product-form" + +const DigitalProductsPage = () => { + // ... + + return ( + + {/* Replace the TODO with the following */} + setOpen(openChanged)}> + { + setOpen(true) + }} + asChild + > + + + + + Create Product + + + { + setOpen(false) + if (currentPage === 0) { + fetchProducts() + } else { + setCurrentPage(0) + } + }} /> + + + + + ) +} +``` + +This adds a Create button in the Digital Products UI route and, when it’s clicked, shows the form in a drawer or side window. + +You pass to the `CreateDigitalProductForm` component an `onSuccess` prop that, when the digital product is created successfully, re-fetches the digital products. + +### Test Create Form Out + +To test the form, open the Digital Products page in the Medusa Admin. There, you’ll find a new Create button. + +If you click on the button, a form will open in a drawer. Fill in the details of the digital product to create one. + +After you create the digital product, you’ll find it in the table. You can also click on View Product to edit the product’s details, such as the variant’s price. + +To use this digital product in later steps (such as to create an order), you must make the following changes to its associated product details: + +1. Change the status to published. +2. Add it to the default sales channel. +3. Add prices to the variant. + +*** + +## Step 11: Handle Product Deletion + +When a product is deleted, its product variants are also deleted, meaning that their associated digital products should also be deleted. + +In this step, you'll build a flow that deletes the digital products associated with a deleted product's variants. Then, you'll execute this workflow whenever a product is deleted. + +The workflow has the following steps: + +- `retrieveDigitalProductsToDeleteStep`: Retrieve the digital products associated with a deleted product's variants. +- `deleteDigitalProductsStep`: Delete the digital products. + +### retrieveDigitalProductsToDeleteStep + +The first step of the workflow receives the ID of the deleted product as an input and retrieves the digital products associated with its variants. + +Create the file `src/workflows/delete-product-digital-products/steps/retrieve-digital-products-to-delete.ts` with the following content: + +```ts title="src/workflows/delete-product-digital-products/steps/retrieve-digital-products-to-delete.ts" highlights={retrieveDigitalProductsHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import DigitalProductVariantLink from "../../../links/digital-product-variant" + +type RetrieveDigitalProductsToDeleteStepInput = { + product_id: string +} + +export const retrieveDigitalProductsToDeleteStep = createStep( + "retrieve-digital-products-to-delete", + async ({ product_id }: RetrieveDigitalProductsToDeleteStepInput, { container }) => { + const productService = container.resolve("product") + const query = container.resolve("query") + + const productVariants = await productService.listProductVariants({ + product_id: product_id, + }, { + withDeleted: true, + }) + + const { data } = await query.graph({ + entity: DigitalProductVariantLink.entryPoint, + fields: ["digital_product.*"], + filters: { + product_variant_id: productVariants.map((v) => v.id), + }, + }) + + const digitalProductIds = data.map((d) => d.digital_product.id) + + return new StepResponse(digitalProductIds) + } +) +``` + +You create a `retrieveDigitalProductsToDeleteStep` step that retrieves the product variants of the deleted product. Notice that you pass in the second object parameter of `listProductVariants` a `withDeleted` property that ensures deleted variants are included in the result. + +Then, you use Query to retrieve the digital products associated with the product variants. Links created with `defineLink` have an `entryPoint` property that you can use with Query to retrieve data from the pivot table of the link between the data models. + +Finally, you return the IDs of the digital products to delete. + +### deleteDigitalProductsSteps + +Next, you'll implement the step that deletes those digital products. + +Create the file `src/workflows/delete-product-digital-products/steps/delete-digital-products.ts` with the following content: + +```ts title="src/workflows/delete-product-digital-products/steps/delete-digital-products.ts" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { DIGITAL_PRODUCT_MODULE } from "../../../modules/digital-product" +import DigitalProductModuleService from "../../../modules/digital-product/service" + +type DeleteDigitalProductsStep = { + ids: string[] +} + +export const deleteDigitalProductsSteps = createStep( + "delete-digital-products", + async ({ ids }: DeleteDigitalProductsStep, { container }) => { + const digitalProductService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + await digitalProductService.softDeleteDigitalProducts(ids) + + return new StepResponse({}, ids) + }, + async (ids, { container }) => { + if (!ids) { + return + } + + const digitalProductService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + await digitalProductService.restoreDigitalProducts(ids) + } +) +``` + +In the `deleteDigitalProductsSteps`, you soft delete the digital products by the ID passed as a parameter. In the compensation function, you restore the digital products if an error occurs. + +### Create deleteProductDigitalProductsWorkflow + +You can now create the workflow that executes those steps. + +Create the file `src/workflows/delete-product-digital-products/index.ts` with the following content: + +```ts title="src/workflows/delete-product-digital-products/index.ts" +import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { deleteDigitalProductsSteps } from "./steps/delete-digital-products" +import { retrieveDigitalProductsToDeleteStep } from "./steps/retrieve-digital-products-to-delete" + +type DeleteProductDigitalProductsInput = { + id: string +} + +export const deleteProductDigitalProductsWorkflow = createWorkflow( + "delete-product-digital-products", + (input: DeleteProductDigitalProductsInput) => { + const digitalProductsToDelete = retrieveDigitalProductsToDeleteStep({ + product_id: input.id, + }) + + deleteDigitalProductsSteps({ + ids: digitalProductsToDelete, + }) + + return new WorkflowResponse({}) + } +) +``` + +The `deleteProductDigitalProductsWorkflow` receives the ID of the deleted product as an input. In the workflow, you: + +- Run the `retrieveDigitalProductsToDeleteStep` to retrieve the digital products associated with the deleted product. +- Run the `deleteDigitalProductsSteps` to delete the digital products. + +### Execute Workflow on Product Deletion + +When a product is deleted, Medusa emits a `product.deleted` event. You can handle this event with a subscriber. A subscriber is an asynchronous function that, when an event is emitted, is executed. You can implement in subscribers features that aren't essential to the original flow that emitted the event. + +Learn more about subscribers in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md). + +So, you'll listen to the `product.deleted` event in a subscriber, and execute the workflow whenever the product is deleted. + +Create the file `src/subscribers/handle-product-deleted.ts` with the following content: + +```ts title="src/subscribers/handle-product-deleted.ts" +import { SubscriberArgs, SubscriberConfig } from "@medusajs/framework" +import { + deleteProductDigitalProductsWorkflow, +} from "../workflows/delete-product-digital-products" + +export default async function handleProductDeleted({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + await deleteProductDigitalProductsWorkflow(container) + .run({ + input: data, + }) +} + +export const config: SubscriberConfig = { + event: "product.deleted", +} +``` + +A subscriber file must export: + +- An asynchronous function that's executed whenever the specified event is emitted. +- A configuration object that specifies the event the subscriber listens to, which is in this case `product.deleted`. + +The subscriber function receives as a parameter an object having the following properties: + +- `event`: An object containing the data payload of the emitted event. +- `container`: Instance of the [Medusa Container](https://docs.medusajs.com/docs/learn/fundamentals/medusa-container/index.html.md). + +In the subscriber, you execute the workflow by invoking it, passing the Medusa container as an input, then executing its `run` method. You pass the product's ID, which is received through the event's data payload, as an input to the workflow. + +### Test it Out + +To test this out, start the Medusa application and, from the Medusa Admin dashboard, delete a product that has digital products. You can confirm that the digital product was deleted by checking the Digital Products page. + +*** + +## Step 12: Create Digital Product Fulfillment Module Provider + +In this step, you'll create a fulfillment module provider for digital products. It doesn't have any real fulfillment functionality as digital products aren't physically fulfilled. + +### Create Module Provider Service + +Start by creating the `src/modules/digital-product-fulfillment` directory. + +Then, create the file `src/modules/digital-product-fulfillment/service.ts` with the following content: + +```ts title="src/modules/digital-product-fulfillment/service.ts" +import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils" +import { + CreateFulfillmentResult, + FulfillmentDTO, + FulfillmentItemDTO, + FulfillmentOption, + FulfillmentOrderDTO, +} from "@medusajs/framework/types" + +class DigitalProductFulfillmentService extends AbstractFulfillmentProviderService { + static identifier = "digital" + + constructor() { + super() + } + + async getFulfillmentOptions(): Promise { + return [ + { + id: "digital-fulfillment", + }, + ] + } + + async validateFulfillmentData( + optionData: Record, + data: Record, + context: Record + ): Promise { + return data + } + + async validateOption(data: Record): Promise { + return true + } + + async createFulfillment( + data: Record, + items: Partial>[], + order: Partial | undefined, + fulfillment: Partial> + ): Promise { + // No data is being sent anywhere + return { + data, + labels: [], + } + } + + async cancelFulfillment(): Promise { + return {} + } + + async createReturnFulfillment(): Promise { + return {} + } +} + +export default DigitalProductFulfillmentService +``` + +The fulfillment provider registers one fulfillment option, and doesn't perform actual fulfillment. + +### Create Module Provider Definition + +Then, create the module provider's definition in the file `src/modules/digital-product-fulfillment/index.ts`: + +```ts title="src/modules/digital-product-fulfillment/index.ts" +import { ModuleProviderExports } from "@medusajs/framework/types" +import DigitalProductFulfillmentService from "./service" + +const services = [DigitalProductFulfillmentService] + +const providerExport: ModuleProviderExports = { + services, +} + +export default providerExport +``` + +### Register Module Provider in Medusa's Configurations + +Finally, register the module provider in `medusa-config.ts`: + +```ts title="medusa-config.ts" +// other imports... +import { Modules } from "@medusajs/framework/utils" + +module.exports = defineConfig({ + modules: [ + // ... + { + resolve: "@medusajs/medusa/fulfillment", + options: { + providers: [ + { + resolve: "@medusajs/medusa/fulfillment-manual", + id: "manual", + }, + { + resolve: "./src/modules/digital-product-fulfillment", + id: "digital", + }, + ], + }, + }, + ], +}) +``` + +This registers the digital product fulfillment as a module provider of the Fulfillment Module. + +### Add Fulfillment Provider to Location + +In the Medusa Admin, go to Settings -> Location & Shipping, and add the fulfillment provider and a shipping option for it in a location. + +This is necessary to use the fulfillment provider's shipping option during checkout. + +*** + +## Step 13: Create Cart Completion Flow for Digital Products + +In this step, you’ll create a new cart completion flow that not only creates a Medusa order, but also create a digital product order. + +To create the cart completion flow, you’ll create a workflow and then use that workflow in an API route defined at `src/api/store/carts/[id]/complete-digital/route.ts`. + +```mermaid +graph TD + completeCartWorkflow["completeCartWorkflow (Medusa)"] --> useQueryGraphStep["useQueryGraphStep (Medusa)"] + useQueryGraphStep --> when{order has digital products?} + when -->|Yes| createDigitalProductOrderStep + createDigitalProductOrderStep --> createRemoteLinkStep["createRemoteLinkStep (Medusa)"] + createRemoteLinkStep --> createOrderFulfillmentWorkflow["createOrderFulfillmentWorkflow (Medusa)"] + createOrderFulfillmentWorkflow --> emitEventStep["emitEventStep (Medusa)"] + emitEventStep --> End + when -->|No| End +``` + +The workflow has the following steps: + +1. `completeCartWorkflow` to create a Medusa order from the cart. Medusa provides this workflow through the `@medusajs/medusa/core-flows` package and you can use it as a step. +2. `useQueryGraphStep` to retrieve the order’s items with the digital products associated with the purchased product variants. Medusa provides this step through the `@medusajs/medusa/core-flows` package. +3. If the order has digital products, you: + 1. create the digital product order. + 2. link the digital product order with the Medusa order. Medusa provides a `createRemoteLinkStep` in the `@medusajs/medusa/core-flows` package that can be used here. + 3. Create a fulfillment for the digital products in the order. Medusa provides a `createOrderFulfillmentWorkflow` in the `@medusajs/medusa/core-flows` package that you can use as a step here. + 4. Emit the `digital_product_order.created` custom event to handle it later in a subscriber and send the customer an email. Medusa provides a `emitEventStep` in the `@medusajs/medusa/core-flows` that you can use as a step here. + +You’ll only implement the `3.a` step of the workflow. + +### createDigitalProductOrderStep (Step 3.a) + +Create the file `src/workflows/create-digital-product-order/steps/create-digital-product-order.ts` with the following content: + +```ts title="src/workflows/create-digital-product-order/steps/create-digital-product-order.ts" highlights={createDpoHighlights} collapsibleLines="1-14" expandMoreLabel="Show Imports" +import { + createStep, + StepResponse, +} from "@medusajs/framework/workflows-sdk" +import { + OrderLineItemDTO, + ProductVariantDTO, + InferTypeOf, +} from "@medusajs/framework/types" +import { OrderStatus } from "../../../modules/digital-product/types" +import DigitalProductModuleService from "../../../modules/digital-product/service" +import { DIGITAL_PRODUCT_MODULE } from "../../../modules/digital-product" +import DigitalProduct from "../../../modules/digital-product/models/digital-product" + +type StepInput = { + items: (OrderLineItemDTO & { + variant: ProductVariantDTO & { + digital_product: InferTypeOf + } + })[] +} + +const createDigitalProductOrderStep = createStep( + "create-digital-product-order", + async ({ items }: StepInput, { container }) => { + const digitalProductModuleService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + const digitalProductIds = items.map((item) => item.variant.digital_product.id) + + const digitalProductOrder = await digitalProductModuleService + .createDigitalProductOrders({ + status: OrderStatus.PENDING, + products: digitalProductIds, + }) + + return new StepResponse({ + digital_product_order: digitalProductOrder, + }, { + digital_product_order: digitalProductOrder, + }) + }, + async ({ digital_product_order }, { container }) => { + const digitalProductModuleService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + await digitalProductModuleService.deleteDigitalProductOrders( + digital_product_order.id + ) + } +) + +export default createDigitalProductOrderStep +``` + +This creates the `createDigitalProductOrderStep`. In this step, you create a digital product order. + +In the compensation function, you delete the digital product order. + +### Create createDigitalProductOrderWorkflow + +Create the file `src/workflows/create-digital-product-order/index.ts` with the following content: + +```ts title="src/workflows/create-digital-product-order/index.ts" highlights={createDpoWorkflowHighlights} collapsibleLines="1-17" expandMoreLabel="Show Imports" +import { + createWorkflow, + transform, + when, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { + completeCartWorkflow, + useQueryGraphStep, + createRemoteLinkStep, + createOrderFulfillmentWorkflow, + emitEventStep, +} from "@medusajs/medusa/core-flows" +import { + Modules, +} from "@medusajs/framework/utils" +import createDigitalProductOrderStep from "./steps/create-digital-product-order" +import { DIGITAL_PRODUCT_MODULE } from "../../modules/digital-product" + +type WorkflowInput = { + cart_id: string +} + +const createDigitalProductOrderWorkflow = createWorkflow( + "create-digital-product-order", + (input: WorkflowInput) => { + const { id } = completeCartWorkflow.runAsStep({ + input: { + id: input.cart_id, + }, + }) + + const { data: orders } = useQueryGraphStep({ + entity: "order", + fields: [ + "*", + "items.*", + "items.variant.*", + "items.variant.digital_product.*", + "shipping_address.*", + ], + filters: { + id, + }, + options: { + throwIfKeyNotFound: true, + }, + }) + + 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 + } + ).then(() => { + const { + digital_product_order, + } = createDigitalProductOrderStep({ + items: orders[0].items, + }) + + createRemoteLinkStep([{ + [DIGITAL_PRODUCT_MODULE]: { + digital_product_order_id: digital_product_order.id, + }, + [Modules.ORDER]: { + order_id: id, + }, + }]) + + createOrderFulfillmentWorkflow.runAsStep({ + input: { + order_id: id, + items: transform({ + itemsWithDigitalProducts, + }, (data) => { + return data.itemsWithDigitalProducts.map((item) => ({ + id: item.id, + quantity: item.quantity, + })) + }), + }, + }) + + emitEventStep({ + eventName: "digital_product_order.created", + data: { + id: digital_product_order.id, + }, + }) + + return digital_product_order + }) + + return new WorkflowResponse({ + order: orders[0], + digital_product_order, + }) + } +) + +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. 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. + +The workflow returns the Medusa order and the digital product order, if created. + +### Cart Completion API Route + +Next, create the file `src/api/store/carts/[id]/complete-digital/route.ts` with the following content: + +```ts title="src/api/store/carts/[id]/complete-digital/route.ts" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import createDigitalProductOrderWorkflow from "../../../../../workflows/create-digital-product-order" + +export const POST = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const { result } = await createDigitalProductOrderWorkflow(req.scope) + .run({ + input: { + cart_id: req.params.id, + }, + }) + + res.json({ + type: "order", + ...result, + }) +} +``` + +Since you export a `POST` function, you expose a `POST` API route at `/store/carts/[id]/complete-digital`. + +In the route handler, you execute the `createDigitalProductOrderWorkflow` and return the created order in the response. + +### Test Cart Completion: Customize Next.js Starter Storefront + +To test out the cart completion, you'll customize the [Next.js Starter Storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md) that you installed in the first step to use the new cart completion route to place an order. + +The Next.js Starter Storefront was installed in a separate directory from Medusa. The directory's name is `{your-project}-storefront`. + +So, if your Medusa application's directory is `medusa-digital-product`, you can find the storefront by going back to the parent directory and changing to the `medusa-digital-product-storefront` directory: + +```bash +cd ../medusa-digital-product-storefront # change based on your project name +``` + +In the Next.js Starter Storefront, open the file `src/lib/data/cart.ts` and find the following lines in the `placeOrder` function: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="blue" +const cartRes = await sdk.store.cart + .complete(id, {}, headers) +``` + +Replace these lines with the following: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="blue" +const cartRes = await sdk.client.fetch( + `/store/carts/${id}/complete-digital`, + { + method: "POST", + headers, + } + ) +``` + +This will send a `POST` request to the new cart completion route you created to complete the cart. + +Then, run the following command in the Medusa application directory to start the Medusa application: + +```bash npm2yarn badgeLabel="Medusa application" badgeColor="green" +npm run start +``` + +And run the following command in the Next.js Starter Storefront directory to start the Next.js application: + +```bash npm2yarn badgeLabel="Storefront" badgeColor="blue" +npm run dev +``` + +Open the storefront in your browser at `http://localhost:8000` and add a digital product to the cart. + +Then, go through the checkout process. Make sure to choose the shipping option you created in the previous step for shipping. + +Once you place the order, the cart completion route you added above will run, creating the order and digital product order, if the order has digital products. + +In a later step, you’ll add an API route to allow customers to view and download their purchased digital products. + +### Further Read + +- [Conditions in Workflows with When-Then](https://docs.medusajs.com/docs/learn/fundamentals/workflows/conditions/index.html.md) + +*** + +## Step 14: Fulfill Digital Order Workflow + +In this step, you'll create a workflow that fulfills a digital order by sending a notification to the customer. Later, you'll execute this workflow in a subscriber that listens to the `digital_product_order.created` event. + +The workflow has the following steps: + +1. Retrieve the digital product order's details. For this, you'll use `useQueryGraphStep` from Medusa's core workflows. +2. Send a notification to the customer with the digital products to download. +3. Mark the Medusa order's fulfillment as delivered. For this, you'll use `markOrderFulfillmentAsDeliveredWorkflow` from Medusa's core workflows. + +So, you only need to implement the second step. + +### Add Types + +Before creating the step, add to `src/modules/digital-product/types/index.ts` the following: + +```ts +import { OrderDTO, InferTypeOf } from "@medusajs/framework/types" +import DigitalProductOrder from "../models/digital-product-order" + +// ... + +export type DigitalProductOrder = + InferTypeOf & { + order?: OrderDTO + } +``` + +This adds a type for a digital product order, which you'll use next. + +You use `InferTypeOf` to infer the type of the `DigitalProductOrder` data model, and add to it the optional `order` property, which is the linked order. + +### Create sendDigitalOrderNotificationStep + +To create the step, create the file `src/workflows/fulfill-digital-order/steps/send-digital-order-notification.ts` with the following content: + +```ts title="src/workflows/fulfill-digital-order/steps/send-digital-order-notification.ts" collapsibleLines="1-11" expandMoreLabel="Show Imports" +import { + createStep, + StepResponse, +} from "@medusajs/framework/workflows-sdk" +import { + INotificationModuleService, + IFileModuleService, +} from "@medusajs/framework/types" +import { ModuleRegistrationName } from "@medusajs/framework/utils" +import { DigitalProductOrder, MediaType } from "../../../modules/digital-product/types" + +type SendDigitalOrderNotificationStepInput = { + digital_product_order: DigitalProductOrder +} + +export const sendDigitalOrderNotificationStep = createStep( + "send-digital-order-notification", + async ({ + digital_product_order: digitalProductOrder, + }: SendDigitalOrderNotificationStepInput, + { container }) => { + const notificationModuleService: INotificationModuleService = container + .resolve(ModuleRegistrationName.NOTIFICATION) + const fileModuleService: IFileModuleService = container.resolve( + ModuleRegistrationName.FILE + ) + + // TODO assemble notification + } +) +``` + +This creates the `sendDigitalOrderNotificationStep` step that receives a digital product order as an input. + +In the step, so far you resolve the main services of the Notification and File Modules. + +Replace the `TODO` with the following: + +```ts title="src/workflows/fulfill-digital-order/steps/send-digital-order-notification.ts" +const notificationData = await Promise.all( + digitalProductOrder.products.map(async (product) => { + const medias = [] + + await Promise.all( + product.medias + .filter((media) => media.type === MediaType.MAIN) + .map(async (media) => { + medias.push( + (await fileModuleService.retrieveFile(media.fileId)).url + ) + }) + ) + + return { + name: product.name, + medias, + } + }) +) + +// TODO send notification +``` + +In this snippet, you put together the data to send in the notification. You loop over the digital products in the order and retrieve the URL of their main files using the File Module. + +Finally, replace the new `TODO` with the following: + +```ts title="src/workflows/fulfill-digital-order/steps/send-digital-order-notification.ts" +const notification = await notificationModuleService.createNotifications({ + to: digitalProductOrder.order.email, + template: "digital-order-template", + channel: "email", + data: { + products: notificationData, + }, +}) + +return new StepResponse(notification) +``` + +You use the `createNotifications` method of the Notification Module's main service to send an email using the installed provider. + +### Create Workflow + +Create the workflow in the file `src/workflows/fulfill-digital-order/index.ts`: + +```ts title="src/workflows/fulfill-digital-order/index.ts" highlights={fulfillWorkflowHighlights} collapsibleLines="1-10" expandMoreLabel="Show Imports" +import { + createWorkflow, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { + markOrderFulfillmentAsDeliveredWorkflow, + useQueryGraphStep, +} from "@medusajs/medusa/core-flows" +import { sendDigitalOrderNotificationStep } from "./steps/send-digital-order-notification" + +type FulfillDigitalOrderWorkflowInput = { + id: string +} + +export const fulfillDigitalOrderWorkflow = createWorkflow( + "fulfill-digital-order", + ({ id }: FulfillDigitalOrderWorkflowInput) => { + const { data: digitalProductOrders } = useQueryGraphStep({ + entity: "digital_product_order", + fields: [ + "*", + "products.*", + "products.medias.*", + "order.*", + "order.fulfillments.*", + ], + filters: { + id, + }, + options: { + throwIfKeyNotFound: true, + }, + }) + + sendDigitalOrderNotificationStep({ + digital_product_order: digitalProductOrders[0], + }) + + markOrderFulfillmentAsDeliveredWorkflow.runAsStep({ + input: { + orderId: digitalProductOrders[0].order.id, + fulfillmentId: digitalProductOrders[0].order.fulfillments[0].id, + }, + }) + + return new WorkflowResponse( + digitalProductOrders[0] + ) + } +) +``` + +In the workflow, you: + +1. Retrieve the digital product order's details using `useQueryGraphStep` from Medusa's core workflows. +2. Send a notification to the customer with the digital product download links using the `sendDigitalOrderNotificationStep`. +3. Mark the order's fulfillment as delivered using `markOrderFulfillmentAsDeliveredWorkflow` from Medusa's core workflows. + +### Configure Notification Module Provider + +In the `sendDigitalOrderNotificationStep`, you use a notification provider configured for the `email` channel to send the notification. + +Check out the [Integrations page](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/integrations/index.html.md) to find Notification Module Providers. + +For testing purposes, add to `medusa-config.ts` the following to use the Local Notification Module Provider: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // ... + modules: [ + // ... + { + resolve: "@medusajs/medusa/notification", + options: { + providers: [ + { + resolve: "@medusajs/medusa/notification-local", + id: "local", + options: { + name: "Local Notification Provider", + channels: ["email"], + }, + }, + ], + }, + }, + ], +}) + +``` + +*** + +## Step 15: Handle the Digital Product Order Event + +In this step, you'll create a subscriber that listens to the `digital_product_order.created` event and executes the workflow from the above step. + +Create the file `src/subscribers/handle-digital-order.ts` with the following content: + +```ts title="src/subscribers/handle-digital-order.ts" collapsibleLines="1-8" expandMoreLabel="Show Imports" +import type { + SubscriberArgs, + SubscriberConfig, +} from "@medusajs/framework" +import { + fulfillDigitalOrderWorkflow, +} from "../workflows/fulfill-digital-order" + +async function digitalProductOrderCreatedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + await fulfillDigitalOrderWorkflow(container).run({ + input: { + id: data.id, + }, + }) +} + +export default digitalProductOrderCreatedHandler + +export const config: SubscriberConfig = { + event: "digital_product_order.created", +} +``` + +This adds a subscriber that listens to the `digital_product_order.created` event. It executes the `fulfillDigitalOrderWorkflow` to send the customer an email and mark the order's fulfillment as fulfilled. + +### Test Subscriber Out + +To test out the subscriber, place an order with digital products. This triggers the `digital_product_order.created` event which executes the subscriber. + +*** + +## Step 16: Create Store API Routes + +In this step, you’ll create three store API routes: + +1. Retrieve the preview files of a digital product. This is useful when the customer is browsing the products before purchase. +2. List the digital products that the customer has purchased. +3. Get the download link to a media of the digital product that the customer purchased. + +### Retrieve Digital Product Previews API Route + +Create the file `src/api/store/digital-products/[id]/preview/route.ts` with the following content: + +```ts title="src/api/store/digital-products/[id]/preview/route.ts" highlights={previewRouteHighlights} collapsibleLines="1-15" expandMoreLabel="Show Imports" +import { + MedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { + Modules, +} from "@medusajs/framework/utils" +import { + DIGITAL_PRODUCT_MODULE, +} from "../../../../../modules/digital-product" +import DigitalProductModuleService from "../../../../../modules/digital-product/service" +import { + MediaType, +} from "../../../../../modules/digital-product/types" + +export const GET = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const fileModuleService = req.scope.resolve( + Modules.FILE + ) + + const digitalProductModuleService: DigitalProductModuleService = + req.scope.resolve( + DIGITAL_PRODUCT_MODULE + ) + + const medias = await digitalProductModuleService.listDigitalProductMedias({ + digital_product_id: req.params.id, + type: MediaType.PREVIEW, + }) + + const normalizedMedias = await Promise.all( + medias.map(async (media) => { + const { fileId, ...mediaData } = media + const fileData = await fileModuleService.retrieveFile(fileId) + + return { + ...mediaData, + url: fileData.url, + } + }) + ) + + res.json({ + previews: normalizedMedias, + }) +} +``` + +This adds a `GET` API route at `/store/digital-products/[id]/preview`, where `[id]` is the ID of the digital product to retrieve its preview media. + +In the route handler, you retrieve the preview media of the digital product and then use the File Module’s service to get the URL of the preview file. + +You return in the response the preview files. + +### List Digital Product Purchases API Route + +Create the file `src/api/store/customers/me/digital-products/route.ts` with the following content: + +```ts title="src/api/store/customers/me/digital-products/route.ts" highlights={purchasedDpHighlights} collapsibleLines="1-8" expandMoreLabel="Show Imports" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { + ContainerRegistrationKeys, +} from "@medusajs/framework/utils" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: [customer] } = await query.graph({ + entity: "customer", + fields: [ + "orders.digital_product_order.products.*", + "orders.digital_product_order.products.medias.*", + ], + filters: { + id: req.auth_context.actor_id, + }, + }) + + const digitalProducts = {} + + customer.orders.forEach((order) => { + order.digital_product_order.products.forEach((product) => { + digitalProducts[product.id] = product + }) + }) + + res.json({ + digital_products: Object.values(digitalProducts), + }) +} +``` + +This adds a `GET` API route at `/store/customers/me/digital-products`. All API routes under `/store/customers/me` require customer authentication. + +In the route handler, you use Query to retrieve the customer’s orders and linked digital product orders, and return the purchased digital products in the response. + +### Get Digital Product Media Download URL API Route + +Create the file `src/api/store/customers/me/digital-products/[mediaId]/download/route.ts` with the following content: + +```ts title="src/api/store/customers/me/digital-products/[mediaId]/download/route.ts" highlights={downloadUrlHighlights} collapsibleLines="1-10" expandMoreLabel="Show Imports" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { + Modules, + ContainerRegistrationKeys, + MedusaError, +} from "@medusajs/framework/utils" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const fileModuleService = req.scope.resolve( + Modules.FILE + ) + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: [customer] } = await query.graph({ + entity: "customer", + fields: [ + "orders.digital_product_order.*", + ], + filters: { + id: req.auth_context.actor_id, + }, + }) + + const customerDigitalOrderIds = customer.orders + .filter((order) => order.digital_product_order !== undefined) + .map((order) => order.digital_product_order.id) + + const { data: dpoResult } = await query.graph({ + entity: "digital_product_order", + fields: [ + "products.medias.*", + ], + filters: { + id: customerDigitalOrderIds, + }, + }) + + if (!dpoResult.length) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Customer didn't purchase digital product." + ) + } + + let foundMedia = undefined + + dpoResult[0].products.some((product) => { + return product.medias.some((media) => { + foundMedia = media.id === req.params.mediaId ? media : undefined + + return foundMedia !== undefined + }) + }) + + if (!foundMedia) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Customer didn't purchase digital product." + ) + } + + const fileData = await fileModuleService.retrieveFile(foundMedia.fileId) + + res.json({ + url: fileData.url, + }) +} +``` + +This adds a `POST` API route at `/store/customers/me/digital-products/[mediaId]`, where `[mediaId]` is the ID of the digital product media to download. + +In the route handler, you retrieve the customer’s orders and linked digital orders, then check if the digital orders have the required media file. If not, an error is thrown. + +If the media is found in th customer's previous purchases, you use the File Module’s service to retrieve the download URL of the media and return it in the response. + +You’ll test out these API routes in the next step. + +### Further Reads + +- [What are protected API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/protected-routes/index.html.md) + +*** + +## Step 17: Customize Next.js Starter + +In this section, you’ll customize the [Next.js Starter storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md) to: + +1. Show a preview button on a digital product’s page to view the preview files. +2. Add a new tab in the customer’s dashboard to view their purchased digital products. +3. Allow customers to download the digital products through the new page in the dashboard. + +The Next.js Starter Storefront was installed in a separate directory from Medusa. The directory's name is `{your-project}-storefront`. + +So, if your Medusa application's directory is `medusa-digital-product`, you can find the storefront by going back to the parent directory and changing to the `medusa-digital-product-storefront` directory: + +```bash +cd ../medusa-digital-product-storefront # change based on your project name +``` + +### Add Types + +In `src/types/global.ts`, add the following types that you’ll use in your customizations: + +```ts title="src/types/global.ts" badgeLabel="Storefront" badgeColor="blue" +import { + // other imports... + StoreProductVariant, +} from "@medusajs/types" + +// ... + +export type DigitalProduct = { + id: string + name: string + medias?: DigitalProductMedia[] +} + +export type DigitalProductMedia = { + id: string + fileId: string + type: "preview" | "main" + mimeType: string + digitalProduct?: DigitalProduct[] +} + +export type DigitalProductPreview = DigitalProductMedia & { + url: string +} + +export type VariantWithDigitalProduct = StoreProductVariant & { + digital_product?: DigitalProduct +} + +``` + +### Retrieve Digital Products with Variants + +To retrieve the digital products details when retrieving a product and its variants, in the `src/lib/data/products.ts` file, change the `listProducts` function to pass the digital products in the `fields` property passed to the `sdk.store.product.list` method: + +```ts title="src/lib/data/products.ts" highlights={fieldHighlights} badgeLabel="Storefront" badgeColor="blue" +export const listProducts = async ({ + pageParam = 1, + queryParams, + countryCode, + regionId, +}: { + pageParam?: number + queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams + countryCode?: string + regionId?: string +}): Promise<{ + response: { products: HttpTypes.StoreProduct[]; count: number } + nextPage: number | null + queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams +}> => { + // ... + return sdk.client + .fetch<{ products: HttpTypes.StoreProduct[]; count: number }>( + `/store/products`, + { + // ... + query: { + // ... + fields: "*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags,*variants.calculated_price,*variants.digital_product", + }, + } + ) + // ... +} +``` + +When a customer views a product’s details page, digital products linked to variants are also retrieved. + +### Get Digital Product Preview Links + +To retrieve the links of a digital product’s preview media, first, add the following import at the top of `src/lib/data/products.ts`: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" +import { DigitalProductPreview } from "../../types/global" +``` + +Then, add the following function at the end of the file: + +```ts title="src/lib/data/products.ts" +export const getDigitalProductPreview = async function ({ + id, +}: { + id: string +}) { + const headers = { + ...(await getAuthHeaders()), + } + + const next = { + ...(await getCacheOptions("products")), + } + const { previews } = await sdk.client.fetch<{ + previews: DigitalProductPreview[] + }>( + `/store/digital-products/${id}/preview`, + { + headers, + next, + cache: "force-cache", + } + ) + + // for simplicity, return only the first preview url + // instead you can show all the preview media to the customer + return previews.length ? previews[0].url : "" +} +``` + +This function uses the API route you created in the previous section to get the preview links and return the first preview link. + +### Add Preview Button + +To add a button that shows the customer the preview media of a digital product, first, in `src/modules/products/components/product-actions/index.tsx`, cast the `selectedVariant` variable in the component to the `VariantWithDigitalProduct` type you created earlier: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" +// other imports... +import { VariantWithDigitalProduct } from "../../../../types/global" + +export default function ProductActions({ + product, + region, + disabled, +}: ProductActionsProps) { + + // ... + + const selectedVariant = useMemo(() => { + // ... + }, [product.variants, options]) as VariantWithDigitalProduct + + // ... +} +``` + +Then, add the following function in the component: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" +// other imports... +import { getDigitalProductPreview } from "../../../../lib/data/products" + +export default function ProductActions({ + product, + region, + disabled, +}: ProductActionsProps) { + // ... + + const handleDownloadPreview = async () => { + if (!selectedVariant?.digital_product) { + return + } + + const downloadUrl = await getDigitalProductPreview({ + id: selectedVariant?.digital_product.id, + }) + + if (downloadUrl.length) { + window.open(downloadUrl) + } + } + + // ... +} +``` + +This function uses the `getDigitalProductPreview` function you created earlier to retrieve the preview URL of the selected variant’s digital product. + +Finally, in the `return` statement, add a new button above the add-to-cart button: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" +return ( +
+ {/* Before add to cart */} + {selectedVariant?.digital_product && ( + + )} +
+) +``` + +This button is only shown if the selected variant has a digital product. When it’s clicked, the preview URL is retrieved to show the preview media to the customer. + +### Test Preview Out + +To test it out, run the Next.js starter with the Medusa application, then open the details page of a product that’s digital. You should see a “Download Preview” button to download the preview media of the product. + +### Add Digital Purchases Page + +You’ll now create the page customers can view their purchased digital product in. + +Start by creating the file `src/lib/data/digital-products.ts` with the following content: + +```ts title="src/lib/data/digital-products.ts" badgeLabel="Storefront" badgeColor="blue" +"use server" + +import { DigitalProduct } from "../../types/global" +import { sdk } from "../config" +import { getAuthHeaders, getCacheOptions } from "./cookies" + +export const getCustomerDigitalProducts = async () => { + const headers = { + ...(await getAuthHeaders()), + } + + const next = { + ...(await getCacheOptions("products")), + } + const { digital_products } = await sdk.client.fetch<{ + digital_products: DigitalProduct[] + }>(`/store/customers/me/digital-products`, { + + headers, + next, + cache: "force-cache", + }) + + return digital_products as DigitalProduct[] +} +``` + +The `getCustomerDigitalProducts` retrieves the logged-in customer’s purchased digital products by sending a request to the API route you created earlier. + +Then, create the file `src/modules/account/components/digital-products-list/index.tsx` with the following content: + +```tsx title="src/modules/account/components/digital-products-list/index.tsx" badgeLabel="Storefront" badgeColor="blue" +"use client" + +import { Table } from "@medusajs/ui" +import { DigitalProduct } from "../../../../types/global" + +type Props = { + digitalProducts: DigitalProduct[] +} + +export const DigitalProductsList = ({ + digitalProducts, +}: Props) => { + return ( + + + + Name + Action + + + + {digitalProducts.map((digitalProduct) => { + const medias = digitalProduct.medias?.filter((media) => media.type === "main") + const showMediaCount = (medias?.length || 0) > 1 + return ( + + + {digitalProduct.name} + + + + + + ) + })} + +
+ ) +} +``` + +This adds a `DigitalProductsList` component that receives a list of digital products and shows them in a table. Each digital product’s media has a download link. You’ll implement its functionality afterwards. + +Next, create the file `src/app/[countryCode]/(main)/account/@dashboard/digital-products/page.tsx` with the following content: + +```tsx title="src/app/[countryCode]/(main)/account/@dashboard/digital-products/page.tsx" badgeLabel="Storefront" badgeColor="blue" +import { Metadata } from "next" + +import { getCustomerDigitalProducts } from "../../../../../../lib/data/digital-products" +import { DigitalProductsList } from "../../../../../../modules/account/components/digital-products-list" + +export const metadata: Metadata = { + title: "Digital Products", + description: "Overview of your purchased digital products.", +} + +export default async function DigitalProducts() { + const digitalProducts = await getCustomerDigitalProducts() + + return ( +
+
+

Digital Products

+

+ View the digital products you've purchased and download them. +

+
+
+ +
+
+ ) +} +``` + +This adds a new route in your Next.js application to show the customer’s purchased digital products. + +In the route, you retrieve the digital’s products using the `getCustomerDigitalProducts` function and pass them as the prop of the `DigitalProductsList` component. + +Finally, to add a tab in the customer’s account dashboard that links to this page, add it in the `src/modules/account/components/account-nav/index.tsx` file: + +```tsx title="src/modules/account/components/account-nav/index.tsx" badgeLabel="Storefront" badgeColor="blue" +// other imports... +import { Photo } from "@medusajs/icons" + +const AccountNav = ({ + customer, +}: { + customer: HttpTypes.StoreCustomer | null +}) => { + // ... + + return ( +
+
+ {/* ... */} + {/* Add before log out */} +
  • + +
    + + Digital Products +
    + +
    +
  • + {/* ... */} +
    +
    + {/* ... */} + {/* Add before log out */} +
  • + + Digital Products + +
  • + {/* ... */} +
    +
    + ) +} +``` + +You add a link to the new route before the log out tab both for small and large devices. + +### Test Purchased Digital Products Page + +To test out this page, first, log-in as a customer and place an order with a digital product. + +Then, go to the customer’s account page and click on the new Digital Products tab. You’ll see a table of digital products to download. + +### Add Download Link + +To add a download link for the purchased digital products’ medias, first, add a new function to `src/lib/data/digital-products.ts`: + +```ts title="src/lib/data/digital-products.ts" badgeLabel="Storefront" badgeColor="blue" +export const getDigitalMediaDownloadLink = async (mediaId: string) => { + const headers = { + ...(await getAuthHeaders()), + } + + const next = { + ...(await getCacheOptions("products")), + } + const { url } = await sdk.client.fetch<{ + url: string + }>(`/store/customers/me/digital-products/${mediaId}/download`, { + method: "POST", + headers, + next, + cache: "force-cache", + }) + + return url +} +``` + +In this function, you send a request to the download API route you created earlier to retrieve the download URL of a purchased digital product media. + +Then, in `src/modules/account/components/digital-products-list/index.tsx`, import the `getDigitalMediaDownloadLink` at the top of the file: + +```tsx title="src/modules/account/components/digital-products-list/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { getDigitalMediaDownloadLink } from "../../../../lib/data/digital-products" +``` + +And add a `handleDownload` function in the `DigitalProductsList` component: + +```tsx title="src/modules/account/components/digital-products-list/index.tsx" +const handleDownload = async ( + e: React.MouseEvent, + mediaId: string +) => { + e.preventDefault() + + const url = await getDigitalMediaDownloadLink(mediaId) + + window.open(url) +} +``` + +This function uses the `getDigitalMediaDownloadLink` function to get the download link and opens it in a new window. + +Finally, add an `onClick` handler to the digital product medias’ link in the return statement: + +```tsx title="src/modules/account/components/digital-products-list/index.tsx" + handleDownload(e, media.id)}> + Download{showMediaCount ? ` ${index + 1}` : ``} + +``` + +### Test Download Purchased Digital Product Media + +To test the latest changes out, open the purchased digital products page and click on the Download link of any media in the table. The media’s download link will open in a new page. + +*** + +## Next Steps + +The next steps of this example depend on your use case. This section provides some insight into implementing them. + +### Storefront Development + +Aside from customizing the Next.js Starter storefront, you can also create a custom storefront. Check out the [Storefront Development](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/storefront-development/index.html.md) section to learn how to create a storefront. + +### Admin Development + +In this recipe, you learned how to customize the admin with UI routes. You can also do further customization using widgets. Learn more in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/admin/index.html.md). + + +# Digital Products Recipe + +This recipe provides the general steps to implement digital products in your Medusa application. + +Follow the step-by-step [Digital Products Example](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/recipes/digital-products/examples/standard/index.html.md) to learn how to implement digital products in your Medusa application. + +## Overview + +Digial products are products that are stored and deliveerd electronically. Examples include e-books, software, and digital art. + +When the customer buys a digital product, an email is sent to them where they can download the product. + +To implement digital products in Medusa, you create a Digital Product Module that introduces the concept of a digital product and link it to existing product concepts in the Product Module. + +*** + +## Install a File Module Provider + +A file module provider handles storage functionalities in Medusa. This includes uploading, retrieving, and downloading files, among other features. + +You can use a file module provider to store and manage your digital products. + +During development, you can use the Local File Module Provider, which is installed by default in your store. For production, you can use module providers like [S3](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/infrastructure-modules/file/s3/index.html.md) or create your own. + +- [File Module Providers](https://docs.medusajs.com/infrastructure-modules/file/index.html.md): Check out available file module providers. +- [Create a File Module Provider](https://docs.medusajs.com/references/file-provider-module/index.html.md): Learn how to create a file module provider. + +*** + +## Create Digital Product Module + +Your custom features and functionalities are implemented inside modules. The module is integrated into the Medusa application without any implications on existing functionalities. + +You can create a custom module for digital products that holds your custom data models and the service implementing digital-product-related features. + +[How to Create a Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md): Learn how to create a module. + +### Create Custom Data Model + +A data model represents a table in the database. You can define in your module data models to store data related to your custom features, such as a digital product. + +Then, you can link your custom data model to data models from other modules. For example, you can link the digital product model to the Product Module's `ProductVariant` data model. + +- [How to Create a Data Model](https://docs.medusajs.com/docs/learn/fundamentals/modules#1-create-data-model/index.html.md): Learn how to create a data model. +- [Define Module Links](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md): Define links between data models. + +### Implement Data Management Features + +Your module’s main service holds data-management and other related features. Then, in other resources, such as an API route, you can resolve the service from the Medusa container and use its functionalities. + +Medusa facilitates implementing data-management features using the service factory. Your module's main service can extend this service factory, and it generates data-management methods for your data models. + +[Service Factory](https://docs.medusajs.com/docs/learn/fundamentals/modules/service-factory/index.html.md): Learn about the service factory and how to use it. + +*** + +## Build Flows for Digital Products + +Your use case most likely has flows, such as creating digital products, that require multiple steps. + +Create workflows to implement these flows, then utilize these workflows in other resources, such as an API route. + +In the workflow's steps, you can resolve the Digital Product Module's service and use its data-management methods to manage digital products. + +[Workflows](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md): Learn how to create a workflow. + +*** + +## Add Custom API Routes + +API routes expose your features to external applications, such as the admin dashboard or the storefront. + +You can create custom admin API routes that allow merchants to list and create digital products, and store API routes that allow customers to purchase and download digital products. + +[API Routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md): Learn how to create an API route. + +*** + +## Customize Admin Dashboard + +Based on your use case, you may need to customize the Medusa Admin to add new widgets or pages. + +For example, you can create a page that lists all digital products or a widget that allows merchants to view the digital data associated with a product. + +The Medusa Admin is an extensible application within your Medusa application. You can customize it by: + +- **Widgets**: Adding widgets to existing pages, such as the product page. +- **UI Routes**: Adding new pages to the Medusa Admin, such as a page to manage digital products. +- **Settings Pages**: Adding new pages to the Medusa Admin settings, such as a page to manage digital product settings. + +- [Create Admin Widget](https://docs.medusajs.com/docs/learn/fundamentals/admin/widgets/index.html.md): Add widgets into existing admin pages. +- [Create Admin UI Routes](https://docs.medusajs.com/docs/learn/fundamentals/admin/ui-routes/index.html.md): Add new pages to your Medusa Admin. + +[Create Admin Setting Page](https://docs.medusajs.com/docs/learn/fundamentals/admin/ui-routes#create-settings-page/index.html.md): Add new page to the Medusa Admin settings. + +*** + +## Deliver Digital Products to the Customer + +When a customer purchases a digital product, they should receive a link to download it. + +The Fulfillment Module handles all logic related to fulfilling orders. It also supports using fulfillment module providers that implement the logic of fulfilling orders with third-party services. + +You can create a custom fulfillment module provider that implements the logic of delivering digital products to customers based on your use case. + +- [Fulfillment Module](https://docs.medusajs.com/commerce-modules/fulfillment/index.html.md): Learn about the Fulfillment Module. +- [Create Fulfillment Module Provider](https://docs.medusajs.com/references/fulfillment/provider/index.html.md): Learn how to create a fulfillment module provider. + +*** + +## Customize or Build Storefront + +Customers use your storefront to browse your digital products and purchase them. You can also provide other helpful features, such as previewing the digital product before purchase. + +Medusa provides a Next.js Starter Storefront with standard commerce features including listing products, placing orders, and managing accounts. You can customize the storefront and cater its functionalities to support digital products. + +Alternatively, you can build your own storefront using the Medusa APIs. This headless approach gives you the flexibility to build a custom storefront without limitations on which tech stack you use, or the design of the storefront. + +- [Next.js Starter Storefront](https://docs.medusajs.com/nextjs-starter/index.html.md): Learn how to install and use the Next.js Starter Storefront. +- [Storefront Guides](https://docs.medusajs.com/storefront-development/index.html.md): Find guides to build your own storefront. + + +# Ecommerce Recipe + +This recipe provides the general steps to create an ecommerce store with Medusa. + +## Overview + +Businesses use ecommerce stores to: + +- Provide an online catalog for customers. +- Accept customer orders and payment. +- Manage their store's data and logistics. + +Medusa provides all essential commerce features out-of-the-box. Businesses can go live and start selling without making any adjustments. + +Businesses can also power-up their store by integrating third-party services for payments, fulfillment, and more. + +[How Tekla created an ecommerce store using Medusa](https://medusajs.com/blog/tekla/). + +*** + +## Install Ecommerce Store Powered by Medusa + +You can use the following command to install an ecommerce store with Medusa: + +```bash +npx create-medusa-app@latest --with-nextjs-starter +``` + +This installs: + +- The Medusa application, which is composed of a Node.js server and a Medusa Admin dashboard. The dashboard opens right after the installation finishes, where you can create a user and start managing your store's data. +- A [Next.js Starter Storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md) that connects to your Medusa application to provide customers with ecommerce features. + +*** + +## Integrate Third-Party Services + +You can integrate third-party services and tools, customizing the architecture and commerce features of your store. + +For example, you can integrate [Stripe](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/payment-provider/stripe/index.html.md) to accept payments, or [SendGrid](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/infrastructure-modules/notification/sendgrid/index.html.md) to send emails to customers. + +[Integrations](https://docs.medusajs.com/integrations/index.html.md): Check out available integrations for your Medusa application. + +*** + +## Deploy the Medusa Application + +The most efficient way to deploy your Medusa application is to use [Cloud](https://docs.medusajs.com/cloud/index.html.md). Cloud is our managed services offering that makes deploying and operating Medusa applications possible without having to worry about configuring, scaling, and maintaining infrastructure. Cloud hosts your server, Admin dashboard, database, and Redis instance. + +With Cloud, you maintain full customization control as you deploy your own modules and customizations directly from GitHub: + +- Push to deploy. +- Multiple testing environments. +- Preview environments for new PRs. +- Test on production-like data. + +Our documentation also provides a step-by-step guides to deploy your Medusa application and the Next.js Starter Storefront. + +- [Sign up for Cloud](https://docs.medusajs.com/cloud/index.html.md): Learn more about Cloud and sign up to get started. +- [Deployment Guides](https://docs.medusajs.com/deployment/index.html.md): Learn how to deploy the Medusa application and Next.js Starter Storefront. + +*** + +## Add Custom Features + +Along with the extensive ecommerce features, Medusa also provides the architecture and Framework to customize your application and build custom features that are tailored for your business use case. + +To learn how to develop customziations with Medusa, refer to the [Get Started](https://docs.medusajs.com/docs/learn/index.html.md) documentation. + + +# Integrate Odoo with Medusa + +In this guide, you will learn how to implement the integration layer between Odoo and Medusa. + +When you install a Medusa application, you get a fully-fledged commerce platform that supports customizations. However, your business might already be using other systems such as an ERP to centralize data and processes. The Medusa Framework facilitates integrating the ERP system and using its data to enrich your commerce platform. + +Odoo is a suite of open-source business apps that covers all your business needs, including an ERP system. You can use Odoo to store products and their prices, manage orders, and more. + +This guide will teach you how to implement the general integration between Medusa and Odoo. You will learn how to connect to Odoo's APIs and fetch data such as products. You can then expand on this integration to implement your business requirements. You can also refer to [this recipe](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/recipes/erp/index.html.md) to find general examples of ERP integration use cases and how to implement them. + +*** + +## Step 1: Install a Medusa Application + +### Prerequisites + +- [Node.js v20+](https://nodejs.org/en/download) +- [Git CLI tool](https://git-scm.com/downloads) +- [PostgreSQL](https://www.postgresql.org/download/) + +Start by installing the Medusa application on your machine with the following command: + +```bash +npx create-medusa-app@latest +``` + +You will first be asked for the project's name. You can also optionally choose to install the [Next.js Starter Storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md). + +Afterward, the installation process will start, installing the Medusa application in a directory with your project's name. If you chose to install the Next.js starter, it'll be installed in a separate directory with the `{project-name}-storefront` name. + +The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). Learn more about Medusa's architecture in [this documentation](https://docs.medusajs.com/docs/learn/introduction/architecture/index.html.md). + +Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credential and submit the form. Afterwards, you can login with the new user and explore the dashboard. + +Check out the [troubleshooting guides](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/troubleshooting/create-medusa-app-errors/index.html.md) for help. + +*** + +## Step 2: Install JSONRPC Package + +[Odoo's APIs](https://www.odoo.com/documentation/18.0/developer/reference/external_api.html) are based on the XML-RPC and JSON-RPC protocols. So, to connect to Odoo's APIs, you need a JSON-RPC client library. + +Run the following command in the Medusa application to install the `json-rpc-2.0` package: + +```bash npm2yarn +npm install json-rpc-2.0 +``` + +You will use this package in the next steps to connect to Odoo's APIs. + +*** + +## Step 3: Create Odoo Module + +To integrate third-party systems into Medusa, you create a custom [module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md). A module is a re-usable package with functionalities related to a single feature or domain. Medusa integrates the module into your application without implications or side effects on your setup. + +In this step, you'll create an Odoo Module that provides the interface to connect to and interact with Odoo. You will later use this module when implementing the product syncing logic. + +Learn more about modules in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md). + +### Create Module Directory + +A module is created under the `src/modules` directory of your Medusa application. So, create the directory `src/modules/odoo`. + +![Diagram showcasing directory structure after creating the module directory](https://res.cloudinary.com/dza7lstvk/image/upload/v1740474975/Medusa%20Resources/odoo-1_en3bso.jpg) + +### Create Service + +You define a module's functionalities in a service. A service is a TypeScript or JavaScript class that the module exports. In the service's methods, you can connect to the database, which is useful if your module defines tables in the database, or connect to a third-party service. + +Medusa registers the module's service in the [Medusa container](https://docs.medusajs.com/docs/learn/fundamentals/medusa-container/index.html.md), allowing you to easily resolve the service from other customizations and use its methods. + +The Medusa application registers resources, such as a module's service or the [logging tool](https://docs.medusajs.com/docs/learn/debugging-and-testing/logging/index.html.md), in the Medusa container so that you can resolve them from other customizations, as you'll see in later sections. Learn more about it in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/medusa-container/index.html.md). + +In this section, you'll create the Odoo Module's service and the methods necessary to connect to Odoo. + +To create the service, create the file `src/modules/odoo/service.ts` with the following content: + +![Diagram showcasing directory structure after creating the service file](https://res.cloudinary.com/dza7lstvk/image/upload/v1740474976/Medusa%20Resources/odoo-2_y76dfs.jpg) + +```ts title="src/modules/odoo/service.ts" +import { JSONRPCClient } from "json-rpc-2.0" + +type Options = { + url: string + dbName: string + username: string + apiKey: string +} + +export default class OdooModuleService { + private options: Options + private client: JSONRPCClient + + constructor({}, options: Options) { + this.options = options + + this.client = new JSONRPCClient((jsonRPCRequest) => { + fetch(`${options.url}/jsonrpc`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(jsonRPCRequest), + }).then((response) => { + if (response.status === 200) { + // Use client.receive when you received a JSON-RPC response. + return response + .json() + .then((jsonRPCResponse) => this.client.receive(jsonRPCResponse)) + } else if (jsonRPCRequest.id !== undefined) { + return Promise.reject(new Error(response.statusText)) + } + }) + }) + } +} +``` + +You create an `OdooModuleService` class that has two class properties: + +1. `options`: An object that holds the Odoo Module's options. Those include the API key, URL, database name, and username. You'll learn how to pass those to the module later. +2. `client`: An instance of the `JSONRPCClient` class from the `json-rpc-2.0` package. You'll use this client to connect to Odoo's APIs. + +The service's constructor accepts as a second parameter the module's options. So, you use those to initialize the `options` property and create the `client` property. The `client` property is initialized with a function that sends a JSON-RPC request to Odoo's API and receives the response. + +Next, you will add the methods to log in and fetch data from Odoo. + +### Login Method + +Before sending any request to Odoo's APIs, you need to have an authenticated UID of the user. So, you'll implement a method to retrieve that UID when it's not set. + +Start by adding a `uid` property to the `OdooModuleService` class: + +```ts title="src/modules/odoo/service.ts" +export default class OdooModuleService { + private uid?: number + // ... +} +``` + +Then, add the following `login` method: + +```ts title="src/modules/odoo/service.ts" +export default class OdooModuleService { + // ... + async login() { + this.uid = await this.client.request("call", { + service: "common", + method: "authenticate", + args: [ + this.options.dbName, + this.options.username, + this.options.apiKey, + {}, + ], + }) + } +} +``` + +The `login` method sends a JSON-RPC request to [Odoo's API to authenticate the user](https://www.odoo.com/documentation/18.0/developer/reference/external_api.html#logging-in). It uses the `client` property to send a request with the `service`, `method`, and `args` properties. + +If the authentication was successful, Odoo returns a UID, which you store in the `uid` property. + +### Fetch Products Method + +You can fetch many data from Odoo based on your business requirements, or create data in Odoo. For this guide, you'll only learn how to fetch products. You will use this method later to sync products from Odoo to Medusa. + +First, add the following types to `src/modules/odoo/service.ts`: + +```ts title="src/modules/odoo/service.ts" +export type Pagination = { + offset?: number + limit?: number +} + +export type OdooProduct = { + id: number + display_name: string + is_published: boolean + website_url: string + name: string + list_price: number + description: string | false + description_sale: string | false + product_variant_ids: OdooProductVariant[] + qty_available: number + location_id: number | false + taxes_id: number[] + hs_code: string | false + allow_out_of_stock_order: boolean + is_kits: boolean + image_1920: string + image_1024: string + image_512: string + image_256: string + image_128: string + attribute_line_ids: { + attribute_id: { + display_name: string + } + value_ids: { + display_name: string + }[] + }[] + currency_id: { + id: number + display_name: string + } +} + +export type OdooProductVariant = Omit< + OdooProduct, + "product_variant_ids" | "attribute_line_ids" +> & { + product_template_variant_value_ids: { + id: number + name: string + attribute_id: { + display_name: string + } + }[] + code: string +} +``` + +You define the following types: + +- `Pagination`: An object that holds the pagination options for fetching products. +- `OdooProduct`: An object that represents an Odoo product. You define the properties that you'll fetch from Odoo's API. You can add more properties based on your business requirements. +- `OdooProductVariant`: An object that represents an Odoo product variant. You define the properties that you'll fetch from Odoo's API. You can add more properties based on your business requirements. + +Then, add the following `listProducts` method to the `OdooModuleService` class: + +```ts title="src/modules/odoo/service.ts" +export default class OdooModuleService { + // ... + async listProducts(filters?: any, pagination?: Pagination) { + if (!this.uid) { + await this.login() + } + + const { offset, limit } = pagination || { offset: 0, limit: 10 } + + const ids = await this.client.request("call", { + service: "object", + method: "execute_kw", + args: [ + this.options.dbName, + this.uid, + this.options.apiKey, + "product.template", + "search", + filters || [[ + ["is_product_variant", "=", false], + ]], { + offset, + limit, + }, + ], + }) + + // TODO retrieve product details based on ids + } +} +``` + +In the `listProducts` method, you first check if the user is authenticated, and call the `login` method otherwise. Then, you send a JSON-RPC request to retrieve product IDs from Odoo with pagination and filter options. Odoo's APIs require you to first retrieve the IDs of the products and then fetch the details of each product. + +To retrieve the products, replace the `TODO` with the following: + +```ts title="src/modules/odoo/service.ts" +// product fields to retrieve +const productSpecifications = { + id: {}, + display_name: {}, + is_published: {}, + website_url: {}, + name: {}, + list_price: {}, + description: {}, + description_sale: {}, + qty_available: {}, + location_id: {}, + taxes_id: {}, + hs_code: {}, + allow_out_of_stock_order: {}, + is_kits: {}, + image_1920: {}, + image_1024: {}, + image_512: {}, + image_256: {}, + currency_id: { + fields: { + display_name: {}, + }, + }, +} + +// retrieve products +const products: OdooProduct[] = await this.client.request("call", { + service: "object", + method: "execute_kw", + args: [ + this.options.dbName, + this.uid, + this.options.apiKey, + type, + "web_read", + [ids], + { + specification: { + ...productSpecifications, + product_variant_ids: { + fields: { + ...productSpecifications, + product_template_variant_value_ids: { + fields: { + name: {}, + attribute_id: { + fields: { + display_name: {}, + }, + }, + }, + context: { + show_attribute: false, + }, + }, + code: {}, + }, + context: { + show_code: false, + }, + }, + attribute_line_ids: { + fields: { + attribute_id: { + fields: { + display_name: {}, + }, + }, + value_ids: { + fields: { + display_name: {}, + }, + context: { + show_attribute: false, + }, + }, + }, + }, + }, + }, + ], +}) + +return products +``` + +You first define the `productSpecifications` object that holds the fields you want to fetch for each product and its variants. So, if you want to add more fields, you can add them in this object. + +Then, you send a request to Odoo to fetch the products' details based on the IDs you retrieved earlier. You use the `productSpecifications` object to define the fields you want to fetch for each product and its variants. Finally, you return the fetched products. + +You will use the `listProducts` method to sync products from Odoo to Medusa in the next steps. + +### Export Module Definition + +The final piece to a module is its definition, which you export in an `index.ts` file at its root directory. This definition tells Medusa the name of the module and its service. + +So, create the file `src/modules/odoo/index.ts` with the following content: + +![Diagram showcasing directory structure after creating the module definition file](https://res.cloudinary.com/dza7lstvk/image/upload/v1740475883/Medusa%20Resources/odoo-3_q5y803.jpg) + +```ts title="src/modules/odoo/index.ts" +import OdooModuleService from "./service" +import { Module } from "@medusajs/framework/utils" + +export const ODOO_MODULE = "odoo" + +export default Module(ODOO_MODULE, { + service: OdooModuleService, +}) +``` + +You use the `Module` function from the Modules SDK to create the module's definition. It accepts two parameters: + +1. The module's name, which is `odoo`. +2. An object with a required property `service` indicating the module's service. + +### Add Module to Medusa's Configurations + +Once you finish building the module, add it to Medusa's configurations to start using it. + +In `medusa-config.ts`, add a `modules` property and pass an array with your custom module: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // ... + modules: [ + { + resolve: "./src/modules/odoo", + options: { + url: process.env.ODOO_URL, + dbName: process.env.ODOO_DB_NAME, + username: process.env.ODOO_USERNAME, + apiKey: process.env.ODOO_API_KEY, + }, + }, + ], +}) +``` + +Each object in the `modules` array has a `resolve` property, whose value is either a path to the module's directory, or an `npm` package’s name. Modules that accept options also have an `options` property. You pass the options you defined in the `OdooModuleService` class to the module. + +Then, set the environment variables in the `.env` file or system environment variables: + +```plain +ODOO_URL=https://medusa8.odoo.com +ODOO_DB_NAME=medusa8 +ODOO_USERNAME=test@gmail.com # username or email +ODOO_API_KEY=12345... +``` + +Where: + +- `ODOO_URL`: The URL of your Odoo instance, which is of the format `https://.odoo.com`. +- `ODOO_DB_NAME`: The name of the database in your Odoo instance, which is the same as the domain in the URL. +- `ODOO_USERNAME`: The username or email of an Odoo user. +- `ODOO_API_KEY`: The API key of an Odoo user, or the user's password. To retrieve an API Key: + - On your Odoo dashboard, click on the user's avatar at the top right and choose "My Profile" from the dropdown. + +![My profile dropdown in Odoo](https://res.cloudinary.com/dza7lstvk/image/upload/v1740476295/Medusa%20Resources/Screenshot_2025-02-25_at_11.36.23_AM_h9iind.png) + +- On your profile's page, click the "Account Security" tab, then the "New API Key" button. + +![Profile page in Odoo](https://res.cloudinary.com/dza7lstvk/image/upload/v1740476296/Medusa%20Resources/Screenshot_2025-02-25_at_11.36.55_AM_gl4wfu.png) + +- In the pop-up that opens, enter your password. +- Enter the API Key's name, and set the expiration to "Persistent Key", then click the "Generate Key" button. + +![Generate key pop-up](https://res.cloudinary.com/dza7lstvk/image/upload/v1740476443/Medusa%20Resources/Screenshot_2025-02-25_at_11.40.19_AM_ntbx1c.png) + +- Copy the generated API Key and use it as the `ODOO_API_KEY` environment variable's value. + +You will test that the Odoo Module works as expected in the next steps. + +*** + +## Step 4: Sync Products from Odoo to Medusa + +There are different use cases you can implement when integrating an ERP like Odoo. One of them is syncing products from the ERP to Medusa. This way, you can manage products in Odoo and have them reflected in your commerce platform. + +To implement the syncing functionality, you need to create a [workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md). A workflow is a series of queries and actions, called steps, that complete a task. You construct a workflow similar to how you create a JavaScript function, but with additional features like defining rollback logic for each step, performing long actions asynchronously, and tracking the progress of the steps. + +After defining the workflow, you can execute it in other customizations, such as periodically or when an event occurs. + +In this section, you'll create a workflow that syncs products from Odoo to Medusa. Then, you'll execute that workflow once a day using a [scheduled job](https://docs.medusajs.com/docs/learn/fundamentals/scheduled-jobs/index.html.md). The workflow has the following steps: + +- [getProductsFromErp](#getProductsFromErp): Fetch products from Odoo +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Get Medusa store configurations to use when creating the products. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Get Medusa sales channels to use when creating the products. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Get Medusa shipping profiles to use when creating the products. +- [createProductsWorkflow](https://docs.medusajs.com/references/medusa-workflows/createProductsWorkflow/index.html.md): Create new products in Medusa. +- [updateProductsWorkflow](https://docs.medusajs.com/references/medusa-workflows/updateProductsWorkflow/index.html.md): Update existing products in Medusa. + +The only step you'll need to implement is the `getProductsFromErp` step. The other steps are available through Medusa's `@medusajs/medusa/core-flows` package. + +### getProductsFromErp + +The first step of the workflow is to retrieve the products from the ERP. So, create the file `src/workflows/sync-from-erp.ts` with the following content: + +![Diagram showcasing directory structure after creating the workflow file](https://res.cloudinary.com/dza7lstvk/image/upload/v1740479240/Medusa%20Resources/odoo-4_jrh5mx.jpg) + +```ts title="src/workflows/sync-from-erp.ts" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" + +type Input = { + offset: number + limit: number +} + +const getProductsFromErp = createStep( + "get-products-from-erp", + async (input: Input, { container }) => { + const odooModuleService = container.resolve("odoo") + + const products = await odooModuleService.listProducts(undefined, input) + + return new StepResponse(products) + } +) +``` + +You create a step using `createStep` from the Workflows SDK. It accepts two parameters: + +1. The step's name, which is `get-products-from-erp`. +2. An async function that executes the step's logic. The function receives two parameters: + - The input data for the step, which are the pagination fields `offset` and `limit`. + - An object holding the workflow's context, including the [Medusa Container](https://docs.medusajs.com/docs/learn/fundamentals/medusa-container/index.html.md) that allows you to resolve Framework and commerce tools. + +In this step, you resolve the Odoo Module's service from the container and use its `listProducts` method to fetch products from Odoo. You pass the pagination options from the input data to the method. + +A step must return an instance of `StepResponse` which accepts as a parameter the data to return, which is in this case the products. + +### Create Workflow + +You can now create the workflow that syncs the products from Odoo to Medusa. + +In the same `src/workflows/sync-from-erp.ts` file, add the following imports: + +```ts title="src/workflows/sync-from-erp.ts" +import { + createWorkflow, transform, WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { + createProductsWorkflow, updateProductsWorkflow, useQueryGraphStep, +} from "@medusajs/medusa/core-flows" +import { + CreateProductWorkflowInputDTO, UpdateProductWorkflowInputDTO, +} from "@medusajs/framework/types" +``` + +Then, add the workflow after the step: + +```ts title="src/workflows/sync-from-erp.ts" +export const syncFromErpWorkflow = createWorkflow( + "sync-from-erp", + (input: Input) => { + const odooProducts = getProductsFromErp(input) + + // @ts-ignore + const { data: stores } = useQueryGraphStep({ + entity: "store", + fields: [ + "default_sales_channel_id", + ], + }) + + // @ts-ignore + const { data: shippingProfiles } = useQueryGraphStep({ + entity: "shipping_profile", + fields: ["id"], + pagination: { + take: 1, + }, + }).config({ name: "shipping-profile" }) + + const externalIdsFilters = transform({ + odooProducts, + }, (data) => { + return data.odooProducts.map((product) => `${product.id}`) + }) + + // @ts-ignore + const { data: existingProducts } = useQueryGraphStep({ + entity: "product", + fields: ["id", "external_id", "variants.*"], + filters: { + // @ts-ignore + external_id: externalIdsFilters, + }, + }).config({ name: "existing-products" }) + + // TODO prepare products to create and update + } +) +``` + +You create a workflow using `createWorkflow` from the Workflows SDK. It accepts the workflow's unique name as a first parameter. + +It accepts as a second parameter a constructor function, which is the workflow's implementation. The function receives the pagination options as a parameter. In the workflow, you: + +- Call the `getProductsFromErp` step to fetch products from Odoo. +- Use the [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md) to fetch the Medusa store configurations, sales channels, and shipping profiles. You'll use this data when creating the products in a later step. + - The `useQueryGraphStep` uses [Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md), which is a tool that retrieves data across modules. +- To figure out which products need to be updated, you retrieve products filtered by their `external_id` field, which you'll set to the Odoo product's ID when you create the products next. + - Notice that you use `transform` from the Workflows SDK to create the external IDs filters. That's because data manipulation is not allowed in a workflow. You can learn more about this and other restrictions in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/constructor-constraints/index.html.md). + +Next, you need to prepare the products that should be created or updated. To do that, replace the `TODO` with the following: + +```ts title="src/workflows/sync-from-erp.ts" +const { + productsToCreate, + productsToUpdate, +} = transform({ + existingProducts, + odooProducts, + shippingProfiles, + stores, +}, (data) => { + const productsToCreate: CreateProductWorkflowInputDTO[] = [] + const productsToUpdate: UpdateProductWorkflowInputDTO[] = [] + + data.odooProducts.forEach((odooProduct) => { + const product: CreateProductWorkflowInputDTO | UpdateProductWorkflowInputDTO = { + external_id: `${odooProduct.id}`, + title: odooProduct.display_name, + description: odooProduct.description || odooProduct.description_sale || "", + status: odooProduct.is_published ? "published" : "draft", + options: odooProduct.attribute_line_ids.length ? odooProduct.attribute_line_ids.map((attribute) => { + return { + title: attribute.attribute_id.display_name, + values: attribute.value_ids.map((value) => value.display_name), + } + }) : [ + { + title: "Default", + values: ["Default"], + }, + ], + hs_code: odooProduct.hs_code || "", + handle: odooProduct.website_url.replace("/shop/", ""), + variants: [], + shipping_profile_id: data.shippingProfiles[0].id, + sales_channels: [ + { + id: data.stores[0].default_sales_channel_id || "", + }, + ], + } + + const existingProduct = data.existingProducts.find((p) => p.external_id === product.external_id) + if (existingProduct) { + product.id = existingProduct.id + } + + if (odooProduct.product_variant_ids?.length) { + product.variants = odooProduct.product_variant_ids.map((variant) => { + const options = {} + if (variant.product_template_variant_value_ids.length) { + variant.product_template_variant_value_ids.forEach((value) => { + options[value.attribute_id.display_name] = value.name + }) + } else { + product.options?.forEach((option) => { + options[option.title] = option.values[0] + }) + } + return { + id: existingProduct ? existingProduct.variants.find((v) => v.sku === variant.code)?.id : undefined, + title: variant.display_name.replace(`[${variant.code}] `, ""), + sku: variant.code || undefined, + options, + prices: [ + { + amount: variant.list_price, + currency_code: variant.currency_id.display_name.toLowerCase(), + }, + ], + manage_inventory: false, // change to true if syncing inventory from Odoo + metadata: { + external_id: `${variant.id}`, + }, + } + }) + } else { + product.variants?.push({ + id: existingProduct ? existingProduct.variants[0].id : undefined, + title: "Default", + options: { + Default: "Default", + }, + // @ts-ignore + prices: [ + { + amount: odooProduct.list_price, + currency_code: odooProduct.currency_id.display_name.toLowerCase(), + }, + ], + metadata: { + external_id: `${odooProduct.id}`, + }, + manage_inventory: false, // change to true if syncing inventory from Odoo + }) + } + + if (existingProduct) { + productsToUpdate.push(product as UpdateProductWorkflowInputDTO) + } else { + productsToCreate.push(product as CreateProductWorkflowInputDTO) + } + }) + + return { + productsToCreate, + productsToUpdate, + } +}) + +// TODO create and update the products +``` + +You use `transform` again to prepare the products to create and update. It receives two parameters: + +- An object with the data you'll use in the transform function. +- The transform function, which receives the object from the first parameter, and returns the data that can be used in the rest of the workflow. + +In the transform function, you: + +- Create the `productsToCreate` and `productsToUpdate` arrays to hold the products that should be created and updated, respectively. +- Iterate over the products fetched from Odoo and create a product object for each. You set the product's properties based on the Odoo product's properties. If you want to add more properties, you can do so at this point. + - Most importantly, you set the `external_id` to the Odoo product's ID, which allows you later to identify the product later when updating it or for other operations. + - You also set the product's variants either to Odoo's variants or to a default variant. You set the product variant's Odoo ID in the `metadata.external_id` field, which allows you to identify the variant later when updating it or for other operations. +- To determine if a product already exists, you check if the product's `external_id` matches an existing product's `external_id`. You add it to the products to be updated. You apply a similar logic for the variants. +- Finally, you return an object with the `productsToCreate` and `productsToUpdate` arrays. + +You can now create and update the products in the workflow. Replace the `TODO` with the following: + +```ts title="src/workflows/sync-from-erp.ts" +createProductsWorkflow.runAsStep({ + input: { + products: productsToCreate, + }, +}) + +updateProductsWorkflow.runAsStep({ + input: { + products: productsToUpdate, + }, +}) + +return new WorkflowResponse({ + odooProducts, +}) +``` + +You use the `createProductsWorkflow` and `updateProductsWorkflow` to create and update the products returned from the transform function. Since both of these are workflows, you use the `runAsStep` method to run them as steps in the current workflow. + +Finally, a workflow must return a instance of `WorkflowResponse` passing it as a parameter the data to return, which in this case is the products fetched from Odoo. + +You can now execute this workflow in other customizations, such as a scheduled job. + +### Create Scheduled Job + +In Medusa, you can run a task at a specified interval using a [scheduled job](https://docs.medusajs.com/docs/learn/fundamentals/scheduled-jobs/index.html.md). A scheduled job is an asynchronous function that runs at a regular interval during the Medusa application's runtime to perform tasks such as syncing products from Odoo to Medusa. + +To create a scheduled job, create the file `src/jobs/sync-products-from-erp.ts` with the following content: + +![Diagram showcasing directory structure after creating the scheduled job file](https://res.cloudinary.com/dza7lstvk/image/upload/v1740480934/Medusa%20Resources/odoo-5_xf0xug.jpg) + +```ts title="src/jobs/sync-products-from-erp.ts" +import { + MedusaContainer, +} from "@medusajs/framework/types" +import { syncFromErpWorkflow } from "../workflows/sync-from-erp" +import { OdooProduct } from "../modules/odoo/service" + +export default async function syncProductsJob(container: MedusaContainer) { + const limit = 10 + let offset = 0 + let total = 0 + let odooProducts: OdooProduct[] = [] + + console.log("Syncing products...") + + do { + odooProducts = (await syncFromErpWorkflow(container).run({ + input: { + limit, + offset, + }, + })).result.odooProducts + + offset += limit + total += odooProducts.length + } while (odooProducts.length > 0) + + console.log(`Synced ${total} products`) +} + +export const config = { + name: "daily-product-sync", + schedule: "0 0 * * *", // Every day at midnight +} +``` + +In this file, you export: + +- An asynchronous function, which is the task to execute at the specified schedule. +- A configuration object having the following properties: + - `name`: A unique name for the scheduled job. + - `schedule`: A [cron expression](https://crontab.guru/) string indicating the schedule to run the job at. The specified schedule indicates that this job should run every day at midnight. + +The scheduled job function accepts the Medusa container as a parameter. In the function, you define the pagination options for the products to fetch from Odoo. You then run the `syncFromErpWorkflow` workflow with the pagination options. You increment the offset by the limit each time you run the workflow until you fetch all the products. + +*** + +## Test it Out + +To test out syncing the products from Odoo to Medusa, first, change the schedule of the job in `src/jobs/sync-products-from-erp.ts` to run every minute: + +```ts title="src/jobs/sync-products-from-erp.ts" +export const config = { + name: "daily-product-sync", + schedule: "* * * * *", // Every minute +} +``` + +Then, start the Medusa application with the following command: + +```bash npm2yarn +npm run dev +``` + +A minute later, you should find the message `Syncing products...` in the console. Once the job finishes, you should see the message `Synced products`, indicating the number of products synced. + +You can also confirm that the products were synced by checking the products in the Medusa Admin dashboard. + +If you encounter any issues, make sure the module options are set correctly as explained in [this section](#add-module-to-medusas-configurations). + +*** + +## Next Steps + +You now have the foundation for integrating Odoo with Medusa. You can expand on this integration to implement more use cases, such as syncing orders, restricting purchases of products based on custom rules, and checking inventory in Odoo before adding to the cart. You can find the approach to implement these use cases in [this recipe](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/recipes/erp/index.html.md). + +If you're new to Medusa, check out the [main documentation](https://docs.medusajs.com/docs/learn/index.html.md), where you'll get a more in-depth learning of all the concepts you've used in this guide and more. + +To learn more about the commerce features that Medusa provides, check out Medusa's [Commerce Modules](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/index.html.md). + + +# Integrate ERP with Medusa + +In this recipe, you'll learn about the general approach to integrating an ERP system with Medusa. + +Businesses often rely on an ERP system to centralize their data and custom business rules and operations. This includes using the ERP to store special product prices, manage custom business rules that change how their customers make purchases, and handle the fulfillment and processing of orders. + +When integrating the ERP system with other ecommerce platforms, you'll face complications maintaining data consistency across systems and customizing the platform's existing flows to accommodate the ERP system's data and operations. For example, the ecommerce platform may not support purchasing products with custom pricing or restricting certain products from purchase under certain conditions. + +The Medusa Framework solves these challenges by giving you a durable execution engine to orchestrate operations through custom flows, and the flexibility to customize the platform's existing flows. You can wrap existing flows with custom logic, inject custom features into existing flows, and create new flows that interact with the ERP system and sync data between the two systems. + +![ERP Integration Illustration](https://res.cloudinary.com/dza7lstvk/image/upload/v1740470820/Medusa%20Resources/erp-medusa-integration_pxjpcx.jpg) + +In this recipe, you'll learn how to implement some common use cases when integrating an ERP system with Medusa. This includes how to purchase products with custom pricing, restrict products from purchase under conditions in the ERP, sync orders to the ERP, and more. + +You can use the code snippets in the recipe as a starting point for your ERP integration, making changes as necessary for your use case. You can also implement other use cases using the same Medusa concepts. + +*** + +## Prerequisite: Install Medusa + +If you don't have a Medusa application yet, you can install it with the following command: + +### Prerequisites + +- [Node.js v20+](https://nodejs.org/en/download) +- [Git CLI tool](https://git-scm.com/downloads) +- [PostgreSQL](https://www.postgresql.org/download/) + +```bash +npx create-medusa-app@latest +``` + +You'll first be asked for the project's name. You can also optionally choose to install the [Next.js Starter Storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md). + +Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name. If you chose to install the Next.js starter, it'll be installed in a separate directory with the `{project-name}-storefront` name. + +The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). Learn more about Medusa's architecture in [this documentation](https://docs.medusajs.com/docs/learn/introduction/architecture/index.html.md). + +Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. Afterwards, you can log in with the new user and explore the dashboard. + +Check out the [troubleshooting guides](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/troubleshooting/create-medusa-app-errors/index.html.md) for help. + +*** + +## Integrate ERP in a Module + +Before you start integrating the ERP system into existing or new flows in Medusa, you must build the integration layer that allows you to communicate with the ERP in your customizations. + +In Medusa, you implement integrations or features around a single commerce domain in a [module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md). A module is a reusable package that can interact with the database or external APIs. The module integrates into your Medusa application without side effects to the existing setup. + +So, you can create a module that exports a class called a service, and in that service, you implement the logic to connect to your ERP system, fetch data from it, and send data to it. The service may look like this: + +```ts title="src/modules/erp/service.ts" +type Options = { + apiKey: string +} + +export default class ErpModuleService { + private options: Options + private client + + constructor({}, options: Options) { + this.options = options + // TODO initialize client that connects to ERP + } + + async getProducts() { + // assuming client has a method to fetch products + return this.client.getProducts() + } + + // TODO add more methods +} +``` + +You can then use the module's service in the custom flows and customizations that you'll see in later sections. + +### How to Create Module + +Refer to [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md) on how to create a module. You can also refer to the [Odoo Integration guide](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/recipes/erp/odoo/index.html.md) as an example of how to build a module that integrates an ERP into Medusa. + +The rest of this recipe assumes you have an ERP Module with some methods to retrieve products, prices, and other relevant data. + +*** + +## Sync Products from ERP + +If you store products in the ERP system, you want to sync them into Medusa to allow customers to purchase them. You may sync them once or periodically to keep the products in Medusa up-to-date with the ERP. + +Syncing data between systems is a big challenge and it's often the pitfall of most ecommerce platforms, as you need to ensure data consistency and handle errors gracefully. Medusa solves this challenge by providing a durable execution engine to complete tasks that span multiple systems, allowing you to orchestrate your operations across systems in Medusa instead of managing it yourself. + +Medusa's workflows are a series of queries and actions, called steps, that complete a task. You construct a workflow similar to how you create a JavaScript function, but with additional features like defining rollback logic for each step, performing long actions asynchronously, and tracking the progress of the steps. You can then use these workflows in other customizations, such as: + +- [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md) to allow clients to trigger the workflow's execution. +- [Subscribers](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md) to trigger the workflow when an event occurs. +- [Scheduled jobs](https://docs.medusajs.com/docs/learn/fundamentals/scheduled-jobs/index.html.md) to run the workflow periodically. + +So, to sync products from the ERP system to Medusa, you can create a custom workflow that fetches the products from the ERP system and adds them to Medusa. Then, you can create a scheduled job that syncs the products once a day, for example. + +### 1. Create Workflow to Sync Products + +The workflow that syncs products from the ERP system to Medusa will have a step that fetches the product from the ERP, and another step that adds the product to Medusa. + +For example, you can create the following workflow: + +```ts title="src/workflows/sync-products.ts" +import { + createStep, createWorkflow, StepResponse, + transform, WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { createProductsWorkflow } from "@medusajs/medusa/core-flows" + +const getProductsFromErpStep = createStep( + "get-products-from-erp", + async (_, { container }) => { + const erpModuleService = container.resolve("erp") + + const products = await erpModuleService.getProducts() + + return new StepResponse(products) + } +) + +export const syncFromErpWorkflow = createWorkflow( + "sync-from-erp", + () => { + const erpProducts = getProductsFromErpStep() + + const productsToCreate = transform({ + erpProducts, + }, (data) => { + // TODO prepare ERP products to be created in Medusa + return data.erpProducts.map((erpProduct) => { + return { + title: erpProduct.title, + external_id: erpProduct.id, + variants: erpProduct.variants.map((variant) => ({ + title: variant.title, + metadata: { + external_id: variant.id, + }, + })), + // other data... + } + }) + }) + + createProductsWorkflow.runAsStep({ + input: { + products: productsToCreate, + }, + }) + + return new WorkflowResponse({ + erpProducts, + }) + } +) +``` + +In the above file, you first create a `getProductsFromErpStep` that resolves the ERP Module's service from the [Medusa container](https://docs.medusajs.com/docs/learn/fundamentals/medusa-container/index.html.md), which is a registry of Framework and commerce tools, including your modules, that you can access in your customizations. You can then call the `getProducts` method in the ERP Module's service to fetch the products from the ERP and return them. + +Then, you create a `syncFromErpWorkflow` that executes the `getProductsFromErpStep` to get the products from the ERP, then prepare the products to be created in Medusa. For example, you can set the product's title, and specify its ID in the ERP using the `external_id` field. Also, assuming the ERP products have variants, you can map the variants to Medusa's format, setting the variant's title and its ERP ID in the metadata. + +Finally, you pass the products to be created to the `createProductsWorkflow`, which is a built-in Medusa workflow that creates products. + +Learn more about creating workflows and steps in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md). + +Find a detailed example of implementing this workflow in the [Odoo Integration guide](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/recipes/erp/odoo/index.html.md). + +### 2. Create Scheduled Job to Sync Products + +After creating a workflow, you can create a scheduled job that runs the workflow periodically to sync the products from the ERP to Medusa. + +For example, you can create the following scheduled job: + +```ts title="src/scheduled-jobs/sync-products.ts" +import { + MedusaContainer, +} from "@medusajs/framework/types" +import { syncFromErpWorkflow } from "../workflows/sync-from-erp" + +export default async function syncProductsJob(container: MedusaContainer) { + await syncFromErpWorkflow(container).run({}) +} + +export const config = { + name: "daily-product-sync", + schedule: "0 0 * * *", // Every day at midnight +} +``` + +You create a scheduled job that runs once a day, executing the `syncFromErpWorkflow` to sync the products from the ERP to Medusa. + +Learn more about creating scheduled jobs in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/scheduled-jobs/index.html.md). + +*** + +## Retrieve Custom Prices from ERP + +Consider you store products in an ERP system with fixed prices, or prices based on different conditions. You want to display these prices in the storefront and allow customers to purchase products with these prices. To do that, you need the mechanism to fetch the custom prices from the ERP system and add the product to the cart with the custom price. + +To do that, you can build a custom workflow that uses the ERP Module to retrieve the custom price of a product variant, then add the product to the cart with that price. + +You can also follow [this guide](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/examples/guides/custom-item-price/index.html.md) for a step-by-step guide on how to add items with custom prices to the cart. + +### 1. Create Step to Get Variant Price + +One of the steps in the custom add-to-cart workflow is to retrieve the custom price of the product variant from the ERP system. The step's implementation will differ based on the ERP system you're integrating. Here's a general implementation of how the step would look like: + +```ts title="src/workflows/steps/get-erp-price.ts" +import { MedusaError } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" + +export type GetVariantErpPriceStepInput = { + variant_external_id: string + currencyCode: string + quantity: number +} + +export const getVariantErpPriceStep = createStep( + "get-variant-erp-price", + async (input: GetVariantErpPriceStepInput, { container }) => { + const { variant_external_id, currencyCode, quantity } = input + + const erpModuleService = container.resolve("erp") + + const price = await erpModuleService.getErpPrice( + variant_external_id, + currencyCode + ) + + return new StepResponse( + price * quantity + ) + } +) +``` + +You create the step using the `createStep` from the Workflows SDK. The step has a function that receives the variant's ID in the ERP, the currency code, and the quantity as input. + +The function then resolves the ERP Module's service from the [Medusa container](https://docs.medusajs.com/docs/learn/fundamentals/medusa-container/index.html.md) and, assuming you have a `getErpPrice` method in your ERP Module's service, you call it to retrieve that price, and return it multiplied by the quantity. + +### 2. Create Custom Add-to-Cart Workflow + +You can now create the custom workflow that uses the previous step to retrieve a variant's custom price from the ERP, then add it to the cart with that price. + +For example, you can create the following workflow: + +```ts title="src/workflows/add-custom-to-cart.ts" +import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { addToCartWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { getVariantErpPriceStep } from "./steps/get-erp-price" + +type AddCustomToCartWorkflowInput = { + cart_id: string + item: { + variant_id: string + quantity: number + } +} + +export const addCustomToCartWorkflow = createWorkflow( + "add-custom-to-cart", + ({ cart_id, item }: AddCustomToCartWorkflowInput) => { + // Retrieve the cart's currency code + const { data: carts } = useQueryGraphStep({ + entity: "cart", + filters: { id: cart_id }, + fields: ["id", "currency_code"], + }) + + // Retrieve the variant's metadata to get its ERP ID + const { data: variants } = useQueryGraphStep({ + entity: "variant", + fields: [ + "id", + "metadata", + ], + filters: { + id: item.variant_id, + }, + options: { + throwIfKeyNotFound: true, + }, + }).config({ name: "retrieve-variant" }) + + // get the variant's price from the ERP + const price = getVariantErpPriceStep({ + variant_external_id: variants[0].metadata?.external_id as string, + currencyCode: carts[0].currency_code, + quantity: item.quantity, + }) + + // prepare to add the variant to the cart + const itemToAdd = transform({ + item, + price, + variants, + }, (data) => { + return [{ + ...data.item, + unit_price: data.price, + metadata: data.variants[0].metadata, + }] + }) + + // add the variant to the cart + addToCartWorkflow.runAsStep({ + input: { + items: itemToAdd, + cart_id, + }, + }) + } +) +``` + +In this workflow, you first fetch the details of the cart and the variant from the database using the [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md) that Medusa defines. + +Then, you use the `getVariantErpPriceStep` you created to retrieve the price from the ERP. You pass it the variant's ERP ID, supposedly stored in the variant's metadata as shown in the [previous section](#1-create-workflow-to-sync-products), the cart's currency code, and the quantity of the item. + +Finally, you prepare the item to be added to the cart, setting the unit price to the price you retrieved from the ERP. You then call the `addToCartWorkflow` to add the item to the cart. + +### 3. Execute Workflow in API Route + +To allow clients to add products to the cart with custom prices, you can create an [API route](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md) that exposes the workflow's functionality. An API Route is an endpoint that exposes commerce features to external applications and clients, such as storefronts. + +For example, you can create the following API route: + +```ts title="src/api/store/cart/[id]/custom-line-items/route.ts" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework" +import { HttpTypes } from "@medusajs/framework/types" +import { addCustomToCartWorkflow } from "../../../../../workflows/add-custom-to-cart" + +export const POST = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const { id } = req.params + const item = req.body + + await addCustomToCartWorkflow(req.scope) + .run({ + input: { + cart_id: id, + item, + }, + }) + + res.status(200).json({ success: true }) +} +``` + +In this API route, you receive the cart's ID from the route's parameters, and the item to be added to the cart from the request body. You then call the `addCustomToCartWorkflow` to add the item to the cart with the custom price. + +Learn more about how to create an API route in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). You can also add request body validation as explained in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/validation/index.html.md). + +*** + +## Restrict Purchase with Custom ERP Rules + +Your ERP may store restrictions on who can purchase what products. For example, you may allow only some companies to purchase certain products. + +Since Medusa implements the add-to-cart functionality within a workflow, it allows you to inject custom logic into the workflow using [workflow hooks](https://docs.medusajs.com/docs/learn/fundamentals/workflows/workflow-hooks/index.html.md). A workflow hook is a point in a workflow where you can inject a step and perform a custom functionality. + +So, to implement the use case of product-purchase restriction, you can use the `validate` hook of the `addToCartWorkflow` or the [completeCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/completeCartWorkflow/index.html.md) to check if the customer is allowed to purchase the product. For example: + +```ts title="src/workflows/hooks/validate-purchase.ts" +import { MedusaError } from "@medusajs/framework/utils" +import { addToCartWorkflow } from "@medusajs/medusa/core-flows" + +addToCartWorkflow.hooks.validate( + async ({ input, cart }, { container }) => { + const erpModuleService = container.resolve("erp") + const productModuleService = container.resolve("product") + const customerModuleService = container.resolve("customer") + + const customer = cart.customer_id ? await customerModuleService.retrieveCustomer(cart.customer_id) : undefined + const productVariants = await productModuleService.listProductVariants({ + id: input.items.map((item) => item.variant_id).filter(Boolean) as string[], + }, { + relations: ["product"], + }) + + await Promise.all( + productVariants.map(async (productVariant) => { + if (!productVariant.product?.external_id) { + // product isn't in ERP + return + } + + const isAllowed = await erpModuleService.canCompanyPurchaseProduct( + productVariant.product.external_id, + customer?.company_name || undefined + ) + + if (!isAllowed) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Company ${customer?.company_name || ""} is not allowed to purchase product ${productVariant.product.id}` + ) + } + }) + ) + } +) +``` + +You consume a hook using the workflow's `hooks` property. In the above example, you consume the `validate` hook of the `addToCartWorkflow` to inject a step. + +In the step, you resolve the ERP Module's service, the Product Module's service, and the Customer Module's service from the [Medusa container](https://docs.medusajs.com/docs/learn/fundamentals/medusa-container/index.html.md). You then retrieve the cart's customer and the product variants to be added to the cart. + +Then, for each product variant, you use a `canCompanyPurchaseProduct` method in the ERP Module's service that checks if the customer's company is allowed to purchase the product. If not, you throw a `MedusaError` with a message that the company is not allowed to purchase the product. + +So, only customers who are allowed in the ERP system to purchase a product can add it to the cart. + +Learn more about workflow hooks and how to consume them in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/workflow-hooks/index.html.md). + +*** + +## Two-Way Order Syncing + +After a customer places an order in Medusa, you may want to sync the order to the ERP system where you handle its fulfillment and processing. However, you may also want to sync the order back to Medusa, where you handle customer-service related operations, such as returns and refunds. + +As explained earlier, workflows facilitate the orchestration of operations across systems while maintaining data consistency. So, you can create two workflows: + +1. A workflow that syncs the order from Medusa to the ERP system. You can execute this workflow in a [subscriber](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md) that is triggered when an order is created in Medusa. +2. A workflow that syncs the order from the ERP system back to Medusa. Then, you can create a webhook listener in Medusa that executes the workflow, and use the webhook in the ERP system to send order updates to Medusa. + +### 1. Sync Order to ERP + +To sync the order from Medusa to the ERP system, you can create a custom workflow that sends the order's details from Medusa to the ERP system. For example: + +```ts title="src/workflows/sync-order-to-erp.ts" +import { createStep, createWorkflow, StepResponse, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { updateOrderWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { OrderDTO } from "@medusajs/framework/types" + +type StepInput = { + order: OrderDTO +} + +export const syncOrderToErpStep = createStep( + "sync-order-to-erp", + async ({ order }: StepInput, { container }) => { + const erpModuleService = container.resolve("erp") + + const erpOrderId = await erpModuleService.createOrder(order) + + return new StepResponse(erpOrderId, erpOrderId) + }, + async (erpOrderId, { container }) => { + if (!erpOrderId) { + return + } + + const erpModuleService = container.resolve("erp") + await erpModuleService.deleteOrder(erpOrderId) + } +) + +type WorkflowInput = { + order_id: string +} + +export const syncOrderToErpWorkflow = createWorkflow( + "sync-order-to-erp", + ({ order_id }: WorkflowInput) => { + // @ts-ignore + const { data: orders } = useQueryGraphStep({ + entity: "order", + fields: [ + "*", + "shipping_address.*", + "billing_address.*", + "items.*", + ], + filters: { + id: order_id, + }, + options: { + throwIfKeyNotFound: true, + }, + }) + + const erpOrderId = syncOrderToErpStep({ + order: orders[0] as unknown as OrderDTO, + }) + + updateOrderWorkflow.runAsStep({ + input: { + id: order_id, + user_id: "", + metadata: { + external_id: erpOrderId, + }, + }, + }) + + return new WorkflowResponse(erpOrderId) +}) +``` + +You first create a `syncOrderToErpStep` that receives an order's details, resolves the ERP Module's service from the Medusa container, and calls a `createOrder` method in the ERP Module's service that creates the order in the ERP. + +Notice that you pass to `createStep` a third-parameter function. This is the compensation function that defines how to rollback changes. So, when an error occurs during the workflow's execution, the step deletes the order from the ERP system. This ensures that the data remains consistent across systems. + +Learn more about the compensation function in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/compensation-function/index.html.md). + +Then, you create a `syncOrderToErpWorkflow` that retrieves the order's details from the database using the `useQueryGraphStep`, then executes the `syncOrderToErpStep` to sync the order to the ERP system. Finally, you update the order in Medusa to set the ERP order's ID in the `metadata` field. + +You can now use this workflow whenever an order is placed. To do that, you can create a subscriber that listens to the `order.created` event and executes the workflow: + +```ts title="src/subscribers/sync-order-to-erp.ts" +import type { + SubscriberArgs, + SubscriberConfig, +} from "@medusajs/framework" +import { syncOrderToErpWorkflow } from "../workflows/sync-order-to-erp" + +export default async function productCreateHandler({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + const { result } = await syncOrderToErpWorkflow(container) + .run({ + input: { + order_id: data.id, + }, + }) + + console.log(`Order synced to ERP with id: ${result}`) +} + +export const config: SubscriberConfig = { + event: "order.placed", +} +``` + +You create a subscriber that listens to the `order.placed` event. When the event is triggered, the subscriber executes the `syncOrderToErpWorkflow` to sync the order to the ERP system. + +Learn more about events and subscribers in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md). + +### 2. Sync Order from ERP to Medusa + +To sync the order from the ERP system back to Medusa, create first the workflow that receives the updated order data and reflects them in Medusa: + +```ts title="src/workflows/sync-order-from-erp.ts" +import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { updateOrderWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" + +type Input = { + order_erp_data: any +} + +export const syncOrderFromErpWorkflow = createWorkflow( + "sync-order-from-erp", + ({ order_erp_data }: Input) => { + const { data: orders } = useQueryGraphStep({ + entity: "order", + fields: ["*"], + filters: { + // @ts-ignore + metadata: { + external_id: order_erp_data.id, + }, + }, + }) + + const orderUpdateData = transform({ + order_erp_data, + orders, + }, (data) => { + return { + id: data.orders[0].id, + user_id: "", + status: data.order_erp_data.status, + } + }) + + const order = updateOrderWorkflow.runAsStep({ + input: orderUpdateData, + }) + + return new WorkflowResponse(order) + } +) +``` + +In the workflow, you retrieve the order from the database using the `useQueryGraphStep`, assuming that the ERP order's ID is stored in the order's metadata. Then, you prepare the order's data to be updated in Medusa, setting the order's status to the status you received from the ERP system. Finally, you update the order using the `updateOrderWorkflow`. + +You can now create an API route that receives webhook updates from the ERP system and executes the workflow: + +```ts title="src/api/erp/order-updates/route.ts" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { syncOrderFromErpWorkflow } from "../../workflows/sync-order-from-erp" + +export async function POST( + req: MedusaRequest, + res: MedusaResponse +) { + const webhookData = req.rawBody + + // TODO construct the order object from the webhook data + + // execute the workflow + await syncOrderFromErpWorkflow(req.scope).run({ + input: { + order_erp_data, // pass constructed order object + }, + }) +} +``` + +In the API route, you receive the raw webhook data from the request body. You can then construct the order object from the webhook data based on the ERP system's format. + +Then, you call the `syncOrderFromErpWorkflow` to sync the order from the ERP system back to Medusa. + +Finally, to ensure the webhook's raw data is received, you need to configure the middleware that runs before the route handler to preserve the raw body data. To do that, add the following middleware configuration in `src/api/middlewares.ts`: + +```ts title="src/api/middlewares.ts" +import { defineMiddlewares } from "@medusajs/framework/http" + +export default defineMiddlewares({ + routes: [ + { + method: ["POST"], + bodyParser: { preserveRawBody: true }, + matcher: "/erp/order-updates", + }, + ], +}) +``` + +You configure the body parser of `POST` requests to the `/erp/order-updates` route to preserve the raw body data. + +You can now receive webhook requests from your ERP system and sync the order data back to Medusa. + +Learn more about middlewares in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/middlewares/index.html.md). + +*** + +## Validate Inventory Availability in ERP + +An ERP system often manages the inventory of products, with the ability to track the stock levels and availability of products. When a customer is purchasing a product through Medusa, you want to ensure that the product is available in the ERP system before allowing the purchase. + +Similar to the [product-purchase restriction](#restrict-purchase-with-custom-erp-rules) use case, you can use the `validate` hook of the `addToCartWorkflow` or the [completeCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/completeCartWorkflow/index.html.md) to check the product's availability in the ERP system. For example: + +```ts title="src/workflows/hooks/validate-inventory.ts" +import { MedusaError } from "@medusajs/framework/utils" +import { addToCartWorkflow } from "@medusajs/medusa/core-flows" + +addToCartWorkflow.hooks.validate( + async ({ input }, { container }) => { + const erpModuleService = container.resolve("erp") + const productModuleService = container.resolve("product") + + const productVariants = await productModuleService.listProductVariants({ + id: input.items.map((item) => item.variant_id).filter(Boolean) as string[], + }, { + relations: ["product"], + }) + + await Promise.all( + productVariants.map(async (productVariant) => { + const erpVariant = await erpModuleService.getQty(productVariant.metadata?.external_id) + const item = input.items.find((item) => item.variant_id === productVariant.id)! + + if (erpVariant.qty_available < item.quantity && !erpVariant.allow_out_of_stock_order) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Not enough stock for product ${productVariant.product?.id}` + ) + } + }) + ) + } +) +``` + +You consume the `validate` hook of the `addToCartWorkflow` to inject a step function. In the step, you resolve the services of the ERP Module and the Product Module from the Medusa container. Then, you loop over the product variants to be added to the cart, and for each variant, you call a `getQty` method in the ERP Module's service to get the variant's quantity available. + +If the available quantity in the ERP is less than the quantity to be added to the cart, and the ERP doesn't allow out-of-stock orders for the variant, you throw an error that the product is out of stock. + +So, only products that have sufficient quantity in the ERP system can be added to the cart. + +*** + +## Implement More Use Cases + +The use cases covered in this guide are some common ERP integration scenarios that you can implement with Medusa. However, you can implement more use cases based on your ERP system's capabilities and your business requirements. + +Refer to the [main documentation](https://docs.medusajs.com/docs/learn/index.html.md) to learn more about Medusa's concepts and how to implement customizations. You can also use the feedback form at the end of this guide to suggest more use cases you'd like to see implemented. + + +# Marketplace Recipe: Restaurant-Delivery Example + +In this guide, you'll learn how to build a restaurant-delivery marketplace platform, similar to Uber Eats, with Medusa. + +When you install a Medusa application, you get a fully-fledged commerce platform with support for customizations. While Medusa doesn't provide marketeplace functionalities natively, it provides features that you can extend and a Framework to support all your customization needs to build a marketplace. + +In this guide, you'll customize Medusa to build a restaurant-delivery platform with the following features: + +1. Manage restaurants, each having admin users and products. +2. Manage drivers and allow them to handle the delivery of orders from restaurants to customers. +3. Real-time delivery handling and tracking, from the restaurant accepting the order to the driver delivering the order to the customer. + +- [Example Repository](https://github.com/medusajs/examples/tree/main/restaurant-marketplace): Find the full code for this recipe example in this repository. +- [OpenApi Specs for Postman](https://res.cloudinary.com/dza7lstvk/raw/upload/v1724757329/OpenApi/Restaurant-Delivery-Marketplace_vxao2l.yml): Imported this OpenApi Specs file into tools like Postman. + +This recipe is adapted from [Medusa Eats](https://github.com/medusajs/medusa-eats), which offers more implementation details including a custom storefront to place and track orders. + +*** + +## Step 1: Install a Medusa Application + +### Prerequisites + +- [Node.js v20+](https://nodejs.org/en/download) +- [Git CLI tool](https://git-scm.com/downloads) +- [PostgreSQL](https://www.postgresql.org/download/) + +Start by installing the Medusa application on your machine with the following command: + +```bash +npx create-medusa-app@latest +``` + +You'll first be asked for the project's name. You can also optionally choose to install the [Next.js Starter Storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md). + +Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name. If you chose to install the Next.js starter, it'll be installed in a separate directory with the `{project-name}-storefront` name. + +The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). Learn more about Medusa's architecture in [this documentation](https://docs.medusajs.com/docs/learn/introduction/architecture/index.html.md). + +Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credential and submit the form. Afterwards, you can login with the new user and explore the dashboard. + +Check out the [troubleshooting guides](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/troubleshooting/create-medusa-app-errors/index.html.md) for help. + +*** + +## Step 2: Create a Restaurant Module + +Medusa creates commerce features in modules. For example, product features and data models are created in the Product Module. + +You also create custom commerce data models and features in custom modules. They're integrated into the Medusa application similar to Medusa's modules without side effects. + +So, you'll create a restaurant module that holds the data models related to a restaurant and allows you to manage them. + +Create the directory `src/modules/restaurant`. + +### Create Restaurant Data Models + +Create the file `src/modules/restaurant/models/restaurant.ts` with the following content: + +```ts title="src/modules/restaurant/models/restaurant.ts" +import { model } from "@medusajs/framework/utils" +import { RestaurantAdmin } from "./restaurant-admin" + +export const Restaurant = model.define("restaurant", { + id: model + .id() + .primaryKey(), + handle: model.text(), + is_open: model.boolean().default(false), + name: model.text(), + description: model.text().nullable(), + phone: model.text(), + email: model.text(), + address: model.text(), + image_url: model.text().nullable(), + admins: model.hasMany(() => RestaurantAdmin), +}) +``` + +This defines a `Restaurant` data model with properties like `is_open` to track whether a restaurant is open, and `address` to show the restaurant’s address. + +It also has a relation to the `RestaurantAdmin` data model that you’ll define next. + +Create the file `src/modules/restaurant/models/restaurant-admin.ts` with the following content: + +```ts title="src/modules/restaurant/models/restaurant-admin.ts" +import { model } from "@medusajs/framework/utils" +import { Restaurant } from "./restaurant" + +export const RestaurantAdmin = model.define("restaurant_admin", { + id: model + .id() + .primaryKey(), + first_name: model.text(), + last_name: model.text(), + email: model.text(), + avatar_url: model.text().nullable(), + restaurant: model.belongsTo(() => Restaurant, { + mappedBy: "admins", + }), +}) +``` + +This defines a `RestaurantAdmin` data model, which belongs to a restaurant. It represents an admin that can manage a restaurant and its data. + +### Create Main Service for Restaurant Module + +Next, create the main service of the module at `src/modules/restaurant/service.ts` with the following content: + +```ts title="src/modules/restaurant/service.ts" +import { MedusaService } from "@medusajs/framework/utils" +import { Restaurant } from "./models/restaurant" +import { RestaurantAdmin } from "./models/restaurant-admin" + +class RestaurantModuleService extends MedusaService({ + Restaurant, + RestaurantAdmin, +}) {} + +export default RestaurantModuleService +``` + +The service extends the [service factory](https://docs.medusajs.com/docs/learn/fundamentals/modules/service-factory/index.html.md), which provides basic data-management features. + +### Create Restaurant Module Definition + +Then, create the file `src/modules/restaurant/index.ts` that holds the module definition: + +```ts title="src/modules/restaurant/index.ts" +import Service from "./service" +import { Module } from "@medusajs/framework/utils" + +export const RESTAURANT_MODULE = "restaurantModuleService" + +export default Module(RESTAURANT_MODULE, { + service: Service, +}) +``` + +### Add Restaurant Module to Medusa Configuration + +Finally, add the module to the list of modules in `medusa-config.ts`: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // ... + modules: [ + { + resolve: "./src/modules/restaurant", + }, + ], +}) +``` + +### Further Reads + +- [How to Create a Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md) +- [How to Create a Data Model](https://docs.medusajs.com/docs/learn/fundamentals/modules#1-create-data-model/index.html.md) + +*** + +## Step 3: Create a Delivery Module + +In this step, you’ll create the Delivery Module that defines delivery-related data models. + +Create the directory `src/modules/delivery`. + +### Create Types + +Before creating the data models, create the file `src/modules/delivery/types/index.ts` with the following content: + +```ts title="src/modules/delivery/types/index.ts" +export enum DeliveryStatus { + PENDING = "pending", + RESTAURANT_DECLINED = "restaurant_declined", + RESTAURANT_ACCEPTED = "restaurant_accepted", + PICKUP_CLAIMED = "pickup_claimed", + RESTAURANT_PREPARING = "restaurant_preparing", + READY_FOR_PICKUP = "ready_for_pickup", + IN_TRANSIT = "in_transit", + DELIVERED = "delivered", +} + +declare module "@medusajs/framework/types" { + export interface ModuleImplementations { + deliveryModuleService: DeliveryModuleService; + } +} +``` + +This adds an enum that is used by the data models. It also adds a type for `deliveryModuleService` in `ModuleImplementations` so that when you resolve it from the Medusa container, it has the correct typing. + +### Create Delivery Data Models + +Create the file `src/modules/delivery/models/driver.ts` with the following content: + +```ts title="src/modules/delivery/models/driver.ts" +import { model } from "@medusajs/framework/utils" +import { Delivery } from "./delivery" + +export const Driver = model.define("driver", { + id: model + .id() + .primaryKey(), + first_name: model.text(), + last_name: model.text(), + email: model.text(), + phone: model.text(), + avatar_url: model.text().nullable(), + deliveries: model.hasMany(() => Delivery, { + mappedBy: "driver", + }), +}) + +``` + +This defines a `Driver` data model with properties related to a driver user. + +It has a relation to a `Delivery` data model that you’ll create next. + +Create the file `src/modules/delivery/models/delivery.ts` with the following content: + +```ts title="src/modules/delivery/models/delivery.ts" +import { model } from "@medusajs/framework/utils" +import { DeliveryStatus } from "../types/common" +import { Driver } from "./driver" + +export const Delivery = model.define("delivery", { + id: model + .id() + .primaryKey(), + transaction_id: model.text().nullable(), + delivery_status: model.enum(DeliveryStatus).default(DeliveryStatus.PENDING), + eta: model.dateTime().nullable(), + delivered_at: model.dateTime().nullable(), + driver: model.belongsTo(() => Driver, { + mappedBy: "deliveries", + }).nullable(), +}) + +``` + +This defines a `Delivery` data model with notable properties including: + +- `transaction_id`: The ID of the workflow transaction that’s handling this delivery. This makes it easier to track the workflow’s execution and update its status later. +- `delivery_status`: The current status of the delivery. + +It also has a relation to the `Driver` data model, indicating the driver handling the delivery. + +### Create Main Service for Delivery Module + +Then, create the main service of the Delivery Module at `src/modules/delivery/service.ts` with the following content: + +```ts title="src/modules/delivery/service.ts" +import { MedusaService } from "@medusajs/framework/utils" +import { Delivery } from "./models/delivery" +import { Driver } from "./models/driver" + +class DeliveryModuleService extends MedusaService({ + Delivery, + Driver, +}) {} + +export default DeliveryModuleService +``` + +The service extends the [service factory](https://docs.medusajs.com/docs/learn/fundamentals/modules/service-factory/index.html.md), which provides basic data-management features. + +### Create Delivery Module Definition + +Next, create the file `src/modules/delivery/index.ts` holding the module’s definition: + +```ts title="src/modules/delivery/index.ts" +import Service from "./service" +import { Module } from "@medusajs/framework/utils" + +export const DELIVERY_MODULE = "deliveryModuleService" + +export default Module(DELIVERY_MODULE, { + service: Service, +}) +``` + +### Add Delivery Module to Medusa Configuration + +Finally, add the module to the list of modules in `medusa-config.ts`: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // ... + modules: [ + { + resolve: "./src/modules/delivery", + }, + ], +}) + +``` + +*** + +## Step 4: Define Links + +Modules are isolated in Medusa, making them reusable, replaceable, and integrable in your application without side effects. + +So, you can't have relations between data models in modules. Instead, you define a link between them. + +Links are relations between data models of different modules that maintain the isolation between the modules. + +In this step, you’ll define links between the Restaurant and Delivery modules, and other modules: + +1. Link between the `Restaurant` model and the Product Module's `Product` model. +2. Link between the `Restaurant` model and the Delivery Module's `Delivery` model. +3. Link between the `Delivery` model and the Cart Module's `Cart` model. +4. Link between the `Delivery` model and the Order Module's `Order` model. + +### Restaurant \<> Product Link + +Create the file `src/links/restaurant-products.ts` with the following content: + +```ts title="src/links/restaurant-products.ts" +import RestaurantModule from "../modules/restaurant" +import ProductModule from "@medusajs/medusa/product" +import { defineLink } from "@medusajs/framework/utils" + +export default defineLink( + RestaurantModule.linkable.restaurant, + { + linkable: ProductModule.linkable.product.id, + isList: true, + } +) +``` + +This defines a link between the Restaurant Module’s `restaurant` data model and the Product Module’s `product` data model, indicating that a restaurant is associated with its products. + +Since a restaurant has multiple products, `isList` is enabled on the product’s side. + +### Restaurant \<> Delivery Link + +Create the file `src/links/restaurant-delivery.ts` with the following content: + +```ts title="src/links/restaurant-delivery.ts" +import RestaurantModule from "../modules/restaurant" +import DeliveryModule from "../modules/delivery" +import { defineLink } from "@medusajs/framework/utils" + +export default defineLink( + RestaurantModule.linkable.restaurant, + { + linkable: DeliveryModule.linkable.delivery, + isList: true, + } +) + +``` + +This defines a link between the Restaurant Module’s `restaurant` data model and the Delivery Module’s `delivery` data model, indicating that a restaurant is associated with the deliveries created for it. + +Since a restaurant has multiple deliveries, `isList` is enabled on the delivery’s side. + +### Delivery \<> Cart + +Create the file `src/links/delivery-cart.ts` with the following content: + +```ts title="src/links/delivery-cart.ts" +import DeliveryModule from "../modules/delivery" +import CartModule from "@medusajs/medusa/cart" +import { defineLink } from "@medusajs/framework/utils" + +export default defineLink( + DeliveryModule.linkable.delivery, + CartModule.linkable.cart +) +``` + +This defines a link between the Delivery Module’s `delivery` data model and the Cart Module’s `cart` data model, indicating that delivery is associated with the cart it’s created from. + +### Delivery \<> Order + +Create the file `src/links/delivery-order.ts` with the following content: + +```ts title="src/links/delivery-order.ts" +import DeliveryModule from "../modules/delivery" +import OrderModule from "@medusajs/medusa/order" +import { defineLink } from "@medusajs/framework/utils" + +export default defineLink( + DeliveryModule.linkable.delivery, + OrderModule.linkable.order +) +``` + +This defines a link between the Delivery Module’s `delivery` data model and the Order Module’s `order` data model, indicating that a delivery is associated with the order created by the customer. + +### Further Reads + +- [How to Define Links](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md) + +*** + +## Step 5: Run Migrations and Sync Links + +To create tables for the above data models in the database, start by generating the migrations for the Restaurant and Delivery Modules with the following commands: + +```bash +npx medusa db:generate restaurantModuleService +npx medusa db:generate deliveryModuleService +``` + +This generates migrations in the `src/modules/restaurant/migrations` and `src/modules/delivery/migrations` directories. + +Then, to reflect the migration and links in the database, run the following command: + +```bash +npx medusa db:migrate +``` + +*** + +## Step 6: Create Restaurant API Route + +To expose custom commerce features to frontend applications, such as the Medusa Admin dashboard or a storefront, you expose an endpoint by creating an API route. + +In this step, you’ll create the API route used to create a restaurant. This route requires no authentication, as anyone can create a restaurant. + +### Create Types + +Before implementing the functionalities, you’ll create type files in the Restaurant Module useful in the next steps. + +Create the file `src/modules/restaurant/types/index.ts` with the following content: + +```ts title="src/modules/restaurant/types/index.ts" +import { InferTypeOf } from "@medusajs/framework/types" +import RestaurantModuleService from "../service" +import { Restaurant } from "../models/restaurant" + +export type CreateRestaurant = Omit< + InferTypeOf, "id" | "admins" +> + +declare module "@medusajs/framework/types" { + export interface ModuleImplementations { + restaurantModuleService: RestaurantModuleService; + } +} +``` + +This adds a type used for inputs in creating a restaurant. It also adds a type for `restaurantModuleService` in `ModuleImplementations` so that when you resolve it from the Medusa container, it has the correct typing. + +Since the `Restaurant` data model is a variable, use `InferTypeOf` to infer its type. + +### Create Workflow + +To implement the functionality of creating a restaurant, create a workflow and execute it in the API route. + +The workflow only has one step that creates a restaurant. + +To implement the step, create the file `src/workflows/restaurant/steps/create-restaurant.ts` with the following content: + +```ts title="src/workflows/restaurant/steps/create-restaurant.ts" highlights={createRestaurantHighlight} collapsibleLines="1-7" expandMoreLabel="Show Imports" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { + CreateRestaurantDTO, +} from "../../../modules/restaurant/types/mutations" +import { RESTAURANT_MODULE } from "../../../modules/restaurant" +import RestaurantModuleService from "../../../modules/restaurant/service" + +export const createRestaurantStep = createStep( + "create-restaurant-step", + async function (data: CreateRestaurantDTO, { container }) { + const restaurantModuleService: RestaurantModuleService = container.resolve( + RESTAURANT_MODULE + ) + + const restaurant = await restaurantModuleService.createRestaurants(data) + + return new StepResponse(restaurant, restaurant.id) + }, + function (id: string, { container }) { + const restaurantModuleService: RestaurantModuleService = container.resolve( + RESTAURANT_MODULE + ) + + return restaurantModuleService.deleteRestaurants(id) + } +) +``` + +This creates a step that creates a restaurant. The step’s compensation function, which executes if an error occurs, deletes the created restaurant. + +Next, create the workflow at `src/workflows/restaurant/workflows/create-restaurant.ts`: + +```ts title="src/workflows/restaurant/workflows/create-restaurant.ts" +import { + createWorkflow, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { createRestaurantStep } from "../steps/create-restaurant" +import { CreateRestaurant } from "../../../modules/restaurant/types" + +type WorkflowInput = { + restaurant: CreateRestaurant; +}; + +export const createRestaurantWorkflow = createWorkflow( + "create-restaurant-workflow", + function (input: WorkflowInput) { + const restaurant = createRestaurantStep(input.restaurant) + + return new WorkflowResponse(restaurant) + } +) +``` + +The workflow executes the step and returns the created restaurant. + +### Create Route + +You’ll now create the API route that executes the workflow. + +Start by creating the file `src/api/restaurants/validation-schemas.ts` that holds the schema to validate the request body: + +```ts title="src/api/restaurants/validation-schemas.ts" +import { z } from "zod" + +export const restaurantSchema = z.object({ + name: z.string(), + handle: z.string(), + address: z.string(), + phone: z.string(), + email: z.string(), + image_url: z.string().optional(), +}) +``` + +Then, create the file `src/api/restaurants/route.ts` with the following content: + +```ts title="src/api/restaurants/route.ts" highlights={createRestaurantRouteHighlights} collapsibleLines="1-10" expandMoreLabel="Show Imports" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { MedusaError } from "@medusajs/framework/utils" +import { + CreateRestaurantDTO, + } from "../../modules/restaurant/types/mutations" +import { + createRestaurantWorkflow, + } from "../../workflows/restaurant/workflows/create-restaurant" +import { restaurantSchema } from "./validation-schemas" + +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const validatedBody = restaurantSchema.parse(req.body) as CreateRestaurantDTO + + if (!validatedBody) { + return MedusaError.Types.INVALID_DATA + } + + const { result: restaurant } = await createRestaurantWorkflow(req.scope) + .run({ + input: { + restaurant: validatedBody, + }, + }) + + return res.status(200).json({ restaurant }) +} +``` + +This creates a `POST` API route at `/restaurants`. It executes the `createRestaurantWorkflow` to create a restaurant and returns it in the response. + +### Test it Out + +To test the API route out, start the Medusa application: + +```bash +npm run dev +``` + +Then, send a `POST` request to `/restaurants` : + +```bash +curl -X POST 'http://localhost:9000/restaurants' \ +-H 'Content-Type: application/json' \ +--data-raw '{ + "name": "Acme", + "handle": "acme", + "address": "1st street", + "phone": "1234567", + "email": "acme@restaurant.com" +}' +``` + +The API route creates a restaurant and returns it. + +If you’re calling this API route from a frontend client, make sure to set the [CORS middleware](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/cors/index.html.md) on it since it’s not under the `/store` or `/admin` route prefixes. + +### Further Reads + +- [How to Create a Workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md) +- [What is a Compensation Function](https://docs.medusajs.com/docs/learn/fundamentals/workflows/compensation-function/index.html.md) +- [How to Create an API route](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md) + +*** + +## Step 7: List Restaurants API Route + +In this step, you’ll create the API routes that retrieves a list of restaurants. + +In the file `src/api/restaurants/route.ts` add the following API route: + +```ts title="src/api/restaurants/route.ts" +// other imports... +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { + ContainerRegistrationKeys, + QueryContext, +} from "@medusajs/framework/utils" + +// ... + +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const { currency_code = "eur", ...queryFilters } = req.query + + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: restaurants } = await query.graph({ + entity: "restaurants", + fields: [ + "id", + "handle", + "name", + "address", + "phone", + "email", + "image_url", + "is_open", + "products.*", + "products.categories.*", + "products.variants.*", + "products.variants.calculated_price.*", + ], + filters: queryFilters, + context: { + products: { + variants: { + calculated_price: QueryContext({ + currency_code, + }), + }, + }, + }, + }) + + return res.status(200).json({ restaurants }) +} +``` + +This creates a `GET` API route at `/restaurants`. It uses Query to retrieve a restaurant, its products, and the product variant’s prices for a specified currency. + +### Test it Out + +To test this API route out, send a `GET` request to `/restaurants`: + +```bash +curl 'http://localhost:9000/restaurants' +``` + +This returns the list of restaurants in the response. + +### Further Reads + +- [What is and how to use it](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md) +- [How to Retrieve Prices for Product Variants](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/product/guides/price/index.html.md) + +*** + +## Step 8: Create User API Route + +In this step, you’ll create the API route that creates a driver or a restaurant admin user. + +Medusa provides an authentication flow that allows you to authenticate custom user types: + +1. Use the `/auth/{actor_type}/{provider}/register` route to obtain an authentication token for registration. `{actor_type}` is the custom user type, such as `driver`, and `{provider}` is the provider used for authentication, such as `emailpass`. +2. Use a custom route to create the user. You pass in the request header the authentication token from the previous request to associate your custom user with the authentication identity created for it in the previous request. +3. After that, you can retrieve an authenticated token for the user using the `/auth/{actor_type}/provider` API route. + +### Create Workflow + +To implement and expose a feature that manipulates data, you create a workflow that uses services to implement the functionality, then create an API route that executes that workflow. + +So, you'll start by implementing the functionality to create a user in a workflow. The workflow has two steps: + +1. Create the user in the database. This user is either a restaurant admin or a driver. So, you'll create two separate steps for each user type. +2. Set the actor type of the user’s authentication identity (created by the `/auth/{actor_type}/{provider}/register` API route). For this step, you’ll use `setAuthAppMetadataStep` from Medusa's core workflows. + +You'll start by implementing the steps to create a restaurant admin. Create the file \` + +To implement the first step, create the file `src/workflows/user/steps/create-restaurant-admin.ts` with the following content: + +```ts title="src/workflows/user/steps/create-restaurant-admin.ts" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { RESTAURANT_MODULE } from "../../../modules/restaurant" +import RestaurantModuleService from "../../../modules/restaurant/service" + +export type CreateRestaurantAdminInput = { + restaurant_id: string; + email: string; + first_name: string; + last_name: string; +}; + +export const createRestaurantAdminStep = createStep( + "create-restaurant-admin-step", + async ( + data: CreateRestaurantAdminInput, + { container } + ) => { + const restaurantModuleService: RestaurantModuleService = container.resolve( + RESTAURANT_MODULE + ) + const restaurantAdmin = await restaurantModuleService.createRestaurantAdmins( + data + ) + + return new StepResponse(restaurantAdmin, restaurantAdmin.id) + }, + async (id, { container }) => { + if (!id) { + return + } + + const restaurantModuleService: RestaurantModuleService = + container.resolve(RESTAURANT_MODULE) + + await restaurantModuleService.deleteRestaurantAdmins(id) + } +) + +``` + +This creates a step that accepts as input the data of the restaurant to create and its type. The step creates a restaurant admin and returns it. + +In the compensation function, you delete the created restaurant admin if an error occurs. + +Then, to implement the step that creates a driver, create the file `src/workflows/user/steps/create-driver.ts` with the following content: + +```ts title="src/workflows/user/steps/create-driver.ts" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { DELIVERY_MODULE } from "../../../modules/delivery" +import DeliveryModuleService from "../../../modules/delivery/service" + +export type CreateDriverInput = { + email: string; + first_name: string; + last_name: string; + phone: string; + avatar_url?: string; +}; + +export const createDriverStep = createStep( + "create-driver-step", + async ( + data: CreateDriverInput, + { container } + ) => { + const deliveryModuleService: DeliveryModuleService = container.resolve( + DELIVERY_MODULE + ) + + const driver = await deliveryModuleService.createDrivers(data) + + return new StepResponse(driver, driver.id) + }, + async (id, { container }) => { + if (!id) { + return + } + + const deliveryModuleService: DeliveryModuleService = container.resolve( + DELIVERY_MODULE + ) + + await deliveryModuleService.deleteDrivers(id) + } +) +``` + +This creates a step that accepts as input the data of the driver to create. The step creates a driver and returns it. + +In the compensation function, you delete the created driver if an error occurs. + +Next, create the workflow in the file `src/workflows/user/workflows/create-user.ts`: + +```ts title="src/workflows/user/workflows/create-user.ts" collapsibleLines="1-13" expandButtonLabel="Show Imports" +import { setAuthAppMetadataStep } from "@medusajs/medusa/core-flows" +import { + createWorkflow, + transform, + when, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { CreateDriverInput, createDriverStep } from "../steps/create-driver" +import { + CreateRestaurantAdminInput, + createRestaurantAdminStep, +} from "../steps/create-restaurant-admin" + +export type CreateUserWorkflowInput = { + user: (CreateRestaurantAdminInput | CreateDriverInput) & { + actor_type: "restaurant" | "driver"; + }; + auth_identity_id: string; +}; + +export const createUserWorkflow = createWorkflow( + "create-user-workflow", + function (input: CreateUserWorkflowInput) { + // TODO create user + } +) +``` + +In this file, you create the necessary types and the workflow with a `TODO`. + +Replace the `TODO` with the following: + +```ts title="src/workflows/user/workflows/create-user.ts" highlights={createUserHighlights} +const restaurantUser = when(input, (input) => input.user.actor_type === "restaurant") + .then(() => { + return createRestaurantAdminStep( + input.user as CreateRestaurantAdminInput + ) + }) + + const driverUser = when(input, (input) => input.user.actor_type === "driver") + .then(() => { + return createDriverStep( + input.user as CreateDriverInput + ) + }) + +const { user, authUserInput } = transform({ input, restaurantUser, driverUser }, (data) => { + const user = data.restaurantUser || data.driverUser + return { + user, + authUserInput: { + authIdentityId: data.input.auth_identity_id, + actorType: data.input.user.actor_type, + value: user.id, + }, + } +}) + +setAuthAppMetadataStep(authUserInput) + +return new WorkflowResponse(user) +``` + +In the workflow, you: + +1. Create a restaurant admin if the actor type is `restaurant`. +2. Create a driver if the actor type is `driver`. +3. Use `transform` to create the input to be passed to the next step and return the created user, which is either a restaurant admin or a driver. +4. Use `setAuthAppMetadataStep` from Medusa's core workflows to update the authentication identity and associate it with the new user. +5. Return the created user. + +### Create API Route + +You’ll now create the API route to create a new user using the `createUserWorkflow`. + +Start by creating the file `src/api/users/validation-schemas.ts` that holds the schema necessary to validate the request body: + +```ts title="src/api/users/validation-schemas.ts" +import { z } from "zod" + +export const createUserSchema = z + .object({ + email: z.string().email(), + first_name: z.string(), + last_name: z.string(), + phone: z.string(), + avatar_url: z.string().optional(), + restaurant_id: z.string().optional(), + actor_type: z.ZodEnum.create(["restaurant", "driver"]), + }) +``` + +Then, create the file `src/api/users/route.ts` with the following content: + +```ts title="src/api/users/route.ts" collapsibleLines="1-10" expandButtonLabel="Show Imports" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { + createUserWorkflow, + CreateUserWorkflowInput, +} from "../../workflows/user/workflows/create-user" +import { createUserSchema } from "./validation-schemas" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { auth_identity_id } = req.auth_context + + const validatedBody = createUserSchema.parse(req.body) + + const { result } = await createUserWorkflow(req.scope).run({ + input: { + user: validatedBody, + auth_identity_id, + } as CreateUserWorkflowInput, + }) + + res.status(201).json({ user: result }) +} + +``` + +This creates a `POST` API route at `/users` that creates a driver or a restaurant admin. + +### Add Authentication Middleware + +A middleware is executed when an HTTP request is received and before the route handler. It can be used to guard routes based on restrictions or authentication. + +The `/users` API route must only be accessed with the authentication token in the header. So, you must add an authentication middleware on the route. + +Create the file `src/api/middlewares.ts` with the following content: + +```ts title="src/api/middlewares.ts" +import { + authenticate, + defineMiddlewares, +} from "@medusajs/framework/http" + +export default defineMiddlewares({ + routes: [ + { + method: ["POST"], + matcher: "/users", + middlewares: [ + authenticate(["driver", "restaurant"], "bearer", { + allowUnregistered: true, + }), + ], + }, + ], +}) +``` + +This applies the `authenticate` middleware from the Medusa Framework on the `POST /users` API routes. + +### Test it Out: Create Restaurant Admin + +To create a restaurant admin: + +1. Send a `POST` request to `/auth/restaurant/emailpass` to retrieve the token for the next request: + +```bash +curl -X POST 'http://localhost:9000/auth/restaurant/emailpass/register' \ +--data-raw '{ + "email": "admin@restaurant.com", + "password": "supersecret" +}' +``` + +2. Send a `POST` request to `/users`, passing the token received from the previous request in the header: + +```bash +curl -X POST 'http://localhost:9000/users' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer {token}' \ +--data-raw '{ + "email": "admin@restaurant.com", + "first_name": "admin", + "last_name": "restaurant", + "phone": "1234566", + "actor_type": "restaurant", + "restaurant_id": "res_01J5ZWMY48JWFY4W5Y8B3NER7S" +}' +``` + +Notice that you must also pass the restaurant ID in the request body. + +This returns the created restaurant admin user. + +### Test it Out: Create Driver + +To create a driver: + +1. Send a `POST` request to `/auth/driver/emailpass` to retrieve the token for the next request: + +```bash +curl -X POST 'http://localhost:9000/auth/driver/emailpass/register' \ +-H 'Content-Type: application/json' \ +--data-raw '{ + "email": "driver@gmail.com", + "password": "supersecret" +}' +``` + +2. Send a `POST` request to `/users`, passing the token received from the previous request in the header: + +```bash +curl --location 'http://localhost:9000/users' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer {token}' \ +--data-raw '{ + "email": "driver@gmail.com", + "first_name": "driver", + "last_name": "test", + "phone": "1234566", + "actor_type": "driver" +}' +``` + +This returns the created driver user. + +### Further Reads + +- [How to Create an Actor Type](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/auth/create-actor-type/index.html.md) + +*** + +## Step 9: Delete Restaurant Admin API Route + +In this step, you'll create a workflow that deletes the restaurant admin and its association to its auth identity, then use it in an API route. + +The same logic can be applied to delete a driver. + +### Create deleteRestaurantAdminStep + +First, create the step that deletes the restaurant admin at `restaurant-marketplace/src/workflows/restaurant/steps/delete-restaurant-admin.ts`: + +```ts title="restaurant-marketplace/src/workflows/restaurant/steps/delete-restaurant-admin.ts" +import { + createStep, + StepResponse, +} from "@medusajs/framework/workflows-sdk" +import { RESTAURANT_MODULE } from "../../../modules/restaurant" +import { DeleteRestaurantAdminWorkflow } from "../workflows/delete-restaurant-admin" +import RestaurantModuleService from "../../../modules/restaurant/service" + +export const deleteRestaurantAdminStep = createStep( + "delete-restaurant-admin", + async ({ id }: DeleteRestaurantAdminWorkflow, { container }) => { + const restaurantModuleService: RestaurantModuleService = container.resolve( + RESTAURANT_MODULE + ) + + const admin = await restaurantModuleService.retrieveRestaurantAdmin(id) + + await restaurantModuleService.deleteRestaurantAdmins(id) + + return new StepResponse(undefined, { admin }) + }, + async ({ admin }, { container }) => { + const restaurantModuleService: RestaurantModuleService = container.resolve( + RESTAURANT_MODULE + ) + + const { restaurant: _, ...adminData } = admin + + await restaurantModuleService.createRestaurantAdmins(adminData) + } +) +``` + +In this step, you resolve the Restaurant Module's service and delete the admin. In the compensation function, you create the admin again. + +### Create deleteRestaurantAdminWorkflow + +Then, create the workflow that deletes the restaurant admin at `restaurant-marketplace/src/workflows/restaurant/workflows/delete-restaurant-admin.ts`: + +```ts title="restaurant-marketplace/src/workflows/restaurant/workflows/delete-restaurant-admin.ts" collapsibleLines="1-13" expandButtonLabel="Show Imports" +import { MedusaError } from "@medusajs/framework/utils" +import { + WorkflowData, + WorkflowResponse, + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" +import { + setAuthAppMetadataStep, + useQueryGraphStep, +} from "@medusajs/medusa/core-flows" +import { deleteRestaurantAdminStep } from "../steps/delete-restaurant-admin" + +export type DeleteRestaurantAdminWorkflow = { + id: string +} + +export const deleteRestaurantAdminWorkflow = createWorkflow( + "delete-restaurant-admin", + ( + input: WorkflowData + ): WorkflowResponse => { + deleteRestaurantAdminStep(input) + + // TODO update auth identity + } +) +``` + +So far, you only use the `deleteRestaurantAdminStep` in the workflow, which deletes the restaurant admin. + +Replace the `TODO` with the following: + +```ts title="restaurant-marketplace/src/workflows/restaurant/workflows/delete-restaurant-admin.ts" +const { data: authIdentities } = useQueryGraphStep({ + entity: "auth_identity", + fields: ["id"], + filters: { + app_metadata: { + restaurant_id: input.id, + }, + }, +}) + +const authIdentity = transform( + { authIdentities }, + ({ authIdentities }) => { + const authIdentity = authIdentities[0] + + if (!authIdentity) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + "Auth identity not found" + ) + } + + return authIdentity + } +) + +setAuthAppMetadataStep({ + authIdentityId: authIdentity.id, + actorType: "restaurant", + value: null, +}) + +return new WorkflowResponse(input.id) +``` + +After deleting the restaurant admin, you: + +1. Retrieve its auth identity using Query. To do that, you filter its `app_metadata` property by checking that its `restaurant_id` property's value is the admin's ID. For drivers, you replace `restaurant_id` with `driver_id`. +2. Check that the auth identity exists using `transform`. Otherwise, throw an error. +3. Unset the association between the auth identity and the restaurant admin using `setAuthAppMetadataStep` from Medusa's core workflows. + +### Create API Route + +Finally, add the API route that uses the workflow at `src/api/restaurants/[id]/admins/[admin_id]/route.ts`: + +```ts title="src/api/restaurants/[id]/admins/[admin_id]/route.ts" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { + deleteRestaurantAdminWorkflow, +} from "../../../../../workflows/restaurant/workflows/delete-restaurant-admin" + +export const DELETE = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + await deleteRestaurantAdminWorkflow(req.scope).run({ + input: { + id: req.params.admin_id, + }, + }) + + res.json({ message: "success" }) +} +``` + +You add a `DELETE` API route at `/restaurants/[id]/admins/[admin_id]`. In the route, you execute the workflow to delete the restaurant admin. + +### Add Authentication Middleware + +This API route should only be accessible by restaurant admins. + +So, in the file `src/api/middlewares.ts`, add a new middleware: + +```ts title="src/api/middlewares.ts" +export default defineMiddlewares({ + routes: [ + // ... + { + method: ["POST", "DELETE"], + matcher: "/restaurants/:id/**", + middlewares: [ + authenticate(["restaurant", "user"], "bearer"), + ], + }, + ], +}) +``` + +This allows only restaurant admins and Medusa Admin users to access routes under the `/restaurants/[id]` prefix if the request method is `POST` or `DELETE`. + +### Test API Route + +To test it out, create another restaurant admin user, then send a `DELETE` request to `/restaurants/[id]/admins/[admin_id]`, authenticated as the first admin user you created: + +```bash +curl -X DELETE 'http://localhost:9000/restaurants/01J7GHGQTCAVY5C1AH1H733Q4G/admins/01J7GJKHWXF1YDMXH09EXEDCD6' \ +-H 'Authorization: Bearer {token}' +``` + +Make sure to replace the first ID with the restaurant's ID, and the second ID with the ID of the admin to delete. + +*** + +## Step 10: Create Restaurant Product API Route + +In this step, you’ll create the API route that creates a product for a restaurant. + +### Create Workflow + +You’ll start by creating a workflow that creates the restaurant’s products. It has two steps: + +1. Create the product using Medusa’s `createProductsWorkflow` as a step. This workflow is available through Medusa's core workflows. +2. Create a link between the restaurant and the products using `createRemoateLinkStep` from Medusa's core workflows. + +So, create the workflow in the file `src/workflows/restaurant/workflows/create-restaurant-products.ts` with the following content: + +```ts title="src/workflows/restaurant/workflows/create-restaurant-products.ts" highlights={createProductHighlights} collapsibleLines="13" expandButtonLabel="Show Imports" +import { + createProductsWorkflow, + createRemoteLinkStep, +} from "@medusajs/medusa/core-flows" +import { CreateProductWorkflowInputDTO } from "@medusajs/framework/types" +import { Modules } from "@medusajs/framework/utils" +import { + WorkflowResponse, + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" +import { RESTAURANT_MODULE } from "../../../modules/restaurant" + +type WorkflowInput = { + products: CreateProductWorkflowInputDTO[]; + restaurant_id: string; +}; + +export const createRestaurantProductsWorkflow = createWorkflow( + "create-restaurant-products-workflow", + function (input: WorkflowInput) { + const products = createProductsWorkflow.runAsStep({ + input: { + products: input.products, + }, + }) + + const links = transform({ + products, + input, + }, (data) => data.products.map((product) => ({ + [RESTAURANT_MODULE]: { + restaurant_id: data.input.restaurant_id, + }, + [Modules.PRODUCT]: { + product_id: product.id, + }, + }))) + + createRemoteLinkStep(links) + + return new WorkflowResponse(products) + } +) + +``` + +In the workflow, you: + +1. Execute the `createProductsWorkflow` as a step, passing the workflow’s input as the details of the product. +2. Use `transform` to create a `links` object used to specify the links to create in the next step. +3. Use the `createRemoteLinkStep` to create the links between the restaurant and the products. +4. Return the created products. + +### Create API Route + +Create the file `src/api/restaurants/[id]/products/route.ts` with the following content: + +```ts title="src/api/restaurants/[id]/products/route.ts" collapsibleLines="1-9" expandButtonLabel="Show Imports" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { + AdminCreateProduct, +} from "@medusajs/medusa/api/admin/products/validators" +import { z } from "zod" +import { + createRestaurantProductsWorkflow, +} from "../../../../workflows/restaurant/workflows/create-restaurant-products" + +const createSchema = z.object({ + products: AdminCreateProduct().array(), +}) + +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const validatedBody = createSchema.parse(req.body) + + const { result: restaurantProducts } = await createRestaurantProductsWorkflow( + req.scope + ).run({ + input: { + products: validatedBody.products as any[], + restaurant_id: req.params.id, + }, + }) + + return res.status(200).json({ restaurant_products: restaurantProducts }) +} +``` + +The creates a `POST` API route at `/restaurants/[id]/products`. It accepts the products’ details in the request body, executes the `createRestaurantProductsWorkflow` to create the products, and returns the created products in the response. + +### Test it Out + +To create a product using the above API route, send a `POST` request to `/restaurants/[id]/products`, replacing `[id]` with the restaurant’s ID: + +```bash +curl -X POST 'http://localhost:9000/restaurants/res_01J5X704WQTFSZMRC7Z6S3YAC7/products' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer {token}' \ +--data '{ + "products": [ + { + "title": "Sushi", + "status": "published", + "variants": [ + { + "title": "Default", + "prices": [ + { + "currency_code": "eur", + "amount": 20 + } + ], + "manage_inventory": false + } + ], + "sales_channels": [ + { + + "id": "sc_01J5ZWK4MKJF85PM8KTW0BWMCK" + } + ] + } + ] +}' +``` + +Make sure to replace the sales channel’s ID with the ID of a sales channel in your store. This is necessary when you later create an order, as the cart must have the same sales channel as the product. + +The request returns the created product in the response. + +*** + +## Step 11: Create Order Delivery Workflow + +In this step, you’ll create the workflow that creates a delivery. You’ll use it at a later step once a customer places their order. + +The workflow to create a delivery has three steps: + +1. `validateRestaurantStep` that checks whether a restaurant with the specified ID exists. +2. `createDeliveryStep` that creates the delivery. +3. `createRemoteLinkStep` that creates links between the different data model records. This step is from Medusa's core workflows. + +### Create validateRestaurantStep + +To create the first step, create the file `src/workflows/delivery/steps/validate-restaurant.ts` with the following content: + +```ts title="src/workflows/delivery/steps/validate-restaurant.ts" +import { + createStep, +} from "@medusajs/framework/workflows-sdk" +import { RESTAURANT_MODULE } from "../../../modules/restaurant" +import RestaurantModuleService from "../../../modules/restaurant/service" + +type ValidateRestaurantStepInput = { + restaurant_id: string +} + +export const validateRestaurantStep = createStep( + "validate-restaurant", + async ({ restaurant_id }: ValidateRestaurantStepInput, { container }) => { + const restaurantModuleService: RestaurantModuleService = container.resolve( + RESTAURANT_MODULE + ) + + // if a restaurant with the ID doesn't exist, an error is thrown + await restaurantModuleService.retrieveRestaurant( + restaurant_id + ) + } +) +``` + +This step tries to retrieve the restaurant using the Restaurant Module’s main service. If the restaurant doesn’t exist, and error is thrown and the workflow stops execution. + +### Create createDeliveryStep + +Next, create the file `src/workflows/delivery/steps/create-delivery.ts` with the following content to create the second step: + +```ts title="src/workflows/delivery/steps/create-delivery.ts" highlights={createDeliveryStepHighlights} +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { DELIVERY_MODULE } from "../../../modules/delivery" +import DeliveryModuleService from "../../../modules/delivery/service" + +export const createDeliveryStep = createStep( + "create-delivery-step", + async function (_, { container }) { + const deliverModuleService: DeliveryModuleService = + container.resolve(DELIVERY_MODULE) + + const delivery = await deliverModuleService.createDeliveries({}) + + return new StepResponse(delivery, { + delivery_id: delivery.id, + }) + }, + async function ({ delivery_id }, { container }) { + const deliverModuleService: DeliveryModuleService = + container.resolve(DELIVERY_MODULE) + + deliverModuleService.softDeleteDeliveries(delivery_id) + } +) + +``` + +This step creates a delivery and returns it. In the compensation function, it deletes the delivery. + +### Create createDeliveryWorkflow + +Finally, create the workflow in `src/workflows/delivery/workflows/create-delivery.ts`: + +```ts title="src/workflows/delivery/workflows/create-delivery.ts" highlights={createDeliveryWorkflowHighlights} collapsibleLines="1-13" expandButtonLabel="Show Imports" +import { + WorkflowData, + WorkflowResponse, + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" +import { Modules } from "@medusajs/framework/utils" +import { createRemoteLinkStep } from "@medusajs/medusa/core-flows" +import { DELIVERY_MODULE } from "../../../modules/delivery" +import { RESTAURANT_MODULE } from "../../../modules/restaurant" +import { validateRestaurantStep } from "../steps/validate-restaurant" +import { createDeliveryStep } from "../steps/create-delivery" + +type WorkflowInput = { + cart_id: string; + restaurant_id: string; +}; + +export const createDeliveryWorkflowId = "create-delivery-workflow" +export const createDeliveryWorkflow = createWorkflow( + createDeliveryWorkflowId, + function (input: WorkflowInput) { + validateRestaurantStep({ + restaurant_id: input.restaurant_id, + }) + const delivery = createDeliveryStep() + + const links = transform({ + input, + delivery, + }, (data) => ([ + { + [DELIVERY_MODULE]: { + delivery_id: data.delivery.id, + }, + [Modules.CART]: { + cart_id: data.input.cart_id, + }, + }, + { + [RESTAURANT_MODULE]: { + restaurant_id: data.input.restaurant_id, + }, + [DELIVERY_MODULE]: { + delivery_id: data.delivery.id, + }, + }, + ])) + + createRemoteLinkStep(links) + + return new WorkflowResponse(delivery) + } +) +``` + +In the workflow, you: + +1. Use the `validateRestaurantStep` to validate that the restaurant exists. +2. Use the `createDeliveryStep` to create the delivery. +3. Use `transform` to specify the links to be created in the next step. You specify links between the delivery and cart, and between the restaurant and delivery. +4. Use the `createRemoteLinkStep` to create the links. +5. Return the created delivery. + +*** + +## Step 12: Handle Delivery Workflow + +In this step, you’ll create the workflow that handles the different stages of the delivery. This workflow needs to run in the background to update the delivery when an action occurs. + +For example, when a restaurant finishes preparing the order’s items, this workflow creates a fulfillment for the order. + +This workflow will be a [long-running workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/long-running-workflow/index.html.md) that runs asynchronously in the background. Its async steps only succeed once an outside action sets its status, allowing the workflow to move to the next step. + +API routes that perform actions related to the delivery, which you’ll create later, will trigger the workflow to move to the next step. + +### Workflow’s Steps + +The workflow has the following steps: + +Steps that have a `*` next to their names are async steps. + +- [setTransactionIdStep](#create-setTransactionIdStep): Sets the ID of the workflow’s transaction in the delivery’s \`transaction\_id\` property. This is useful for moving the steps of the +- [notifyRestaurantStep\*](#create-notifyRestaurantStep): An async step that omits an event to notify restaurants of the new order. +- [awaitDriverClaimStep\*](#create-awaitDriverClaimStep): An async step that’s executed once the restaurant accepts the order. It waits until a driver claims the delivery. +- [createOrderStep](#create-createOrderStep): Creates an order once a driver claims the delivery. It also returns links to be created by the next step. +- [createRemoteLinkStep](https://docs.medusajs.com/references/helper-steps/createRemoteLinkStep/index.html.md): Creates the links returned by the previous step between the order and delivery. This is from Medusa's core workflows. +- [awaitStartPreparationStep\*](#create-awaitStartPreparationStep): An async step that waits until the restaurant changes the delivery’s status to \`restaurant\_preparing\`. +- [awaitPreparationStep\*](#create-awaitPreparationStep): An async step that once the restaurant changes the delivery’s status to preparing, waits until the restaurant changes the delivery’s status to \`ready\_for\_pickup\`. +- [createFulfillmentStep](#create-createFulfillmentStep): Creates a fulfillment after the restaurant changes the delivery’s status to \`ready\_for\_pickup\`. +- [awaitPickUpStep\*](#create-awaitPickUpStep): An async step that waits until the driver changes the delivery’s status to \`in\_transit\`. +- [awaitDeliveryStep\*](#create-awaitDeliveryStep): An async step that waits until the driver changes the delivery’s status to \`delivered\`. + +You’ll implement these steps next. + +### create setTransactionIdStep + +Create the file `src/workflows/delivery/steps/set-transaction-id.ts` with the following content: + +```ts title="src/workflows/delivery/steps/set-transaction-id.ts" highlights={setTransactionIdStepHighlights} +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { DELIVERY_MODULE } from "../../../modules/delivery" +import DeliveryModuleService from "../../../modules/delivery/service" + +export type SetTransactionIdStepInput = { + delivery_id: string; +}; + +export const setTransactionIdStep = createStep( + "create-delivery-step", + async function (deliveryId: string, { container, context }) { + const deliverModuleService: DeliveryModuleService = + container.resolve(DELIVERY_MODULE) + + const delivery = await deliverModuleService.updateDeliveries({ + id: deliveryId, + transaction_id: context.transactionId, + }) + + return new StepResponse(delivery, delivery.id) + }, + async function (delivery_id: string, { container }) { + const deliverModuleService: DeliveryModuleService = + container.resolve(DELIVERY_MODULE) + + await deliverModuleService.updateDeliveries({ + id: delivery_id, + transaction_id: null, + }) + } +) +``` + +In this step, you update the `transaction_id` property of the delivery to the current workflow execution’s transaction ID. It can be found in the `context` property passed in the second object parameter of the step. + +In the compensation function, you set the `transaction_id` to `null`. + +### create notifyRestaurantStep + +Create the file `src/workflows/delivery/steps/notify-restaurant.ts` with the following content: + +```ts title="src/workflows/delivery/steps/notify-restaurant.ts" highlights={notifyRestaurantStepHighlights} collapsibleLines="1-6" expandButtonLabel="Show Imports" +import { + Modules, + ContainerRegistrationKeys, +} from "@medusajs/framework/utils" +import { createStep } from "@medusajs/framework/workflows-sdk" + +export const notifyRestaurantStepId = "notify-restaurant-step" +export const notifyRestaurantStep = createStep( + { + name: notifyRestaurantStepId, + async: true, + timeout: 60 * 15, + maxRetries: 2, + }, + async function (deliveryId: string, { container }) { + const query = container.resolve(ContainerRegistrationKeys.QUERY) + + const { data: [delivery] } = await query.graph({ + entity: "deliveries", + filters: { + id: deliveryId, + }, + fields: ["id", "restaurant.id"], + }) + + const eventBus = container.resolve(Modules.EVENT_BUS) + + await eventBus.emit({ + name: "notify.restaurant", + data: { + restaurant_id: delivery.restaurant.id, + delivery_id: delivery.id, + }, + }) + } +) +``` + +In this step, you: + +- Retrieve the delivery with its linked restaurant. +- Emit a `notify.restaurant` event using the event bus module’s service. + +Since the step is async, the workflow only removes past it once it’s marked as successful, which will happen when the restaurant accepts the order. + +A step is async if the `async` option is specified in the first object parameter of `createStep`. + +### Create awaitDriverClaimStep + +Create the file `src/workflows/delivery/steps/await-driver-claim.ts` with the following content: + +```ts title="src/workflows/delivery/steps/await-driver-claim.ts" +import { createStep } from "@medusajs/framework/workflows-sdk" + +export const awaitDriverClaimStepId = "await-driver-claim-step" +export const awaitDriverClaimStep = createStep( + { + name: awaitDriverClaimStepId, + async: true, + timeout: 60 * 15, + maxRetries: 2, + }, + async function (_, { container }) { + const logger = container.resolve("logger") + logger.info("Awaiting driver to claim...") + } +) +``` + +This step is async and its only purpose is to wait until it’s marked as successful, which will happen when the driver claims the delivery. + +### Create createOrderStep + +Create the file `src/workflows/delivery/steps/create-order.ts` with the following content: + +```ts title="src/workflows/delivery/steps/create-order.ts" highlights={createOrderStepHighlights1} collapsibleLines="1-9" expandButtonLabel="Show Imports" +import { CreateOrderShippingMethodDTO } from "@medusajs/framework/types" +import { + Modules, + ContainerRegistrationKeys, +} from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { DELIVERY_MODULE } from "../../../modules/delivery" + +export const createOrderStep = createStep( + "create-order-step", + async function (deliveryId: string, { container }) { + const query = container.resolve(ContainerRegistrationKeys.QUERY) + + const { data: [delivery] } = await query.graph({ + entity: "deliveries", + fields: [ + "id", + "cart.*", + "cart.shipping_address.*", + "cart.billing_address.*", + "cart.items.*", + "cart.shipping_methods.*", + ], + filters: { + id: deliveryId, + }, + }) + + // TODO create order + }, + async ({ orderId }, { container }) => { + // TODO add compensation + } +) + +``` + +This creates the `createOrderStep`, which so far only retrieves the delivery with its linked cart. + +Replace the `TODO` with the following to create the order: + +```ts title="src/workflows/delivery/steps/create-order.ts" highlights={createOrderStepHighlights2} +const { cart } = delivery + +const orderModuleService = container.resolve(Modules.ORDER) + +const order = await orderModuleService.createOrders({ + currency_code: cart.currency_code, + email: cart.email, + shipping_address: cart.shipping_address, + billing_address: cart.billing_address, + items: cart.items, + region_id: cart.region_id, + customer_id: cart.customer_id, + sales_channel_id: cart.sales_channel_id, + shipping_methods: + cart.shipping_methods as unknown as CreateOrderShippingMethodDTO[], +}) + +const linkDef = [{ + [DELIVERY_MODULE]: { + delivery_id: delivery.id as string, + }, + [Modules.ORDER]: { + order_id: order.id, + }, +}] + +return new StepResponse({ + order, + linkDef, +}, { + orderId: order.id, +}) +``` + +You create the order using the Order Module’s main service. Then, you create an object holding the links to return. The `createRemoteLinkStep` is used later to create those links. + +Then, replace the `TODO` in the compensation function with the following: + +```ts title="src/workflows/delivery/steps/create-order.ts" +const orderService = container.resolve(Modules.ORDER) + +await orderService.softDeleteOrders([orderId]) +``` + +You delete the order in the compensation function. + +### create awaitStartPreparationStep + +Create the file `src/workflows/delivery/steps/await-start-preparation.ts` with the following content: + +```ts title="src/workflows/delivery/steps/await-start-preparation.ts" +import { createStep } from "@medusajs/framework/workflows-sdk" + +export const awaitStartPreparationStepId = "await-start-preparation-step" +export const awaitStartPreparationStep = createStep( + { name: awaitStartPreparationStepId, async: true, timeout: 60 * 15 }, + async function (_, { container }) { + const logger = container.resolve("logger") + logger.info("Awaiting start of preparation...") + } +) +``` + +This step is async and its only purpose is to wait until it’s marked as successful, which will happen when the restaurant sets the delivery’s status as `restaurant_preparing`. + +### create awaitPreparationStep + +Create the file `src/workflows/delivery/steps/await-preparation.ts` with the following content: + +```ts title="src/workflows/delivery/steps/await-preparation.ts" +import { createStep } from "@medusajs/framework/workflows-sdk" + +export const awaitPreparationStepId = "await-preparation-step" +export const awaitPreparationStep = createStep( + { name: awaitPreparationStepId, async: true, timeout: 60 * 15 }, + async function (_, { container }) { + const logger = container.resolve("logger") + logger.info("Awaiting preparation...") + } +) +``` + +This step is async and its only purpose is to wait until it’s marked as successful, which will happen when the restaurant sets the delivery’s status as `ready_for_pickup`. + +### create createFulfillmentStep + +Create the file `src/workflows/delivery/steps/create-fulfillment.ts` with the following content: + +```ts title="src/workflows/delivery/steps/create-fulfillment.ts" highlights={createFulfillmentStepHighlights} +import { OrderDTO } from "@medusajs/framework/types" +import { Modules } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" + +export const createFulfillmentStep = createStep( + "create-fulfillment-step", + async function (order: OrderDTO, { container }) { + const fulfillmentModuleService = container.resolve( + Modules.FULFILLMENT + ) + + const items = order.items?.map((lineItem) => ({ + title: lineItem.title, + sku: lineItem.variant_sku || "", + quantity: lineItem.quantity, + barcode: lineItem.variant_barcode || "", + line_item_id: lineItem.id, + })) + + const fulfillment = await fulfillmentModuleService.createFulfillment({ + provider_id: "manual_manual", + location_id: "1", + delivery_address: order.shipping_address!, + items: items || [], + labels: [], + order, + }) + + return new StepResponse(fulfillment, fulfillment.id) + }, + function (id: string, { container }) { + const fulfillmentModuleService = container.resolve( + Modules.FULFILLMENT + ) + + return fulfillmentModuleService.cancelFulfillment(id) + } +) +``` + +In this step, you retrieve the order’s items as required to create the fulfillment, then create the fulfillment and return it. + +In the compensation function, you cancel the fulfillment. + +### create awaitPickUpStep + +Create the file `src/workflows/delivery/steps/await-pick-up.ts` with the following content: + +```ts title="src/workflows/delivery/steps/await-pick-up.ts" +import { createStep } from "@medusajs/framework/workflows-sdk" + +export const awaitPickUpStepId = "await-pick-up-step" +export const awaitPickUpStep = createStep( + { name: awaitPickUpStepId, async: true, timeout: 60 * 15 }, + async function (_, { container }) { + const logger = container.resolve("logger") + logger.info("Awaiting pick up by driver...") + } +) + +``` + +This step is async and its only purpose is to wait until it’s marked as successful, which will happen when the driver sets the delivery’s status as `in_transit`. + +### create awaitDeliveryStep + +Create the file `src/workflows/delivery/steps/await-delivery.ts` with the following content: + +```ts title="src/workflows/delivery/steps/await-delivery.ts" +import { createStep } from "@medusajs/framework/workflows-sdk" + +export const awaitDeliveryStepId = "await-delivery-step" +export const awaitDeliveryStep = createStep( + { name: awaitDeliveryStepId, async: true, timeout: 60 * 15 }, + async function (_, { container }) { + const logger = container.resolve("logger") + logger.info("Awaiting delivery by driver...") + } +) +``` + +This step is async and its only purpose is to wait until it’s marked as successful, which will happen when the driver sets the delivery’s status as `delivered`. + +### create handleDeliveryWorkflow + +Finally, create the workflow at `src/workflows/delivery/workflows/handle-delivery.ts`: + +```ts title="src/workflows/delivery/workflows/handle-delivery.ts" highlights={handleDeliveryWorkflowHighlights} collapsibleLines="1-15" expandButtonLabel="Show Imports" +import { + WorkflowResponse, + createWorkflow, +} from "@medusajs/framework/workflows-sdk" +import { createRemoteLinkStep } from "@medusajs/medusa/core-flows" +import { setTransactionIdStep } from "../steps/set-transaction-id" +import { notifyRestaurantStep } from "../steps/notify-restaurant" +import { awaitDriverClaimStep } from "../steps/await-driver-claim" +import { createOrderStep } from "../steps/create-order" +import { awaitStartPreparationStep } from "../steps/await-start-preparation" +import { awaitPreparationStep } from "../steps/await-preparation" +import { createFulfillmentStep } from "../steps/create-fulfillment" +import { awaitPickUpStep } from "../steps/await-pick-up" +import { awaitDeliveryStep } from "../steps/await-delivery" + +type WorkflowInput = { + delivery_id: string; +}; + +const TWO_HOURS = 60 * 60 * 2 +export const handleDeliveryWorkflowId = "handle-delivery-workflow" +export const handleDeliveryWorkflow = createWorkflow( + { + name: handleDeliveryWorkflowId, + store: true, + retentionTime: TWO_HOURS, + }, + function (input: WorkflowInput) { + setTransactionIdStep(input.delivery_id) + + notifyRestaurantStep(input.delivery_id) + + awaitDriverClaimStep() + + const { + order, + linkDef, + } = createOrderStep(input.delivery_id) + + createRemoteLinkStep(linkDef) + + awaitStartPreparationStep() + + awaitPreparationStep() + + createFulfillmentStep(order) + + awaitPickUpStep() + + awaitDeliveryStep() + + return new WorkflowResponse("Delivery completed") + } +) + +``` + +In the workflow, you execute the steps in the same order mentioned earlier. The workflow has the following options: + +- `store` set to `true` to indicate that this workflow’s executions should be stored. +- `retentionTime` which indicates how long the workflow should be stored. It’s set to two hours. + +In the next steps, you’ll execute the workflow and see it in action as you add more API routes to handle the delivery. + +### Further Reads + +- [Long-Running Workflows](https://docs.medusajs.com/docs/learn/fundamentals/workflows/long-running-workflow/index.html.md) + +*** + +## Step 13: Create Order Delivery API Route + +In this step, you’ll create the API route that executes the workflows created by the previous two steps. This API route is used when a customer places their order. + +Create the file `src/api/store/deliveries/route.ts` with the following content: + +```ts title="src/api/store/deliveries/route.ts" highlights={createDeliveryRouteHighlights} collapsibleLines="1-7" expandButtonLabel="Show Imports" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { MedusaError } from "@medusajs/framework/utils" +import zod from "zod" +import { DELIVERY_MODULE } from "../../../modules/delivery" +import { createDeliveryWorkflow } from "../../../workflows/delivery/workflows/create-delivery" +import { handleDeliveryWorkflow } from "../../../workflows/delivery/workflows/handle-delivery" + +const schema = zod.object({ + cart_id: zod.string(), + restaurant_id: zod.string(), +}) + +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const validatedBody = schema.parse(req.body) + + const { result: delivery } = await createDeliveryWorkflow(req.scope).run({ + input: { + cart_id: validatedBody.cart_id, + restaurant_id: validatedBody.restaurant_id, + }, + }) + + await handleDeliveryWorkflow(req.scope).run({ + input: { + delivery_id: delivery.id, + }, + }) + + return res + .status(200) + .json({ delivery }) +} +``` + +This adds a `POST` API route at `/deliveries`. It first executes the `createDeliveryWorkflow`, which returns the delivery. Then, it executes the `handleDeliveryWorkflow`. + +In the response, it returns the created delivery. + +Long-running workflows don’t return the data until it finishes the execution. That’s why you use two workflows instead of one in this API route, as you need to return the created delivery. + +### Test it Out + +Before using the API route, you must have a cart with at least one product from a restaurant, and with the payment and shipping details set. + +### Simplified Steps to Create a Cart + +To create a cart: + +1. Send a `POST` request to `/store/carts` to create a cart: + +```bash +curl -X POST 'http://localhost:9000/store/carts' \ +-H 'Content-Type: application/json' \ +-H 'x-publishable-api-key: {your_publishable_api_key}' \ +--data-raw '{ + "region_id": "reg_01J66SWSSWFZPQV0GWR2W7P1SB", + "sales_channel_id": "sc_01J66SWSRK95F2P2KTFHMYR7VC", + "email": "customer@gmail.com", + "shipping_address": { + "first_name": "customer", + "last_name": "test", + "address_1": "first street", + "country_code": "dk", + "city": "Copenhagen", + "postal_code": "1234" + }, + "billing_address": { + "first_name": "customer", + "last_name": "test", + "address_1": "first street", + "country_code": "dk", + "city": "Copenhagen", + "postal_code": "1234" + } +}' +``` + +Make sure to replace the value of `region_id` and `sales_channel_id` with IDs from your store. + +2. Send a `POST` request to `/store/carts/[id]/line-items` to add a product variant to the cart: + +```bash +curl -X POST 'http://localhost:9000/store/carts/cart_01J67MS5WPH2CE5R84BENJCGSW/line-items' \ +-H 'Content-Type: application/json' \ +-H 'x-publishable-api-key: {your_publishable_api_key}' \ +--data-raw '{ + "variant_id": "variant_01J66T0VC2S0NB4BNDBCZN8P9F", + "quantity": 1 +}' +``` + +Make sure to replace the cart’s ID in the path parameter, and the variant ID with the ID of a restaurant’s product variant. + +3. Send a `GET` request to `/store/shipping-options` to retrieve the shipping options of the cart: + +```bash +curl 'http://localhost:9000/store/shipping-options?cart_id=cart_01J67JT10B3RCWKT9NFEFYA2XG' \ +-H 'x-publishable-api-key: {your_publishable_api_key}' +``` + +Make sure to replace the value of the `cart_id` query parameter with the cart’s ID. + +4. Copy an ID of a shipping option from the previous request, then send a `POST` request to `/store/carts/[id]/shipping-methods` to set the cart’s shipping method: + +```bash +curl -X POST 'http://localhost:9000/store/carts/cart_01J67MS5WPH2CE5R84BENJCGSW/shipping-methods' \ +-H 'Content-Type: application/json' \ +-H 'x-publishable-api-key: {your_publishable_api_key}' \ +--data-raw '{ + "option_id": "so_01J66SWSVQG7S1BSJKR7PYWH6C", + "data": {} +}' +``` + +Make sure to replace the cart’s ID in the path parameter, and the shipping option’s ID in the request body. + +5. Send a `POST` request to `/store/payment-collections` to create a payment collection for your cart: + +```bash +curl -X POST 'http://localhost:9000/store/payment-collections' \ +-H 'Content-Type: application/json' \ +-H 'x-publishable-api-key: {your_publishable_api_key}' \ +--data-raw '{ + "cart_id": "cart_01J67MS5WPH2CE5R84BENJCGSW" +}' +``` + +Make sure to replace the cart’s ID in the request body. + +6. Send a `POST` request to `/store/payment-collections/[id]/payment-sessions` to initialize a payment session in the payment collection: + +```bash +curl -X POST 'http://localhost:9000/store/payment-collections/pay_col_01J67MSK397M3BKD3RDVDMJSRE/payment-sessions' \ +-H 'Content-Type: application/json' \ +-H 'x-publishable-api-key: {your_publishable_api_key}' \ +--data-raw '{ + "provider_id": "pp_system_default" +}' +``` + +Make sure to replace the payment collection’s ID in the path parameter. + +After following these steps, you can test out the API route. + +Then, send a `POST` request to `/store/deliveries` to create the order delivery: + +```bash +curl -X POST 'http://localhost:9000/store/deliveries' \ +-H 'Content-Type: application/json' \ +-H 'x-publishable-api-key: {your_publishable_api_key}' \ +--data '{ + "cart_id": "cart_01J67MS5WPH2CE5R84BENJCGSW", + "restaurant_id": "res_01J66SYN5DSRR0R6QM3A4SYRFZ" +}' +``` + +Make sure to replace the cart and restaurant’s IDs. + +The created delivery is returned in the response. The `handleDeliveryWorkflow` only executes the first two steps, then waits until the `notifyRestaurantStep` is set as successful before continuing. + +In the upcoming steps, you’ll add functionalities to update the delivery’s status, which triggers the long-running workflow to continue executing its steps. + +*** + +## Step 14: Accept Delivery API Route + +In this step, you’ll create an API route that a restaurant admin uses to accept a delivery. This moves the `handleDeliveryWorkflow` execution from `notifyRestaurantStep` to the next step. + +### Add Types + +Before implementing the necessary functionalities, add the following types to `src/modules/delivery/types/index.ts`: + +```ts title="src/modules/delivery/types/index.ts" +// other imports... +import { InferTypeOf } from "@medusajs/framework/types" +import { Delivery } from "../models/delivery" + +// ... + +export type Delivery = InferTypeOf + +export type UpdateDelivery = Partial> & { + id: string; + driver_id?: string +} +``` + +These types are useful in the upcoming implementation steps. + +Since the `Delivery` data model is a variable, use `InferTypeOf` to infer its type. + +### Create Workflow + +As the API route should update the delivery’s status, you’ll create a new workflow to implement that functionality. + +The workflow has the following steps: + +1. `updateDeliveryStep`: A step that updates the delivery’s data, such as updating its status. +2. `setStepSuccessStep`: A step that changes the status of a step in the delivery’s `handleDeliveryWorkflow` execution to successful. This is useful to move to the next step in the long-running workflow. This step is only used if the necessary input is provided. +3. `setStepFailedStep`: A step that changes the status of a step in the delivery’s `handleDeliveryWorkflow` execution to failed. This step is only used if the necessary input is provided. + +So, start by creating the first step at `src/workflows/delivery/steps/update-delivery.ts`: + +```ts title="src/workflows/delivery/steps/update-delivery.ts" highlights={updateDeliveryStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { DELIVERY_MODULE } from "../../../modules/delivery" +import { UpdateDelivery } from "../../../modules/delivery/types" +import DeliveryModuleService from "../../../modules/delivery/service" + +type UpdateDeliveryStepInput = { + data: UpdateDelivery; +}; + +export const updateDeliveryStep = createStep( + "update-delivery-step", + async function ({ data }: UpdateDeliveryStepInput, { container }) { + const deliveryModuleService: DeliveryModuleService = + container.resolve(DELIVERY_MODULE) + + const prevDeliveryData = await deliveryModuleService.retrieveDelivery(data.id) + + const delivery = await deliveryModuleService + .updateDeliveries([data]) + .then((res) => res[0]) + + return new StepResponse(delivery, { + prevDeliveryData, + }) + }, + async ({ prevDeliveryData }, { container }) => { + const deliverModuleService: DeliveryModuleService = + container.resolve(DELIVERY_MODULE) + + const { + driver, + ...prevDeliveryDataWithoutDriver + } = prevDeliveryData + + await deliverModuleService.updateDeliveries(prevDeliveryDataWithoutDriver) + } +) +``` + +This step updates a delivery using the provided data. In the compensation function, it reverts the data to its previous state. + +Then, create the second step in `src/workflows/delivery/steps/set-step-success.ts`: + +```ts title="src/workflows/delivery/steps/set-step-success.ts" highlights={setStepSuccessStepHighlights} collapsibleLines="1-8" expandButtonLabel="Show Imports" +import { + Modules, + TransactionHandlerType, +} from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { Delivery } from "../../../modules/delivery/types" +import { handleDeliveryWorkflowId } from "../workflows/handle-delivery" + +type SetStepSuccessStepInput = { + stepId: string; + updatedDelivery: Delivery; +}; + +export const setStepSuccessStep = createStep( + "set-step-success-step", + async function ( + { stepId, updatedDelivery }: SetStepSuccessStepInput, + { container } + ) { + const engineService = container.resolve( + Modules.WORKFLOW_ENGINE + ) + + await engineService.setStepSuccess({ + idempotencyKey: { + action: TransactionHandlerType.INVOKE, + transactionId: updatedDelivery.transaction_id, + stepId, + workflowId: handleDeliveryWorkflowId, + }, + stepResponse: new StepResponse(updatedDelivery, updatedDelivery.id), + options: { + container, + }, + }) + } +) + +``` + +This step receives as an input the step’s ID and the associated delivery. + +In the step, you resolve the Workflow Engine Module’s service. You then use its `setStepSuccess` method to change the step’s status to success. It accepts details related to the workflow execution’s transaction ID, which is stored in the delivery record, and the step’s response, which is the updated delivery. + +Finally, create the last step in `src/workflows/delivery/steps/set-step-failed.ts`: + +```ts title="src/workflows/delivery/steps/set-step-failed.ts" highlights={setStepFailedStepHighlights} collapsibleLines="1-8" expandButtonLabel="Show Imports" +import { + Modules, + TransactionHandlerType, +} from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { Delivery } from "../../../modules/delivery/types" +import { handleDeliveryWorkflowId } from "../../delivery/workflows/handle-delivery" + +type SetStepFailedtepInput = { + stepId: string; + updatedDelivery: Delivery; +}; + +export const setStepFailedStep = createStep( + "set-step-failed-step", + async function ( + { stepId, updatedDelivery }: SetStepFailedtepInput, + { container } + ) { + const engineService = container.resolve( + Modules.WORKFLOW_ENGINE + ) + + await engineService.setStepFailure({ + idempotencyKey: { + action: TransactionHandlerType.INVOKE, + transactionId: updatedDelivery.transaction_id, + stepId, + workflowId: handleDeliveryWorkflowId, + }, + stepResponse: new StepResponse(updatedDelivery, updatedDelivery.id), + options: { + container, + }, + }) + } +) +``` + +This step is similar to the last one, except it uses the `setStepFailure` method of the Workflow Engine Module’s service to set the status of the step as failed. + +You can now create the workflow. Create the file `src/workflows/delivery/workflows/update-delivery.ts` with the following content: + +```ts title="src/workflows/delivery/workflows/update-delivery.ts" highlights={updateDeliveryWorkflowHighlights} collapsibleLines="1-10" expandButtonLabel="Show Imports" +import { + createWorkflow, + WorkflowResponse, + when, +} from "@medusajs/framework/workflows-sdk" +import { setStepSuccessStep } from "../steps/set-step-success" +import { setStepFailedStep } from "../steps/set-step-failed" +import { updateDeliveryStep } from "../steps/update-delivery" +import { UpdateDelivery } from "../../../modules/delivery/types" + +export type WorkflowInput = { + data: UpdateDelivery; + stepIdToSucceed?: string; + stepIdToFail?: string; +}; + +export const updateDeliveryWorkflow = createWorkflow( + "update-delivery-workflow", + function (input: WorkflowInput) { + // Update the delivery with the provided data + const updatedDelivery = updateDeliveryStep({ + data: input.data, + }) + + // If a stepIdToSucceed is provided, we will set that step as successful + when(input, ({ stepIdToSucceed }) => stepIdToSucceed !== undefined) + .then(() => { + setStepSuccessStep({ + stepId: input.stepIdToSucceed, + updatedDelivery, + }) + }) + + // If a stepIdToFail is provided, we will set that step as failed + when(input, ({ stepIdToFail }) => stepIdToFail !== undefined) + .then(() => { + setStepFailedStep({ + stepId: input.stepIdToFail, + updatedDelivery, + }) + }) + + // Return the updated delivery + return new WorkflowResponse(updatedDelivery) + } +) +``` + +In this workflow, you: + +1. Use the `updateDeliveryStep` to update the workflow with the provided data. +2. If `stepIdToSucceed` is provided in the input, you use the `setStepSuccessStep` to set the status of the step to successful. +3. If `stepIdToFail` is provided in the input, you use the `setStepFailedStep` to set the status of the step to failed. + +### Create Accept Route + +You’ll now use the workflow in the API route that allows restaurant admins to accept an order delivery. + +Create the file `src/api/deliveries/[id]/accept/route.ts` with the following content: + +```ts title="src/api/deliveries/[id]/accept/route.ts" highlights={acceptRouteHighlights} collapsibleLines="1-6" expandButtonLabel="Show Imports" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { MedusaError } from "@medusajs/framework/utils" +import { DeliveryStatus } from "../../../../modules/delivery/types" +import { notifyRestaurantStepId } from "../../../../workflows/delivery/steps/notify-restaurant" +import { updateDeliveryWorkflow } from "../../../../workflows/delivery/workflows/update-delivery" + +const DEFAULT_PROCESSING_TIME = 30 * 60 * 1000 // 30 minutes + +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const { id } = req.params + + const eta = new Date(new Date().getTime() + DEFAULT_PROCESSING_TIME) + + const data = { + id, + delivery_status: DeliveryStatus.RESTAURANT_ACCEPTED, + eta, + } + + const updatedDeliveryResult = await updateDeliveryWorkflow(req.scope) + .run({ + input: { + data, + stepIdToSucceed: notifyRestaurantStepId, + }, + }) + .catch((error) => { + console.log(error) + return MedusaError.Types.UNEXPECTED_STATE + }) + + if (typeof updatedDeliveryResult === "string") { + throw new MedusaError(updatedDeliveryResult, "An error occurred") + } + + return res.status(200).json({ delivery: updatedDeliveryResult.result }) +} + +``` + +This creates a `POST` API route at `/deliveries/[id]/accept`. + +In this route, you calculate an estimated time of arrival (ETA), which is 30 minutes after the current time. You then update the delivery’s `eta` and `status` properties using the `updateDeliveryWorkflow`. + +Along with the delivery’s update details, you set the `stepIdToSucceed`'s value to `notifyRestaurantStepId`. This indicates that the `notifyRestaurantStep` should be marked as successful, and the `handleDeliveryWorkflow` workflow execution should move to the next step. + +The API route returns the updated delivery. + +### Add Middlewares + +The above API route should only be accessed by the admin of the restaurant associated with the delivery. So, you must add a middleware that applies an authentication guard on the route. + +Start by creating the file `src/api/utils/is-delivery-restaurant.ts` with the following content: + +```ts title="src/api/utils/is-delivery-restaurant.ts" highlights={isDeliveryRestaurantHighlights} collapsibleLines="1-11" expandButtonLabel="Show Imports" +import { + AuthenticatedMedusaRequest, + MedusaNextFunction, + MedusaResponse, +} from "@medusajs/framework/http" +import { + ContainerRegistrationKeys, +} from "@medusajs/framework/utils" +import { RESTAURANT_MODULE } from "../../modules/restaurant" +import RestaurantModuleService from "../../modules/restaurant/service" + +export const isDeliveryRestaurant = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse, + next: MedusaNextFunction +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const restaurantModuleService: RestaurantModuleService = req.scope.resolve( + RESTAURANT_MODULE + ) + + const restaurantAdmin = await restaurantModuleService.retrieveRestaurantAdmin( + req.auth_context.actor_id, + { + relations: ["restaurant"], + } + ) + + const { data: [delivery] } = await query.graph({ + entity: "delivery", + fields: [ + "restaurant.*", + ], + filters: { + id: req.params.id, + }, + }) + + if (delivery.restaurant.id !== restaurantAdmin.restaurant.id) { + return res.status(403).json({ + message: "unauthorized", + }) + } + + next() +} +``` + +You define a middleware function that retrieves the currently logged-in restaurant admin and their associated restaurant, and the delivery (whose ID is a path parameter) and its linked restaurant. + +The middleware returns an unauthorized response if the delivery’s restaurant isn’t the same as the admin’s restaurant. + +Then, create the file `src/api/deliveries/[id]/middlewares.ts` with the following content: + +```ts title="src/api/deliveries/[id]/middlewares.ts" +import { + authenticate, + defineMiddlewares, +} from "@medusajs/framework/http" +import { isDeliveryRestaurant } from "../../utils/is-delivery-restaurant" + +export default defineMiddlewares({ + routes: [ + { + matcher: "/deliveries/:id/accept", + middlewares: [ + authenticate("restaurant", "bearer"), + isDeliveryRestaurant, + ], + }, + ], +}) +``` + +This applies two middlewares on the `/deliveries/[id]/accept` API route: + +1. The `authenticate` middleware to ensure that only restaurant admins can access this API route. +2. The `isDeliveryRestaurant` middleware that you created above to ensure that only admins of the restaurant associated with the delivery can access the route. + +Finally, import and use these middlewares in the main `src/api/middlewares.ts` file: + +```ts title="src/api/middlewares.ts" +// other imports... +import deliveriesMiddlewares from "./deliveries/[id]/middlewares" + +export default defineMiddlewares({ + routes: [ + // ... + ...deliveriesMiddlewares.routes, + ], +}) +``` + +### Test it Out + +To test the API route out, send a `POST` request to `/deliveries/[id]/accept` as an authenticated restaurant admin: + +```bash +curl -X POST 'http://localhost:9000/deliveries/01J67MSXQE59KRBA3C7CJSQM0A/accept' \ +-H 'Authorization: Bearer {token}' +``` + +Make sure to replace the delivery ID in the path and pass the restaurant admin’s authenticated token in the header. + +This request returns the updated delivery. If you also check the Medusa application’s logs, you’ll find the following message: + +``` +Awaiting driver to claim... +``` + +Meaning that the `handleDeliveryWorkflow`'s execution has moved to the `awaitDriverClaimStep`. + +*** + +## Step 15: Claim Delivery API Route + +In this step, you’ll add the API route that allows a driver to claim a delivery. + +### Create Workflow + +You’ll implement the functionality of claiming a delivery in a workflow that has two steps: + +1. `updateDeliveryStep` that updates the delivery’s status to `pickup_claimed` and sets the driver of the delivery. +2. `setStepSuccessStep` that sets the status of the `awaitDriverClaimStep` to successful, moving the `handleDeliveryWorkflow`'s execution to the next step. + +So, create the workflow in the file `src/workflows/delivery/workflows/claim-delivery.ts` with the following content: + +```ts title="src/workflows/delivery/workflows/claim-delivery.ts" highlights={claimDeliveryWorkflowHighlights} collapsibleLines="1-9" expandButtonLabel="Show Imports" +import { + createWorkflow, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { DeliveryStatus } from "../../../modules/delivery/types" +import { awaitDriverClaimStepId } from "../steps/await-driver-claim" +import { setStepSuccessStep } from "../steps/set-step-success" +import { updateDeliveryStep } from "../steps/update-delivery" + +export type ClaimWorkflowInput = { + driver_id: string; + delivery_id: string; +}; + +export const claimDeliveryWorkflow = createWorkflow( + "claim-delivery-workflow", + function (input: ClaimWorkflowInput) { + // Update the delivery with the provided data + const claimedDelivery = updateDeliveryStep({ + data: { + id: input.delivery_id, + driver_id: input.driver_id, + delivery_status: DeliveryStatus.PICKUP_CLAIMED, + }, + }) + + // Set the step success for the find driver step + setStepSuccessStep({ + stepId: awaitDriverClaimStepId, + updatedDelivery: claimedDelivery, + }) + + // Return the updated delivery + return new WorkflowResponse(claimedDelivery) + } +) +``` + +In the workflow, you execute the steps as mentioned above and return the updated delivery. + +### Create Claim Route + +To create the API route to claim the delivery, create the file `src/api/deliveries/[id]/claim/route.ts` with the following content: + +```ts title="src/api/deliveries/[id]/claim/route.ts" collapsibleLines="1-9" expandButtonLabel="Show Imports" +import { + AuthenticatedMedusaRequest, + MedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { + claimDeliveryWorkflow, +} from "../../../../workflows/delivery/workflows/claim-delivery" + +export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse) { + const deliveryId = req.params.id + + const claimedDelivery = await claimDeliveryWorkflow(req.scope).run({ + input: { + driver_id: req.auth_context.actor_id, + delivery_id: deliveryId, + }, + }) + + return res.status(200).json({ delivery: claimedDelivery }) +} +``` + +The creates a `POST` API route at `/deliveries/[id]/claim`. In the route, you execute the workflow and return the updated delivery. + +### Add Middleware + +The above API route should only be accessed by drivers. So, add the following middleware to `src/api/deliveries/[id]/middlewares.ts`: + +```ts title="src/api/deliveries/[id]/middlewares.ts" +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/deliveries/:id/claim", + middlewares: [ + authenticate("driver", "bearer"), + ], + }, + ], +}) +``` + +This middleware ensures only drivers can access the API route. + +### Test it Out + +To test it out, first, get the authentication token of a registered driver user: + +```bash +curl --location 'http://localhost:9000/auth/driver/emailpass' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "email": "driver@gmail.com", + "password": "supersecret" +}' +``` + +Then, send a `POST` request to `/deliveries/[id]/claim`: + +```bash +curl -X POST 'http://localhost:9000/deliveries/01J67MSXQE59KRBA3C7CJSQM0A/claim' \ +-H 'Authorization: Bearer {token}' +``` + +Make sure to replace the delivery’s ID in the path parameter and set the driver’s authentication token in the header. + +The request returns the updated delivery. If you check the Medusa application’s logs, you’ll find the following message: + +``` +Awaiting start of preparation... +``` + +This indicates that the `handleDeliveryWorkflow`'s execution continued past the `awaitDriverClaimStep` until it reached the next async step, which is `awaitStartPreparationStep`. + +*** + +## Step 16: Prepare API Route + +In this step, you’ll add the API route that restaurants use to indicate they’re preparing the order. + +Create the file `src/api/deliveries/[id]/prepare/route.ts` with the following content: + +```ts title="src/api/deliveries/[id]/prepare/route.ts" highlights={prepareRouteHighlights} collapsibleLines="1-10" expandButtonLabel="Show Imports" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { MedusaError } from "@medusajs/framework/utils" +import { DeliveryStatus } from "../../../../modules/delivery/types" +import { + updateDeliveryWorkflow, +} from "../../../../workflows/delivery/workflows/update-delivery" +import { + awaitStartPreparationStepId, +} from "../../../../workflows/delivery/steps/await-start-preparation" + +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const { id } = req.params + + const data = { + id, + delivery_status: DeliveryStatus.RESTAURANT_PREPARING, + } + + const updatedDelivery = await updateDeliveryWorkflow(req.scope) + .run({ + input: { + data, + stepIdToSucceed: awaitStartPreparationStepId, + }, + }) + .catch((error) => { + return MedusaError.Types.UNEXPECTED_STATE + }) + + return res.status(200).json({ delivery: updatedDelivery }) +} +``` + +This creates a `POST` API route at `/deliveries/[id]/prepare`. In this API route, you use the `updateDeliveryWorkflow` to update the delivery’s status to `restaurant_preparing`, and set the status of the `awaitStartPreparationStep` to successful, moving the `handleDeliveryWorkflow`'s execution to the next step. + +### Add Middleware + +Since this API route should only be accessed by the admin of a restaurant associated with the delivery, add the following middleware to `src/api/deliveries/[id]/middlewares.ts`: + +```ts title="src/api/deliveries/[id]/middlewares.ts" +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/deliveries/:id/prepare", + middlewares: [ + authenticate("restaurant", "bearer"), + isDeliveryRestaurant, + ], + }, + ], +}) +``` + +### Test it Out + +Send a `POST` request to `/deliveries/[id]/prepare` as an authenticated restaurant admin: + +```bash +curl -X POST 'http://localhost:9000/deliveries/01J67MSXQE59KRBA3C7CJSQM0A/prepare' \ +-H 'Authorization: Bearer {token}' +``` + +Make sure to replace the delivery’s ID in the path parameter and use the restaurant admin’s authentication token in the header. + +The request returns the updated delivery. If you check the Medusa application’s logs, you’ll find the following message: + +``` +Awaiting preparation... +``` + +This message indicates that the `handleDeliveryWorkflow`'s execution has moved to the next step, which is `awaitPreparationStep`. + +*** + +## Step 17: Ready API Route + +In this step, you’ll create the API route that restaurants use to indicate that a delivery is ready for pick up. + +Create the file `src/api/deliveries/[id]/ready/route.ts` with the following content: + +```ts title="src/api/deliveries/[id]/ready/route.ts" highlights={readyRouteHighlights} collapsibleLines="1-10" expandButtonLabel="Show Imports" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { MedusaError } from "@medusajs/framework/utils" +import { DeliveryStatus } from "../../../../modules/delivery/types" +import { + updateDeliveryWorkflow, +} from "../../../../workflows/delivery/workflows/update-delivery" +import { + awaitPreparationStepId, +} from "../../../../workflows/delivery/steps/await-preparation" + +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const { id } = req.params + + const data = { + id, + delivery_status: DeliveryStatus.READY_FOR_PICKUP, + } + + const updatedDelivery = await updateDeliveryWorkflow(req.scope) + .run({ + input: { + data, + stepIdToSucceed: awaitPreparationStepId, + }, + }) + .catch((error) => { + console.log(error) + return MedusaError.Types.UNEXPECTED_STATE + }) + + return res.status(200).json({ delivery: updatedDelivery }) +} + +``` + +This creates a `POST` API route at `/deliveries/[id]/ready`. In the route, you use the `updateDeliveryWorkflow` to update the delivery’s status to `ready_for_pickup` and sets the `awaitPreparationStep`'s status to successful, moving the `handleDeliveryWorkflow`'s execution to the next step. + +### Add Middleware + +The above API route should only be accessed by restaurant admins. So, add the following middleware to `src/api/deliveries/[id]/middlewares.ts`: + +```ts title="src/api/deliveries/[id]/middlewares.ts" +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/deliveries/:id/ready", + middlewares: [ + authenticate("restaurant", "bearer"), + isDeliveryRestaurant, + ], + }, + ], +}) +``` + +### Test it Out + +Send a `POST` request to `/deliveries/[id]/ready` as an authenticated restaurant admin: + +```bash +curl -X POST 'http://localhost:9000/deliveries/01J67MSXQE59KRBA3C7CJSQM0A/ready' \ +-H 'Authorization: Bearer {token}' +``` + +Make sure to replace the delivery’s ID in the path parameter and use the restaurant admin’s authentication token in the header. + +The request returns the updated delivery. If you check the Medusa application’s logs, you’ll find the following message: + +``` +Awaiting pick up by driver... +``` + +This message indicates that the `handleDeliveryWorkflow`'s execution has moved to the next step, which is `awaitPickUpStep`. + +*** + +## Step 18: Pick Up Delivery API Route + +In this step, you’ll add the API route that the driver uses to indicate they’ve picked up the delivery. + +Create the file `src/api/deliveries/[id]/pick-up/route.ts` with the following content: + +```ts title="src/api/deliveries/[id]/pick-up/route.ts" highlights={pickUpRouteHighlights} collapsibleLines="1-10" expandButtonLabel="Show Imports" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { MedusaError } from "@medusajs/framework/utils" +import { DeliveryStatus } from "../../../../modules/delivery/types" +import { + updateDeliveryWorkflow, +} from "../../../../workflows/delivery/workflows/update-delivery" +import { + awaitPickUpStepId, +} from "../../../../workflows/delivery/steps/await-pick-up" + +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const { id } = req.params + + const data = { + id, + delivery_status: DeliveryStatus.IN_TRANSIT, + } + + const updatedDelivery = await updateDeliveryWorkflow(req.scope) + .run({ + input: { + data, + stepIdToSucceed: awaitPickUpStepId, + }, + }) + .catch((error) => { + return MedusaError.Types.UNEXPECTED_STATE + }) + + return res.status(200).json({ delivery: updatedDelivery }) +} +``` + +This creates a `POST` API route at `/deliveries/[id]/pick-up`. In this route, you update the delivery’s status to `in_transit` and set the status of the `awaitPickUpStep` to successful, moving the `handleDeliveryWorkflow`'s execution to the next step. + +### Add Middleware + +The above route should only be accessed by the driver associated with the delivery. + +So, create the file `src/api/utils/is-delivery-driver.ts` holding the middleware function that performs the check: + +```ts title="src/api/utils/is-delivery-driver.ts" highlights={isDeliveryDriverHighlights} collapsibleLines="1-8" expandButtonLabel="Show Imports" +import { + AuthenticatedMedusaRequest, + MedusaNextFunction, + MedusaResponse, +} from "@medusajs/framework/http" +import { DELIVERY_MODULE } from "../../modules/delivery" +import DeliveryModuleService from "../../modules/delivery/service" + +export const isDeliveryDriver = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse, + next: MedusaNextFunction +) => { + const deliveryModuleService: DeliveryModuleService = req.scope.resolve( + DELIVERY_MODULE + ) + + const delivery = await deliveryModuleService.retrieveDelivery( + req.params.id, + { + relations: ["driver"], + } + ) + + if (delivery.driver.id !== req.auth_context.actor_id) { + return res.status(403).json({ + message: "unauthorized", + }) + } + + next() +} +``` + +In this middleware function, you check that the driver is associated with the delivery. If not, you return an unauthorized response. + +Then, import and use the middleware function in `src/api/deliveries/[id]/middlewares.ts`: + +```ts title="src/api/deliveries/[id]/middlewares.ts" +// other imports... +import { isDeliveryDriver } from "../../utils/is-delivery-driver" + +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/deliveries/:id/pick-up", + middlewares: [ + authenticate("driver", "bearer"), + isDeliveryDriver, + ], + }, + ], +}) +``` + +This applies the `authenticate` middleware on the `/deliveries/[id]/pick-up` route to ensure only drivers access it, and the `isDeliveryDriver` middleware to ensure only the driver associated with the delivery can access the route. + +### Test it Out + +Send a `POST` request to `/deliveries/[id]/pick-up` as the authenticated driver that claimed the delivery: + +```bash +curl -X POST 'http://localhost:9000/deliveries/01J67MSXQE59KRBA3C7CJSQM0A/pick-up' \ +-H 'Authorization: Bearer {token}' +``` + +Make sure to replace the delivery’s ID in the path parameter and use the driver’s authentication token in the header. + +The request returns the updated delivery. If you check the Medusa application’s logs, you’ll find the following message: + +``` +Awaiting delivery by driver... +``` + +This message indicates that the `handleDeliveryWorkflow`'s execution has moved to the next step, which is `awaitDeliveryStep`. + +*** + +## Step 19: Complete Delivery API Route + +In this step, you’ll create the API route that the driver uses to indicate that they completed the delivery. + +Create the file `src/api/deliveries/[id]/complete/route.ts` with the following content: + +```ts title="src/api/deliveries/[id]/complete/route.ts" highlights={completeRouteHighlights} collapsibleLines="1-10" expandButtonLabel="Show Imports" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { MedusaError } from "@medusajs/framework/utils" +import { DeliveryStatus } from "../../../../modules/delivery/types" +import { + updateDeliveryWorkflow, +} from "../../../../workflows/delivery/workflows/update-delivery" +import { + awaitDeliveryStepId, +} from "../../../../workflows/delivery/steps/await-delivery" + +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const { id } = req.params + + const data = { + id, + delivery_status: DeliveryStatus.DELIVERED, + delivered_at: new Date(), + } + + const updatedDelivery = await updateDeliveryWorkflow(req.scope) + .run({ + input: { + data, + stepIdToSucceed: awaitDeliveryStepId, + }, + }) + .catch((error) => { + return MedusaError.Types.UNEXPECTED_STATE + }) + + return res.status(200).json({ delivery: updatedDelivery }) +} + +``` + +This adds a `POST` API route at `/deliveries/[id]/complete`. In the API route, you update the delivery’s status to `delivered` and set the status of the `awaitDeliveryStep` to successful, moving the `handleDeliveryWorkflow`'s execution to the next step. + +### Add Middleware + +The above middleware should only be accessed by the driver associated with the delivery. + +So, add the following middlewares to `src/api/deliveries/[id]/middlewares.ts`: + +```ts title="src/api/deliveries/[id]/middlewares.ts" +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/deliveries/:id/complete", + middlewares: [ + authenticate("driver", "bearer"), + isDeliveryDriver, + ], + }, + ], +}) +``` + +### Test it Out + +Send a `POST` request to `/deliveries/[id]/complete` as the authenticated driver that claimed the delivery: + +```bash +curl -X POST 'http://localhost:9000/deliveries/01J67MSXQE59KRBA3C7CJSQM0A/complete' \ +-H 'Authorization: Bearer {token}' +``` + +Make sure to replace the delivery’s ID in the path parameter and use the driver’s authentication token in the header. + +The request returns the updated delivery. + +As the route sets the status of the `awaitDeliveryStep` to successful in the `handleDeliveryWorkflow`'s execution, this finishes the workflow’s execution. + +*** + +## Step 20: Real-Time Tracking in the Storefront + +In this step, you’ll learn how to implement real-time tracking of a delivery in a Next.js-based storefront. + +You can use a custom Next.js project, or use Medusa's Next.js Starter storefront. If you haven't installed the Next.js Starter storefront in the first step, refer to [this documentation](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter#approach-2-install-separately/index.html.md) to learn how to install it. + +### Subscribe Route + +Before adding the storefront UI, you need an API route that allows a client to stream delivery changes. + +So, create the file `src/api/deliveries/[id]/subscribe/route.ts` with the following content: + +```ts title="src/api/deliveries/[id]/subscribe/route.ts" collapsibleLines="1-13" expandButtonLabel="Show Imports" +import { + MedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { + Modules, +} from "@medusajs/framework/utils" +import { + handleDeliveryWorkflowId, +} from "../../../../../workflows/delivery/workflows/handle-delivery" +import { DELIVERY_MODULE } from "../../../../../modules/delivery" +import DeliveryModuleService from "../../../../modules/delivery/service" + +export const GET = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const deliveryModuleService: DeliveryModuleService = req.scope.resolve( + DELIVERY_MODULE + ) + + const { id } = req.params + + const delivery = await deliveryModuleService.retrieveDelivery(id) + + // TODO stream changes +} +``` + +This creates a `GET` API route at `/deliveries/[id]/subscribe`. Currently, you only retrieve the delivery by its ID. + +Next, you’ll stream the changes in the delivery. To do that, replace the `TODO` with the following: + +```ts title="src/api/deliveries/subscribe/route.ts" +const headers = { + "Content-Type": "text/event-stream", + Connection: "keep-alive", + "Cache-Control": "no-cache", +} + +res.writeHead(200, headers) + +// TODO listen to workflow changes + +res.write( + "data: " + + JSON.stringify({ + message: "Subscribed to workflow", + transactionId: delivery.transaction_id, + }) + + "\n\n" +) +``` + +In the above snippet, you set the response to a stream and write an initial message saying that the client is now subscribed to the workflow. + +To listen to the workflow changes, replace the new `TODO` with the following: + +```ts title="src/api/deliveries/subscribe/route.ts" +const workflowEngine = req.scope.resolve( + Modules.WORKFLOW_ENGINE +) + +const workflowSubHandler = (data: any) => { + res.write("data: " + JSON.stringify(data) + "\n\n") +} + +await workflowEngine.subscribe({ + workflowId: handleDeliveryWorkflowId, + transactionId: delivery.transaction_id, + subscriber: workflowSubHandler, +}) +``` + +In this snippet, you resolve the Workflow Engine Module’s main service. Then, you use the `subscribe` method of the service to subscribe to the `handleDeliveryWorkflow` ’s execution. You indicate the execution using the transaction ID stored in the delivery. + +### Retrieve Delivery API Route + +The storefront UI will also need to retrieve the delivery. So, you’ll create an API route that retrieves the delivery’s details. + +Create the file `src/api/deliveries/[id]/route.ts` with the following content: + +```ts title="src/api/deliveries/[id]/route.ts" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { DELIVERY_MODULE } from "../../../../modules/delivery" +import DeliveryModuleService from "../../../../modules/delivery/service" + +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const deliveryModuleService: DeliveryModuleService = + req.scope.resolve(DELIVERY_MODULE) + + const delivery = await deliveryModuleService.retrieveDelivery( + req.params.id + ) + + res.json({ + delivery, + }) +} +``` + +### Storefront Tracking Page + +To implement real-time tracking in a Next.js-based storefront, create the following page: + +```tsx title="Storefront Page" +"use client" + +import { useRouter } from "next/navigation" +import { useEffect, useState, useTransition } from "react" + +type Props = { + params: { id: string } +} + +export default function Delivery({ params: { id } }: Props) { + const [delivery, setDelivery] = useState< + Record | undefined + >() + const router = useRouter() + const [isPending, startTransition] = useTransition() + + useEffect(() => { + // TODO retrieve the delivery + }, [id]) + + useEffect(() => { + // TODO subscribe to the delivery updates + }, []) + + return ( +
    + {isPending && Syncing....} + {!isPending && delivery && ( + Delivery status: {delivery.delivery_status} + )} +
    + ) +} +``` + +In this page, you create a `delivery` state variable that you’ll store the delivery in. You also use [React’s useTransition](https://react.dev/reference/react/useTransition) hook to, later, refresh the page when there are changes in the delivery. + +To retrieve the delivery from the Medusa application, replace the first `TODO` with the following: + +```tsx title="Storefront Page" +// retrieve the delivery +fetch(`http://localhost:9000/store/deliveries/${id}`, { + credentials: "include", + headers: { + "x-publishable-api-key": process.env.NEXT_PUBLIC_PAK, + }, +}) +.then((res) => res.json()) +.then((data) => { + setDelivery(data.delivery) +}) +.catch((e) => console.error(e)) +``` + +This sends a `GET` request to `/store/deliveries/[id]` to retrieve and set the delivery’s details. + +Next, to subscribe to the delivery’s changes in real-time, replace the remaining `TODO` with the following: + +```tsx title="Storefront Page" +// subscribe to the delivery updates +const source = new EventSource( + `http://localhost:9000/deliveries/${id}/subscribe` +) + +source.onmessage = (message) => { + const data = JSON.parse(message.data) as { + response?: Record + } + + if (data.response && "delivery_status" in data.response) { + setDelivery(data.response as Record) + } + + startTransition(() => { + router.refresh() + }) +} + +return () => { + source.close() +} +``` + +You use the [EventSource API](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) to receive the stream from the `/deliveries/[id]/subscribe` API route. + +When a new message is set, the new delivery update is extracted from `message.data.response`, if `response` is available and has a `delivery_status` property. + +### Test it Out + +To test it out, create a delivery order as mentioned in this section. Then, open the page in your storefront. + +As you change the delivery’s status using API routes such as `accept` and `claim`, the delivery’s status is updated in the storefront page as well. + +*** + +## Next Steps + +The next steps of this example depend on your use case. This section provides some insight into implementing them. + +### Admin Development + +The Medusa Admin is extendable, allowing you to add widgets to existing pages or create new pages. Learn more about it in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/admin/index.html.md). + +### Storefront Development + +Medusa provides a Next.js Starter storefront that you can customize to your use case. + +You can also create a custom storefront. Check out the [Storefront Development](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/storefront-development/index.html.md) section to learn how to create a storefront. + + +# Marketplace Recipe: Vendors Example + +In this guide, you'll learn how to build a marketplace with Medusa. + +When you install a Medusa application, you get a fully-fledged commerce platform with support for customizations. While Medusa doesn't provide marketplace functionalities natively, it provides features that you can extend and a Framework to support all your customization needs to build a marketplace. + +## Summary + +In this guide, you'll customize Medusa to build a marketplace with the following features: + +1. Manage multiple vendors, each having vendor admins. +2. Allow vendor admins to manage the vendor’s products and orders. +3. Split orders placed by customers into multiple orders for each vendor. + +You can follow this guide whether you're new to Medusa or an advanced Medusa developer. + +This guide provides an example of an approach to implement marketplaces. You're free to choose a different approach using the Medusa Framework. + +- [Marketplace Example Repository](https://github.com/medusajs/examples/tree/main/marketplace): Find the full code for this recipe in this repository. +- [OpenApi Specs for Postman](https://res.cloudinary.com/dza7lstvk/raw/upload/v1720603521/OpenApi/Marketplace_OpenApi_n458oh.yml): Import this OpenApi Specs file into tools like Postman. + +*** + +## Step 1: Install a Medusa Application + +### Prerequisites + +- [Node.js v20+](https://nodejs.org/en/download) +- [Git CLI tool](https://git-scm.com/downloads) +- [PostgreSQL](https://www.postgresql.org/download/) + +Start by installing the Medusa application on your machine with the following command: + +```bash +npx create-medusa-app@latest +``` + +You'll first be asked for the project's name. You can also optionally choose to install the [Next.js Starter Storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md). + +Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name. If you chose to install the Next.js starter, it'll be installed in a separate directory with the `{project-name}-storefront` name. + +The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). Learn more about Medusa's architecture in [this documentation](https://docs.medusajs.com/docs/learn/introduction/architecture/index.html.md). + +Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credential and submit the form. Afterwards, you can login with the new user and explore the dashboard. + +Check out the [troubleshooting guides](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/troubleshooting/create-medusa-app-errors/index.html.md) for help. + +*** + +## Step 2: Create Marketplace Module + +To add custom tables to the database, which are called data models, you create a [module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md). A module is a re-usable package with functionalities related to a single feature or domain. Medusa integrates the module into your application without implications or side effects on your setup. + +In this step, you'll create a Marketplace Module that holds the data models for a vendor and an admin and allows you to manage them. + +Learn more about modules in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md). + +### Create Module Directory + +A module is created under the `src/modules` directory of your Medusa application. So, create the directory `src/modules/marketplace`. + +### Create Data Models + +A data model represents a table in the database. You create data models using Medusa's Data Model Language (DML). It simplifies defining a table's columns, relations, and indexes with straightforward methods and configurations. + +Learn more about data models in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules#1-create-data-model/index.html.md). + +In the Marketplace Module, you'll create two data models: + +- `Vendor`: Represents a business that sells its products in the marketplace. +- `VendorAdmin`: Represents an admin of a vendor. + +You create a data model in a TypeScript or JavaScript file under the `models` directory of a module. So, to create the `Vendor` data model, create the file `src/modules/marketplace/models/vendor.ts` with the following content: + +```ts title="src/modules/marketplace/models/vendor.ts" +import { model } from "@medusajs/framework/utils" +import VendorAdmin from "./vendor-admin" + +const Vendor = model.define("vendor", { + id: model.id().primaryKey(), + handle: model.text().unique(), + name: model.text(), + logo: model.text().nullable(), + admins: model.hasMany(() => VendorAdmin), +}) + +export default Vendor +``` + +You define the data model using DML's `define` method. It accepts two parameters: + +1. The first one is the name of the data model's table in the database. +2. The second is an object, which is the data model's schema. The schema's properties are defined using DML methods. + +You define the following properties for the `Vendor` data model: + +- `id`: A primary key ID for each record. +- `handle`: A unique handle for the vendor. This can be used in URLs on the storefront, such as to show a vendor's details and products. +- `name`: The name of the vendor. +- `logo`: The logo image of a vendor. +- `admins`: The admins of a vendor. It's a relation to the `VendorAdmin` data model which you'll create next. + +Learn more about data model [properties](https://docs.medusajs.com/docs/learn/fundamentals/data-models/properties/index.html.md) and [relations](https://docs.medusajs.com/docs/learn/fundamentals/data-models/relationships/index.html.md). + +Then, to create the `VendorAdmin` data model, create the file `src/modules/marketplace/models/vendor-admin.ts` with the following content: + +```ts title="src/modules/marketplace/models/vendor-admin.ts" +import { model } from "@medusajs/framework/utils" +import Vendor from "./vendor" + +const VendorAdmin = model.define("vendor_admin", { + id: model.id().primaryKey(), + first_name: model.text().nullable(), + last_name: model.text().nullable(), + email: model.text().unique(), + vendor: model.belongsTo(() => Vendor, { + mappedBy: "admins", + }), +}) + +export default VendorAdmin +``` + +The `VendorAdmin` data model has the following properties: + +- `id`: A primary key ID for each record. +- `first_name`: The first name of the admin. +- `last_name`: The last name of the admin. +- `email`: The email of the admin. +- `vendor`: The vendor the admin belongs to. It's a relation to the `Vendor` data model. + +### Create Service + +You define data-management methods of your data models in a service. A service is a TypeScript or JavaScript class that the module exports. In the service's methods, you can perform database operations. + +Learn more about services in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules#2-create-service/index.html.md). + +In this section, you'll create the Marketplace Module's service. Create the file `src/modules/marketplace/service.ts` with the following content: + +```ts title="src/modules/marketplace/service.ts" +import { MedusaService } from "@medusajs/framework/utils" +import Vendor from "./models/vendor" +import VendorAdmin from "./models/vendor-admin" + +class MarketplaceModuleService extends MedusaService({ + Vendor, + VendorAdmin, +}) { } + +export default MarketplaceModuleService +``` + +The `MarketplaceModuleService` extends `MedusaService` from the Modules SDK which generates a class with data-management methods for your module's data models. This saves you time on implementing Create, Read, Update, and Delete (CRUD) methods. + +So, the `MarketplaceModuleService` class now has methods like `createVendors` and `retrieveVendorAdmin`. + +Find all methods generated by the `MedusaService` in [this reference](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/service-factory-reference/index.html.md). + +You'll use this service in later steps to store and manage vendors and vendor admins. + +### Export Module Definition + +The final piece to a module is its definition, which you export in an `index.ts` file at its root directory. This definition tells Medusa the name of the module and its service. + +So, create the file `src/modules/marketplace/index.ts` with the following content: + +```ts title="src/modules/marketplace/index.ts" +import { Module } from "@medusajs/framework/utils" +import MarketplaceModuleService from "./service" + +export const MARKETPLACE_MODULE = "marketplace" + +export default Module(MARKETPLACE_MODULE, { + service: MarketplaceModuleService, +}) +``` + +You use the `Module` function from the Modules SDK to create the module's definition. It accepts two parameters: + +1. The module's name, which is `marketplace`. +2. An object with a required property `service` indicating the module's service. + +### Add Module to Medusa's Configurations + +Once you finish building the module, add it to Medusa's configurations to start using it. + +In `medusa-config.ts`, add a `modules` property and pass an array with your custom module: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // ... + modules: [ + { + resolve: "./src/modules/marketplace", + }, + ], +}) +``` + +Each object in the `modules` array has a `resolve` property, whose value is either a path to the module's directory, or an `npm` package’s name. + +### Generate Migrations + +Since data models represent tables in the database, you define how they're created in the database with migrations. A migration is a TypeScript or JavaScript file that defines database changes made by a module. + +Learn more about migrations in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules#5-generate-migrations/index.html.md). + +Medusa's CLI tool generates the migrations for you. To generate a migration for the Marketplace Module, run the following command in your Medusa application's directory: + +```bash +npx medusa db:generate marketplace +``` + +The `db:generate` command of the Medusa CLI accepts the name of the module to generate the migration for. You'll now have a `migrations` directory under `src/modules/marketplace` that holds the generated migration. + +Then, to reflect the migration and links in the database, run the following command: + +```bash +npx medusa db:migrate +``` + +This will create the tables for the Marketplace Module's data models in the database. + +### Further Reads + +- [How to Create a Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md) + +*** + +## Step 3: Define Links to Product and Order Data Models + +Modules are [isolated](https://docs.medusajs.com/docs/learn/fundamentals/modules/isolation/index.html.md) to ensure they're re-usable and don't have side effects when integrated into the Medusa application. So, to build associations between modules, you define [module links](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md). A Module link associates two modules' data models while maintaining module isolation. + +Learn more about module links in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md). + +Each vendor should have products and orders. So, in this step, you’ll define links between the `Vendor` data model and the `Product` and `Order` data models from the Product and Order modules, respectively. + +If your use case requires linking the vendor to other data models, such as `SalesChannel` from the [Sales Channel Module](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/sales-channel/index.html.md), define those links in a similar manner. + +To define a link between the `Vendor` and `Product` data models, create the file `src/links/vendor-product.ts` with the following content: + +```ts title="src/links/vendor-product.ts" +import { defineLink } from "@medusajs/framework/utils" +import MarketplaceModule from "../modules/marketplace" +import ProductModule from "@medusajs/medusa/product" + +export default defineLink( + MarketplaceModule.linkable.vendor, + { + linkable: ProductModule.linkable.product.id, + isList: true, + } +) +``` + +You define a link using `defineLink` from the Modules SDK. It accepts two parameters: + +1. The first data model part of the link, which is the Marketplace Module's `vendor` data model. A module has a special `linkable` property that contain link configurations for its data models. +2. The second data model part of the link, which is the Product Module's `product` data model. You also enable `isList`, indicating that a vendor can have many products. + +Next, to define a link between the `Vendor` and `Order` data models, create the file `src/links/vendor-order.ts` with the following content: + +```ts title="src/links/vendor-order.ts" +import { defineLink } from "@medusajs/framework/utils" +import MarketplaceModule from "../modules/marketplace" +import OrderModule from "@medusajs/medusa/order" + +export default defineLink( + MarketplaceModule.linkable.vendor, + { + linkable: OrderModule.linkable.order.id, + isList: true, + } +) +``` + +Similarly, you define an association between the `Vendor` and `Order` data models, where a vendor can have many orders. + +In the next steps, you'll see how these link allows you to retrieve and manage a vendor's products and orders. + +### Sync Links to Database + +Medusa represents the links you define in link tables similar to pivot tables. So, to sync the defined links to the database, run the `db:migrate` command: + +```bash +npx medusa db:migrate +``` + +This command runs any pending migrations and syncs link definitions to the database, creating the necessary tables for your links. + +### Further Read + +- [How to Define Module Links](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md) + +*** + +## Intermission: Understanding Authentication + +Before proceeding further, you need to understand some concepts related to authenticating users, especially those of custom actor types. + +An [actor type](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/auth/auth-identity-and-actor-types#actor-types/index.html.md) is a type of user that can send an authenticated requests. Medusa has two default actor types: `customer` for customers, and `admin` for admin users. + +You can also create custom actor types, allowing you to authenticate your custom users to specific routes. In this recipe, your custom actor type would be the vendor's admin. + +When you create a user of the actor type (for example, a vendor admin), you must: + +1. Retrieve a registration JWT token. Medusa has a `/auth/{actor_type}/emailpass/register` route to retrieve a registration JWT token for the specified actor type. +2. Create the user. This requires creating the user in the database, and associate an [auth identity](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/auth/auth-identity-and-actor-types#what-is-an-auth-identity/index.html.md) with that user. An auth identity allows this user to later send authenticated requests. +3. Retrieve an authenticated JWT token using Medusa's `/auth/{actor_type}/emailpass` route, which retrieves the token for the specified actor type if the credentials in the request body match a user in the database. + +In the next steps, you'll implement the logic to create a vendor and its admin around the above authentication flow. You can also refer to the following documentation pages to learn more about authentication in Medusa: + +- [Auth Identities and Actor Types](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/auth/auth-identity-and-actor-types/index.html.md) +- [Authentication Routes](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/auth/authentication-route/index.html.md) + +*** + +## Step 4: Create Vendor Workflow + +To implement and expose a feature that manipulates data, you create a workflow. + +A workflow is a series of queries and actions, called steps, that complete a task. You construct a workflow like you construct a function, but it's a special function that allows you to track its executions' progress, define roll-back logic, and configure other advanced features. Then, you execute the workflow from other customizations, such as in an endpoint. + +In this step, you’ll create the workflow used to create a vendor and its admin. You'll use it in the next step in an API route. + +Learn more about workflows in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md) + +The workflow’s steps are: + +- [createVendorStep](#createvendorstep): Create the vendor. +- [createVendorAdminStep](#createvendoradminstep): Create the vendor admin. +- [setAuthAppMetadataStep](https://docs.medusajs.com/references/medusa-workflows/steps/setAuthAppMetadataStep/index.html.md): Associate the vendor admin with its auth identity of actor type \`vendor\`. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the created vendor with its admins. + +Medusa provides the last two steps through its `@medusajs/medusa/core-flows` package. So, you only need to implement the first two steps. + +### createVendorStep + +The first step of the workflow creates the vendor in the database using the Marketplace Module's service. + +Create the file `src/workflows/marketplace/create-vendor/steps/create-vendor.ts` with the following content: + +```ts title="src/workflows/marketplace/create-vendor/steps/create-vendor.ts" highlights={createVendorHighlights} +import { + createStep, + StepResponse, +} from "@medusajs/framework/workflows-sdk" +import { MARKETPLACE_MODULE } from "../../../../modules/marketplace" +import MarketplaceModuleService from "../../../../modules/marketplace/service" + +type CreateVendorStepInput = { + name: string + handle?: string + logo?: string +} + +const createVendorStep = createStep( + "create-vendor", + async (vendorData: CreateVendorStepInput, { container }) => { + const marketplaceModuleService: MarketplaceModuleService = + container.resolve(MARKETPLACE_MODULE) + + const vendor = await marketplaceModuleService.createVendors(vendorData) + + return new StepResponse(vendor, vendor.id) + }, + async (vendorId, { container }) => { + if (!vendorId) { + return + } + + const marketplaceModuleService: MarketplaceModuleService = + container.resolve(MARKETPLACE_MODULE) + + marketplaceModuleService.deleteVendors(vendorId) + } +) + +export default createVendorStep +``` + +You create a step with `createStep` from the Workflows SDK. It accepts three parameters: + +1. The step's unique name, which is `create-vendor`. +2. An async function that receives two parameters: + - An input object with the details of the vendor to create. + - The [Medusa container](https://docs.medusajs.com/docs/learn/fundamentals/medusa-container/index.html.md), which is a registry of Framework and commerce tools that you can access in the step. +3. An async compensation function. This function is only executed when an error occurs in the workflow. It undoes the changes made by the step. + +In the step function, you resolve the Marketplace Module's service from the container. Then, you use the service's generated `createVendors` method to create the vendor. + +A step must return an instance of `StepResponse`. It accepts two parameters: + +1. The data to return from the step, which is the created vendor in this case. +2. The data to pass as an input to the compensation function. + +You pass the vendor's ID to the compensation function. In the compensation function, you delete the vendor if an error occurs in the workflow. + +### createVendorAdminStep + +The second step of the workflow creates the vendor's admin. So, create the file `src/workflows/marketplace/create-vendor/steps/create-vendor-admin.ts` with the following content: + +```ts title="src/workflows/marketplace/create-vendor/steps/create-vendor-admin.ts" highlights={createVendorAdminStepHighlights} collapsibleLines="1-7" expandMoreLabel="Show Imports" +import { + createStep, + StepResponse, +} from "@medusajs/framework/workflows-sdk" +import MarketplaceModuleService from "../../../../modules/marketplace/service" +import { MARKETPLACE_MODULE } from "../../../../modules/marketplace" + +type CreateVendorAdminStepInput = { + email: string + first_name?: string + last_name?: string + vendor_id: string +} + +const createVendorAdminStep = createStep( + "create-vendor-admin-step", + async ( + adminData: CreateVendorAdminStepInput, + { container } + ) => { + const marketplaceModuleService: MarketplaceModuleService = + container.resolve(MARKETPLACE_MODULE) + + const vendorAdmin = await marketplaceModuleService.createVendorAdmins( + adminData + ) + + return new StepResponse( + vendorAdmin, + vendorAdmin.id + ) + }, + async (vendorAdminId, { container }) => { + if (!vendorAdminId) { + return + } + + const marketplaceModuleService: MarketplaceModuleService = + container.resolve(MARKETPLACE_MODULE) + + marketplaceModuleService.deleteVendorAdmins(vendorAdminId) + } +) + +export default createVendorAdminStep +``` + +Similar to the previous step, you create a step that accepts the vendor admin's details as an input, and creates the vendor admin using the Marketplace Module. In the compensation function, you delete the vendor admin if an error occurs. + +### Create Workflow + +You can now create the workflow that creates a vendor and its admin. + +Create the file `src/workflows/marketplace/create-vendor/index.ts` with the following content: + +```ts title="src/workflows/marketplace/create-vendor/index.ts" highlights={vendorWorkflowHighlights} +import { + createWorkflow, + WorkflowResponse, + transform, +} from "@medusajs/framework/workflows-sdk" +import { + setAuthAppMetadataStep, + useQueryGraphStep, +} from "@medusajs/medusa/core-flows" +import createVendorAdminStep from "./steps/create-vendor-admin" +import createVendorStep from "./steps/create-vendor" + +export type CreateVendorWorkflowInput = { + name: string + handle?: string + logo?: string + admin: { + email: string + first_name?: string + last_name?: string + } + authIdentityId: string +} + +const createVendorWorkflow = createWorkflow( + "create-vendor", + function (input: CreateVendorWorkflowInput) { + const vendor = createVendorStep({ + name: input.name, + handle: input.handle, + logo: input.logo, + }) + + const vendorAdminData = transform({ + input, + vendor, + }, (data) => { + return { + ...data.input.admin, + vendor_id: data.vendor.id, + } + }) + + const vendorAdmin = createVendorAdminStep( + vendorAdminData + ) + + setAuthAppMetadataStep({ + authIdentityId: input.authIdentityId, + actorType: "vendor", + value: vendorAdmin.id, + }) + // @ts-ignore + const { data: vendorWithAdmin } = useQueryGraphStep({ + entity: "vendor", + fields: ["id", "name", "handle", "logo", "admins.*"], + filters: { + id: vendor.id, + }, + }) + + return new WorkflowResponse({ + vendor: vendorWithAdmin[0], + }) + } +) + +export default createVendorWorkflow +``` + +You create a workflow with `createWorkflow` from the Workflows SDK. It accepts two parameters: + +1. The workflow's unique name, which is `create-vendor`. +2. A function that receives an input object with the details of the vendor and its admin. + +In the workflow function, you run the following steps: + +1. `createVendorStep` to create the vendor. +2. `createVendorAdminStep` to create the vendor admin. + - Notice that you use `transform` from the Workflows SDK to prepare the data you pass into the step. Medusa doesn't allow direct manipulation of variables within the worflow's constructor function. Learn more in the [Data Manipulation in Workflows documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/variable-manipulation/index.html.md). +3. `setAuthAppMetadataStep` to associate the vendor admin with its auth identity of actor type `vendor`. This will allow the vendor admin to send authenticated requests afterwards. +4. `useQueryGraphStep` to retrieve the created vendor with its admins using [Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md). Query allows you to retrieve data across modules. + +A workflow must return a `WorkflowResponse` instance. It accepts as a parameter the data to return, which is the vendor in this case. + +In the next step, you'll learn how to execute the workflow in an API route. + +### Further Read + +- [How to Create a Workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md) +- [What is an Actor Type](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/auth/auth-identity-and-actor-types/index.html.md) +- [How to Create an Actor Type](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/auth/create-actor-type/index.html.md) +- [What is a Compensation Function](https://docs.medusajs.com/docs/learn/fundamentals/workflows/compensation-function/index.html.md) + +*** + +## Step 5: Create Vendor API Route + +Now that you've implemented the logic to create a vendor, you'll expose this functionality in an API route. An API Route is an endpoint that exposes commerce features to external applications and clients, such as storefronts or custom dashboards. + +Learn more about API routes in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). + +### Create API Route + +An API route is created in a `route.ts` file under a sub-directory of the `src/api` directory. + +The path of the API route is the file's path relative to `src/api`. So, to create the `/vendors` API route, create the file `src/api/vendors/route.ts` with the following content: + +```ts title="src/api/vendors/route.ts" highlights={vendorRouteSchemaHighlights} +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { MedusaError } from "@medusajs/framework/utils" +import { z } from "zod" +import createVendorWorkflow, { + CreateVendorWorkflowInput, +} from "../../workflows/marketplace/create-vendor" + +export const PostVendorCreateSchema = z.object({ + name: z.string(), + handle: z.string().optional(), + logo: z.string().optional(), + admin: z.object({ + email: z.string(), + first_name: z.string().optional(), + last_name: z.string().optional(), + }).strict(), +}).strict() + +type RequestBody = z.infer + +``` + +You start by defining the accepted fields in incoming request bodies using [Zod](https://zod.dev/). You'll later learn how to enforce the schema validation on all incoming requests. + +Then, to create the API route, add the following content to the same file: + +```ts title="src/api/vendors/route.ts" +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + // If `actor_id` is present, the request carries + // authentication for an existing vendor admin + if (req.auth_context?.actor_id) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Request already authenticated as a vendor." + ) + } + + const vendorData = req.validatedBody + + // create vendor admin + const { result } = await createVendorWorkflow(req.scope) + .run({ + input: { + ...vendorData, + authIdentityId: req.auth_context.auth_identity_id, + } as CreateVendorWorkflowInput, + }) + + res.json({ + vendor: result.vendor, + }) +} +``` + +Since you export a `POST` function in this file, you're exposing a `POST` API route at `/vendors`. The route handler function accepts two parameters: + +1. A request object with details and context on the request, such as body parameter or authenticated user details. +2. A response object to manipulate and send the response. + +In the function, you first check that the user accessing the request isn't already registered (as a vendor admin). Then, you execute the `createVendorWorkflow` from the previous step, passing it the request body. + +You also pass the workflow the ID of the auth identity to associate the vendor admin with. This auth identity is set in the request's context because you'll later pass the registration JWT token in the request's header. + +Finally, you return the created vendor in the response. + +### Apply Authentication and Validation Middlewares + +To ensure that incoming request bodies contain the required parameters, and that only vendor admins with a registration token can access this route, you'll add middlewares to the API route. + +A middleware is a function executed before the API route when a request is sent to it. Middlewares are useful to restrict access to an API route based on validation or authentication requirements. + +Learn more about middlewares in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/middlewares/index.html.md). + +You define middlewares in Medusa in the `src/api/middlewares.ts` special file. So, create the file `src/api/middlewares.ts` with the following content: + +```ts title="src/api/middlewares.ts" +import { + defineMiddlewares, + authenticate, + validateAndTransformBody, +} from "@medusajs/framework/http" +import { PostVendorCreateSchema } from "./vendors/route" + +export default defineMiddlewares({ + routes: [ + { + matcher: "/vendors", + method: ["POST"], + middlewares: [ + authenticate("vendor", ["session", "bearer"], { + allowUnregistered: true, + }), + validateAndTransformBody(PostVendorCreateSchema), + ], + }, + { + matcher: "/vendors/*", + middlewares: [ + authenticate("vendor", ["session", "bearer"]), + ], + }, + ], +}) +``` + +In this file, you export the middlewares definition using `defineMiddlewares` from the Medusa Framework. This function accepts an object having a `routes` property, which is an array of middleware configurations to apply on routes. + +You pass in the `routes` array objects having the following properties: + +- `matcher`: The route to apply the middleware on. +- `method`: Optional HTTP methods to apply the middleware on for the specified API route. +- `middlewares`: An array of the middlewares to apply. + +You first apply two middlewares to the `POST /vendors` API route you just created: + +- `authenticate`: Ensure that the user sending the request has a registration JWT token. +- `validateAndTransformBody`: Validate that the incoming request body matches the Zod schema that you created in the API route's file. + +You also apply the `authenticate` middleware on all routes starting with `/vendors*` to ensure they can only be accessed by authenticated vendor admin. Note that since you don't enable `allowUnregistered`, the vendor admin must be registered to access these routes. + +### Test it Out + +To test out the above API route, start the Medusa application: + +```bash npm2yarn +npm run dev +``` + +Then, you must retrieve a registration JWT token to access the Create Vendor API route. To obtain it, send a `POST` request to the `/auth/vendor/emailpass/register` API route: + +```bash +curl -X POST 'http://localhost:9000/auth/vendor/emailpass/register' \ +-H 'Content-Type: application/json' \ +--data-raw '{ + "email": "vendor@example.com", + "password": "supersecret" +}' +``` + +You can replace the email and password with other credentials. + +Then, to create a vendor and its admin, send a request to the `/vendors` API route, passing the token retrieved from the previous response in the request header: + +Don't include a trailing slash at the end of the URL. Learn more [here](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/middlewares/index.html.md). + +```bash +curl -X POST 'http://localhost:9000/vendors' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer {token}' \ +--data-raw '{ + "name": "Acme", + "handle": "acme", + "admin": { + "email": "vendor@example.com", + "first_name": "Admin", + "last_name": "Acme" + } +}' +``` + +Make sure to replace `{token}` with the registration token you retrieved. If you changed the email previously, make sure to change it here as well. + +This will return the created vendor and its admin. + +You can now retrieve an authenticated token of the vendor admin. To do that, send a `POST` request to the `/auth/vendor/emailpass` API route: + +```bash +curl -X POST 'http://localhost:9000/auth/vendor/emailpass' \ +-H 'Content-Type: application/json' \ +--data-raw '{ + "email": "vendor@example.com", + "password": "supersecret" +}' +``` + +Use this token in the header of later requests that require authentication. + +### Further Reads + +- [How to Create an API route](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md) +- [How to Create a Middleware](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/middlewares/index.html.md) +- [Learn more about the /auth route](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/auth/authentication-route/index.html.md) + +*** + +## Step 6: Create Product API Route + +Now that you support creating vendors, you want to allow these vendors to manage their products. + +In this step, you'll create a workflow that creates a product, then use that workflow in a new API route. + +### Create Product Workflow + +The workflow to create a product has the following steps: + +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the default sales channel in the store. +- [createProductsWorkflow](https://docs.medusajs.com/references/medusa-workflows/createProductsWorkflow/index.html.md): Create the product. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the admin's vendor ID. +- [createRemoteLinkStep](https://docs.medusajs.com/references/helper-steps/createRemoteLinkStep/index.html.md): Create a link between the vendor and the product. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the created product's details. + +The workflow's steps are all provided by Medusa's `@medusajs/medusa/core-flows` package. So, you can create the workflow right away. + +Create the file `src/workflows/marketplace/create-vendor-product/index.ts` with the following content: + +```ts title="src/workflows/marketplace/create-vendor-product/index.ts" +import { CreateProductWorkflowInputDTO } from "@medusajs/framework/types" +import { + createWorkflow, + transform, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { + createProductsWorkflow, + createRemoteLinkStep, + useQueryGraphStep, +} from "@medusajs/medusa/core-flows" +import { MARKETPLACE_MODULE } from "../../../modules/marketplace" +import { Modules } from "@medusajs/framework/utils" + +type WorkflowInput = { + vendor_admin_id: string + product: CreateProductWorkflowInputDTO +} + +const createVendorProductWorkflow = createWorkflow( + "create-vendor-product", + (input: WorkflowInput) => { + // Retrieve default sales channel to make the product available in. + // Alternatively, you can link sales channels to vendors and allow vendors + // to manage sales channels + const { data: stores } = useQueryGraphStep({ + entity: "store", + fields: ["default_sales_channel_id"], + }) + + const productData = transform({ + input, + stores, + }, (data) => { + return { + products: [{ + ...data.input.product, + sales_channels: [ + { + id: data.stores[0].default_sales_channel_id, + }, + ], + }], + } + }) + + const createdProducts = createProductsWorkflow.runAsStep({ + input: productData, + }) + + // TODO link vendor and products + } +) + +export default createVendorProductWorkflow +``` + +The workflow accepts two parameters: + +- `vendor_admin_id`: The ID of the vendor admin creating the product. +- `product`: The details of the product to create. + +In the workflow, you first retrieve the default sales channel in the store. This is necessary, as the product can only be purchased in the sales channels it's available in. + +Then, you prepare the product's data, combining what's passed in the input and the default sales channel's ID. Finally, you create the product. + +Next, you want to create a link between the product and the vendor it's created for. So, replace the `TODO` with the following: + +```ts title="src/workflows/marketplace/create-vendor-product/index.ts" +const { data: vendorAdmins } = useQueryGraphStep({ + entity: "vendor_admin", + fields: ["vendor.id"], + filters: { + id: input.vendor_admin_id, + }, +}).config({ name: "retrieve-vendor-admins" }) + +const linksToCreate = transform({ + input, + createdProducts, + vendorAdmins, +}, (data) => { + return data.createdProducts.map((product) => { + return { + [MARKETPLACE_MODULE]: { + vendor_id: data.vendorAdmins[0].vendor.id, + }, + [Modules.PRODUCT]: { + product_id: product.id, + }, + } + }) +}) + +createRemoteLinkStep(linksToCreate) + +const { data: products } = useQueryGraphStep({ + entity: "product", + fields: ["*", "variants.*"], + filters: { + id: createdProducts[0].id, + }, +}).config({ name: "retrieve-products" }) + +return new WorkflowResponse({ + product: products[0], +}) +``` + +You retrieve the ID of the admin's vendor. Then, you prepare the data to create a link. + +Medusa provides a `createRemoteLinkStep` that allows you to create links between records of different modules. The step accepts as a parameter an array of link objects, where each object has the module name as the key and the ID of the record to link as the value. The modules must be passed in the same order they were passed in to `defineLink`. + +Refer to the [Link](https://docs.medusajs.com/docs/learn/fundamentals/module-links/link/index.html.md) documentation to learn more about creating links. + +Finally, you retrieve the created product's details using Query and return the product. + +### Create API Route + +Next, you'll create the API route that uses the above workflow to create a product for a vendor. + +Create the file `src/api/vendors/products/route.ts` with the following content: + +```ts title="src/api/vendors/products/route.ts" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { + HttpTypes, +} from "@medusajs/framework/types" +import createVendorProductWorkflow from "../../../workflows/marketplace/create-vendor-product" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { result } = await createVendorProductWorkflow(req.scope) + .run({ + input: { + vendor_admin_id: req.auth_context.actor_id, + product: req.validatedBody, + }, + }) + + res.json({ + product: result.product, + }) +} +``` + +Since you export a `POST` function, you're exposing a `POST` API route at `/vendors/products`. + +In the route handler, you execute the `createVendorProductWorkflow` workflow, passing it the authenticated vendor admin's ID and the request body, which holds the details of the product to create. Finally, you return the product. + +### Apply Validation Middleware + +Since the above API route requires passing the product's details in the request body, you need to apply a validation middleware on it. + +In `src/api/middlewares.ts`, add a new middleware route object: + +```ts title="src/api/middlewares.ts" +// other imports... +import { AdminCreateProduct } from "@medusajs/medusa/api/admin/products/validators" + +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/vendors/products", + method: ["POST"], + middlewares: [ + validateAndTransformBody(AdminCreateProduct), + ], + }, + ], +}) +``` + +Similar to before, you apply the `validateAndTransformBody` middleware on the `POST /vendors/products` API route. You pass to the middleware the `AdminCreateProduct` schema that Medusa uses to validate the request body of the [Create Product Admin API Route](https://docs.medusajs.com/api/admin#products_postproducts). + +### Test it Out + +To test it out, start the Medusa application: + +```bash npm2yarn +npm run dev +``` + +Then, send the following request to `/vendors/products` to create a product for the vendor: + +```bash +curl -X POST 'http://localhost:9000/vendors/products' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer {token}' \ +--data '{ + "title": "T-Shirt", + "status": "published", + "options": [ + { + "title": "Color", + "values": ["Blue"] + } + ], + "variants": [ + { + "title": "T-Shirt", + "prices": [ + { + "currency_code": "eur", + "amount": 10 + } + ], + "manage_inventory": false, + "options": { + "Color": "Blue" + } + } + ] +}' +``` + +Make sure to replace `{token}` with the authenticated token of the vendor admin you retrieved earlier. + +This will return the created product. In the next step, you'll add API routes to retrieve the vendor's products. + +### Further Reads + +- [How to use Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md) +- [How to use Link](https://docs.medusajs.com/docs/learn/fundamentals/module-links/link/index.html.md) + +*** + +## Step 7: Retrieve Products API Route + +In this step, you'll add the API route to retrieve a vendor's products. + +To create the API route that retrieves the vendor’s products, add the following to `src/api/vendors/products/route.ts`: + +```ts title="src/api/vendors/products/route.ts" +// other imports... +import { + ContainerRegistrationKeys, +} from "@medusajs/framework/utils" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: [vendorAdmin] } = await query.graph({ + entity: "vendor_admin", + fields: ["vendor.products.*"], + filters: { + id: [ + // ID of the authenticated vendor admin + req.auth_context.actor_id, + ], + }, + }) + + res.json({ + products: vendorAdmin.vendor.products, + }) +} +``` + +You add a `GET` API route at `/vendors/products`. In the route handler, you use Query to retrieve the list of products of the authenticated admin's vendor and returns them in the response. You can retrieve the linked records since Query retrieves data across modules. + +### Test it Out + +To test out the new API routes, start the Medusa application: + +```bash npm2yarn +npm run dev +``` + +Then, send a `GET` request to `/vendors/products` to retrieve the vendor’s products: + +```bash +curl 'http://localhost:9000/vendors/products' \ +-H 'Authorization: Bearer {token}' +``` + +Make sure to replace `{token}` with the authenticated token of the vendor admin you retrieved earlier. + +### Further Reads + +- [How to use Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md) +- [How to use Link](https://docs.medusajs.com/docs/learn/fundamentals/module-links/link/index.html.md) + +*** + +## Step 8: Create Vendor Order Workflow + +In this step, you’ll create a workflow that’s executed when the customer places an order. It has the following steps: + +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the cart's details. +- [completeCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/completeCartWorkflow/index.html.md): Create the parent order from the cart. +- [groupVendorItemsStep](#groupvendoritemssstep): Group the items by their vendor. +- [getOrderDetailWorkflow](https://docs.medusajs.com/references/medusa-workflows/getOrderDetailWorkflow/index.html.md): Retrieve the parent order's details. +- [createVendorOrdersStep](#createvendorordersstep): Create child orders for each vendor. +- [createRemoteLinkStep](https://docs.medusajs.com/references/helper-steps/createRemoteLinkStep/index.html.md): Create links between vendors and orders. + +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. + +### groupVendorItemsStep + +The third step of the workflow returns an object of items grouped by their vendor. + +To create the step, create the file `src/workflows/marketplace/create-vendor-orders/steps/group-vendor-items.ts` with the following content: + +```ts title="src/workflows/marketplace/create-vendor-orders/steps/group-vendor-items.ts" +import { + createStep, + StepResponse, +} from "@medusajs/framework/workflows-sdk" +import { CartDTO, CartLineItemDTO } from "@medusajs/framework/types" +import { ContainerRegistrationKeys, promiseAll } from "@medusajs/framework/utils" + +type StepInput = { + cart: CartDTO +} + +const groupVendorItemsStep = createStep( + "group-vendor-items", + async ({ cart }: StepInput, { container }) => { + const query = container.resolve(ContainerRegistrationKeys.QUERY) + + const vendorsItems: Record = {} + + await promiseAll(cart.items?.map(async (item) => { + const { data: [product] } = await query.graph({ + entity: "product", + fields: ["vendor.*"], + filters: { + id: [item.product_id], + }, + }) + + const vendorId = product.vendor?.id + + if (!vendorId) { + return + } + vendorsItems[vendorId] = [ + ...(vendorsItems[vendorId] || []), + item, + ] + })) + + return new StepResponse({ + vendorsItems, + }) + } +) + +export default groupVendorItemsStep +``` + +This step receives the cart's details as an input. In the step, you group the items by the vendor associated with the product into an object and returns the object. You use Query to retrieve a product's vendor. + +### createVendorOrdersStep + +The fourth step of the workflow creates an order for each vendor. The order consists of the items in the parent order that belong to the vendor. + +Create the file `src/workflows/marketplace/create-vendor-orders/steps/create-vendor-orders.ts` with the following content: + +```ts title="src/workflows/marketplace/create-vendor-orders/steps/create-vendor-orders.ts" highlights={vendorOrder1Highlights} collapsibleLines="1-19" expandMoreLabel="Show Imports" +import { + createStep, + StepResponse, +} from "@medusajs/framework/workflows-sdk" +import { + CartLineItemDTO, + OrderDTO, + LinkDefinition, + InferTypeOf, +} from "@medusajs/framework/types" +import { Modules, promiseAll } from "@medusajs/framework/utils" +import { + cancelOrderWorkflow, + createOrderWorkflow, +} from "@medusajs/medusa/core-flows" +import MarketplaceModuleService from "../../../../modules/marketplace/service" +import { MARKETPLACE_MODULE } from "../../../../modules/marketplace" +import Vendor from "../../../../modules/marketplace/models/vendor" + +export type VendorOrder = (OrderDTO & { + vendor: InferTypeOf +}) + +type StepInput = { + parentOrder: OrderDTO + vendorsItems: Record +} + +function prepareOrderData( + items: CartLineItemDTO[], + parentOrder: OrderDTO +) { + // TODO format order data +} + +const createVendorOrdersStep = createStep( + "create-vendor-orders", + async ( + { vendorsItems, parentOrder }: StepInput, + { container, context } + ) => { + const linkDefs: LinkDefinition[] = [] + const createdOrders: VendorOrder[] = [] + const vendorIds = Object.keys(vendorsItems) + + const marketplaceModuleService: MarketplaceModuleService = + container.resolve(MARKETPLACE_MODULE) + + const vendors = await marketplaceModuleService.listVendors({ + id: vendorIds, + }) + + // TODO create child orders + + return new StepResponse({ + orders: createdOrders, + linkDefs, + }, { + created_orders: createdOrders, + }) + }, + async ({ created_orders }, { container, context }) => { + // TODO add compensation function + } +) + +export default createVendorOrdersStep +``` + +This creates a step that receives the grouped vendor items and the parent order. For now, it initializes variables and retrieves vendors by their IDs. + +The step returns the created orders and the links to be created. It also passes the created orders to the compensation function + +Replace the `TODO` in the step with the following: + +```ts title="src/workflows/marketplace/create-vendor-orders/steps/create-vendor-orders.ts" highlights={vendorOrder2Highlights} +if (vendorIds.length === 1) { + linkDefs.push({ + [MARKETPLACE_MODULE]: { + vendor_id: vendors[0].id, + }, + [Modules.ORDER]: { + order_id: parentOrder.id, + }, + }) + + createdOrders.push({ + ...parentOrder, + vendor: vendors[0], + }) + + return new StepResponse({ + orders: createdOrders, + linkDefs, + }, { + created_orders: [], + }) +} + +// TODO create multiple child orders +``` + +In the above snippet, if there's only one vendor in the group, the parent order is added to the `linkDefs` array and it's returned in the response. + +Since the parent order isn't a child order, it's not passed to the compensation function as it should only handle child orders. + +Next, replace the new `TODO` with the following snippet: + +```ts title="src/workflows/marketplace/create-vendor-orders/steps/create-vendor-orders.ts" highlights={vendorOrder3Highlights} +try { + await promiseAll( + vendorIds.map(async (vendorId) => { + const items = vendorsItems[vendorId] + const vendor = vendors.find((v) => v.id === vendorId)! + + const { result: childOrder } = await createOrderWorkflow( + container + ) + .run({ + input: prepareOrderData(items, parentOrder), + context, + }) as unknown as { result: VendorOrder } + + childOrder.vendor = vendor + createdOrders.push(childOrder) + + linkDefs.push({ + [MARKETPLACE_MODULE]: { + vendor_id: vendor.id, + }, + [Modules.ORDER]: { + order_id: childOrder.id, + }, + }) + }) + ) +} catch (e) { + return StepResponse.permanentFailure( + `An error occured while creating vendor orders: ${e}`, + { + created_orders: createdOrders, + } + ) +} +``` + +In this snippet, you create multiple child orders for each vendor and link the orders to the vendors. + +You use `promiseAll` from the Workflows SDK that loops over an array of promises and ensures that all transactions within these promises are rolled back in case an error occurs. You also wrap `promiseAll` in a try-catch block, and in the catch block you invoke and return `StepResponse.permanentFailure` which indicates that the step has failed but still invokes the compensation function that you'll implement in a bit. The first parameter of `permanentFailure` is the error message, and the second is the data to pass to the compensation function. + +If an error occurs, the created orders in the `createdOrders` array are canceled using Medusa's `cancelOrderWorkflow` from the `@medusajs/medusa/core-flows` package. + +The order's data is formatted using the `prepareOrderData` function. Replace its definition with the following: + +```ts title="src/workflows/marketplace/create-vendor-orders/steps/create-vendor-orders.ts" +function prepareOrderData( + items: CartLineItemDTO[], + parentOrder: OrderDTO +) { + return { + items, + metadata: { + parent_order_id: parentOrder.id, + }, + // use info from parent + region_id: parentOrder.region_id, + customer_id: parentOrder.customer_id, + sales_channel_id: parentOrder.sales_channel_id, + email: parentOrder.email, + currency_code: parentOrder.currency_code, + shipping_address_id: parentOrder.shipping_address?.id, + billing_address_id: parentOrder.billing_address?.id, + // A better solution would be to have shipping methods for each + // item/vendor. This requires changes in the storefront to commodate that + // and passing the item/vendor ID in the `data` property, for example. + // For simplicity here we just use the same shipping method. + shipping_methods: parentOrder.shipping_methods.map((shippingMethod) => ({ + name: shippingMethod.name, + amount: shippingMethod.amount, + shipping_option_id: shippingMethod.shipping_option_id, + data: shippingMethod.data, + tax_lines: shippingMethod.tax_lines.map((taxLine) => ({ + code: taxLine.code, + rate: taxLine.rate, + provider_id: taxLine.provider_id, + tax_rate_id: taxLine.tax_rate_id, + description: taxLine.description, + })), + adjustments: shippingMethod.adjustments.map((adjustment) => ({ + code: adjustment.code, + amount: adjustment.amount, + description: adjustment.description, + promotion_id: adjustment.promotion_id, + provider_id: adjustment.provider_id, + })), + })), + } +} +``` + +This formats the order's data using the items and parent order's details. + +When creating the child orders, the shipping method of the parent is used as-is for simplicity. A better practice would be to allow the customer to choose different shipping methods for each vendor’s items and then store those details in the `data` property of the shipping method. + +Finally, replace the `TODO` in the compensation function with the following: + +```ts title="src/workflows/marketplace/create-vendor-orders/steps/create-vendor-orders.ts" +await Promise.all(created_orders.map((createdOrder) => { + return cancelOrderWorkflow(container).run({ + input: { + order_id: createdOrder.id, + }, + context, + container, + }) +})) +``` + +The compensation function cancels all child orders received from the step. It uses the `cancelOrderWorkflow` that Medusa provides in the `@medusajs/medusa/core-flows` package. + +### Create Workflow + +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`: + +```ts title="src/workflows/marketplace/create-vendor-orders/index.ts" collapsibleLines="1-13" expandMoreLabel="Show Imports" +import { + createWorkflow, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { + useQueryGraphStep, + createRemoteLinkStep, + completeCartWorkflow, + getOrderDetailWorkflow, +} from "@medusajs/medusa/core-flows" +import groupVendorItemsStep from "./steps/group-vendor-items" +import createVendorOrdersStep from "./steps/create-vendor-orders" + +type WorkflowInput = { + cart_id: string +} + +const createVendorOrdersWorkflow = createWorkflow( + "create-vendor-order", + (input: WorkflowInput) => { + const { data: carts } = useQueryGraphStep({ + entity: "cart", + fields: ["id", "items.*"], + filters: { id: input.cart_id }, + options: { + throwIfKeyNotFound: true, + }, + }) + + const { id: orderId } = completeCartWorkflow.runAsStep({ + input: { + id: carts[0].id, + }, + }) + + const { vendorsItems } = groupVendorItemsStep({ + cart: carts[0], + }) + + const order = getOrderDetailWorkflow.runAsStep({ + input: { + order_id: orderId, + fields: [ + "region_id", + "customer_id", + "sales_channel_id", + "email", + "currency_code", + "shipping_address.*", + "billing_address.*", + "shipping_methods.*", + ], + }, + }) + + const { + orders: vendorOrders, + linkDefs, + } = createVendorOrdersStep({ + parentOrder: order, + vendorsItems, + }) + + createRemoteLinkStep(linkDefs) + + return new WorkflowResponse({ + parent_order: order, + vendor_orders: vendorOrders, + }) + } +) + +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. + +You return the parent and vendor orders. + +### Create API Route Executing the Workflow + +You’ll now create the API route that executes the workflow. + +Create the file `src/api/store/carts/[id]/complete-vendor/route.ts` with the following content: + +```ts title="src/api/store/carts/[id]/complete-vendor/route.ts" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import createVendorOrdersWorkflow from "../../../../../workflows/marketplace/create-vendor-orders" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const cartId = req.params.id + + const { result } = await createVendorOrdersWorkflow(req.scope) + .run({ + input: { + cart_id: cartId, + }, + }) + + res.json({ + type: "order", + order: result.parent_order, + }) +} +``` + +Since you expose a `POST` function, you're exposing a `POST` API route at `/store/carts/:id/complete-vendor`. In the route handler, you execute the `createVendorOrdersWorkflow` and return the created order. + +### Test it Out + +To test this out, it’s recommended to install the [Next.js Starter storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md). + +Then, you need to customize the storefront to use your complete cart API route rather than Medusa's. In `src/lib/data/cart.ts`, find the following lines in the `src/lib/data/cart.ts`: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="blue" +const cartRes = await sdk.store.cart + .complete(id, {}, headers) + .then(async (cartRes) => { + const cartCacheTag = await getCacheTag("carts") + revalidateTag(cartCacheTag) + return cartRes + }) + .catch(medusaError) +``` + +Replace them with the following: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="blue" +const cartRes = await sdk.client.fetch( + `/store/carts/${id}/complete-vendor`, { + method: "POST", + headers, + }) + .then(async (cartRes) => { + const cartCacheTag = await getCacheTag("carts") + revalidateTag(cartCacheTag) + return cartRes + }) + .catch(medusaError) +``` + +Now, the checkout flow uses your custom API route to place the order instead of Medusa's. + +Refer to the [JS SDK](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/js-sdk/index.html.md) documentation to learn more about using it. + +Try going through the checkout flow now, purchasing a product that you created for the vendor earlier. The order should be placed successfully. + +In the next step, you'll create an API route to retrieve the vendor's orders, allowing you to confirm that the child order was created for the vendor. + +*** + +## Step 9: Retrieve Vendor Orders API Route + +In this step, you’ll create an API route that retrieves a vendor’s orders. Create the file `src/api/vendors/orders/route.ts` with the following content: + +```ts title="src/api/vendors/orders/route.ts" highlights={getOrderHighlights} +import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" +import { getOrdersListWorkflow } from "@medusajs/medusa/core-flows" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: [vendorAdmin] } = await query.graph({ + entity: "vendor_admin", + fields: ["vendor.orders.*"], + filters: { + id: [req.auth_context.actor_id], + }, + }) + + const { result: orders } = await getOrdersListWorkflow(req.scope) + .run({ + input: { + fields: [ + "metadata", + "total", + "subtotal", + "shipping_total", + "tax_total", + "items.*", + "items.tax_lines", + "items.adjustments", + "items.variant", + "items.variant.product", + "items.detail", + "shipping_methods", + "payment_collections", + "fulfillments", + ], + variables: { + filters: { + id: vendorAdmin.vendor.orders.map((order) => order.id), + }, + }, + }, + }) + + res.json({ + orders, + }) +} +``` + +You add a `GET` API route at `/vendors/orders`. In the route handler, you first use Query to retrieve the orders of the authenticated admin's vendor. Then, you use Medusa's `getOrdersListWorkflow` to retrieve the list of orders with the specified fields. + +### Test it Out + +To test it out, start the Medusa application: + +```bash npm2yarn +npm run dev +``` + +Then, send a `GET` request to `/vendors/orders` : + +```bash +curl 'http://localhost:9000/vendors/orders' \ +-H 'Authorization: Bearer {token}' +``` + +Make sure to replace the `{token}` with the vendor admin’s authentication token. + +You’ll receive in the response the orders of the vendor created in the previous step. + +*** + +## Next Steps + +The next steps of this example depend on your use case. This section provides some insight into implementing them. + +### Use Existing Features + +If you want vendors to perform actions that are available for admin users through Medusa's [Admin API routes](https://docs.medusajs.com/api/admin), such as managing their orders, you need to recreate them similar to the create product API route you created earlier. + +### Link Other Data Models to Vendors + +Similar to linking an order and a product to a vendor, you can link other data models to vendors as well. + +For example, you can link sales channels or other settings to vendors. + +[Learn more about module links](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md). + +### Storefront Development + +Medusa provides a Next.js Starter storefront, which you can customize to fit your specific use case. + +You can also create a custom storefront. Check out the [Storefront Development](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/storefront-development/index.html.md) section to learn how to create a storefront. + +### Admin Development + +The Medusa Admin is extendable, allowing you to add custom widgets to existing pages or create entirely new pages. For example, you can add a new page showing the list of vendors. Learn more about it in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/admin/index.html.md). + +Only super admins can access the Medusa Admin, not vendor admins. So, if you need a dashboard specific to each vendor admin, you will need to build a custom dashboard with the necessary features. + + +# Marketplace Recipe + +This recipe provides the general steps to implement a marketplace in your Medusa application. + +## Example Guides + +## Overview + +A marketplace is an online commerce store that allows different vendors to sell their products within the same commerce system. Customers can purchase products from any of these vendors, and vendors can manage their orders separately. + +Medusa's [Framework](https://docs.medusajs.com/docs/learn/fundamentals/framework/index.html.md) for customizations facilitates building a marketplace. You can create a Marketplace Module that implements custom data models, such as vendors or sellers, and link those data models to existing ones such as products and orders. You also expose custom features using API routes, and implement complex flows using workflows. + +[How Foraged built a custom marketplace with Medusa](https://medusajs.com/blog/foraged/). + +*** + +## Create Custom Module with Data Models + +In a marketplace, a business or a vendor has a user, and they can use that user to authenticate and manage the vendor's data. + +You can create a marketplace module that implements data models for vendors, their admins, and any other data models that fits your use case. + +- [Create a Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md): Learn how to create a module. +- [Create Data Models](https://docs.medusajs.com/docs/learn/fundamentals/modules#1-create-data-model/index.html.md): Create data models in the module. + +*** + +## Link Custom and Existing Data Models + +Since a vendor has products, orders, and other models based on your use case, you can define module links between your module's data models and the Commerce Module's data models. + +For example, if you defined a vendor data model in a marketplace module, you can define a module link between the vendor and the Product Module's product data model. This builds an association between a vendor and their products, allowing you to query and manage products based on the vendor. + +[Define a Module Link](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md): Learn how to define a module link. + +*** + +## Create Vendor API Routes + +Your marketplace will most likely provide custom features for vendors, such as managing their products and orders. You can create API routes that expose these features to the vendors. + +When you build these API routes, it's essential that you protect them to only allow authenticated vendors. For example, only a vendor's admin should be able to manage their products and orders. + +Medusa supports creating custom actor types that can be authenticated with your custom API routes. + +- [Create API Routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md): Learn how to create an API Route in Medusa. +- [Create an Actor Type](https://docs.medusajs.com/commerce-modules/auth/create-actor-type/index.html.md): Learn how to create an actor type and authenticate it. + +*** + +## Split Orders Based on Vendors + +If your use case allows a customer's orders to have items from different vendors, you can replicate the [Complete Cart API route](https://docs.medusajs.com/api/store#carts_postcartsidcomplete) to customize the order creation process. + +In the API route, you can create a workflow that splits the order into multiple orders, one for each vendor. A workflow is a series of steps that provide features like rollback and retry mechanisms. + +- [Replicate API Routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/override/index.html.md): Learn how to replicate an existing API route. +- [Create a Workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md): Learn how to create a workflow in Medusa. + +*** + +## Customize Admin Dashboard + +Based on your use case, you may need to customize the Medusa Admin to add new widgets or pages. + +For example, you can create a page that lists all vendors or a widget that shows a product's vendor information. + +The Medusa Admin is an extensible application within your Medusa application. You can customize it by: + +- **Widgets**: Adding widgets to existing pages, such as the product page. +- **UI Routes**: Adding new pages to the Medusa Admin, such as a page to manage vendors. +- **Settings Pages**: Adding new pages to the Medusa Admin settings, such as a page to manage marketplace settings. + +- [Create Admin Widget](https://docs.medusajs.com/docs/learn/fundamentals/admin/widgets/index.html.md): Add widgets into existing admin pages. +- [Create Admin UI Routes](https://docs.medusajs.com/docs/learn/fundamentals/admin/ui-routes/index.html.md): Add new pages to your Medusa Admin. + +[Create Admin Setting Page](https://docs.medusajs.com/docs/learn/fundamentals/admin/ui-routes#create-settings-page/index.html.md): Add new page to the Medusa Admin settings. + +*** + +## Build Dashbord for Vendors + +For more complex use cases, customizing the Medusa Admin may not be enough to allow vendors to manage their data. + +In that case, you can build a custom dashboard for vendors that allows them to manage their data. This dashboard can interact with Medusa's Admin API and the custom API routes you created for vendors to provide a seamless experience. + +[Medusa Admin APIs](https://docs.medusajs.com/api/admin): Learn about available APIs for the Medusa Admin. + +*** + +## Customize or Build Storefront + +Medusa provides a Next.js Starter Storefront to use with your application. You can customize it to for your marketplace use case, such as showing products by vendor. + +Alternatively, you can build your own storefront using the Medusa APIs. This headless approach gives you the flexibility to build a custom storefront without limitations on which tech stack you use, or the design of the storefront. + +- [Next.js Starter Storefront](https://docs.medusajs.com/nextjs-starter/index.html.md): Learn how to install and customize the Next.js Starter Storefront. +- [Storefront Development](https://docs.medusajs.com/storefront-development/index.html.md): Find useful guides for creating a custom storefront. + + +# Multi-Region Store Recipe + +This recipe provides the general steps to build a multi-region store with Medusa. + +## Overview + +A multi-regional store allows merchants to sell across different countries. This includes supporting each country's tax rules, currency, available shipping and payment options, and more. + +Medusa comes with multi-regional support out of the box. This recipe explains how to benefit from Medusa's features to create a multi-regional store. + +*** + +## Multi-Region Setup + +In Medusa, you can create unlimited regions in your store. Each region has configurations managed through the Medusa Admin or the Admin REST APIs. + +### Currency + +Merchants specify the currency of each region. Multiple regions can have the same currency, but a region has only one currency. + +When customers view your products from a region, they see the prices in the region’s currency. + +### Tax Regions and Rates + +Merchants can define tax regions, which are tax-related settings for a specific country. For each tax region, merchants can set a default tax rate and override it with tax rates for specific conditions, such as product types. + +During checkout, Medusa calculates the taxes using the tax region settings of the customer's region and selected country in their shipping address. + +- [Using Medusa Admin](https://docs.medusajs.com/user-guide/settings/tax-regions/index.html.md): Learn how to manage tax regions in the Medusa Admin +- [Using Admin APIs](https://docs.medusajs.com/api/admin#tax-regions_posttaxregions): Manage tax regions using the Admin APIs. + +### Payment and Fulfillment Providers + +Merchants choose which payment providers are available in each region. For example, one region can use Payment Provider A and B while another only uses Payment Provider B. + +Merchants can also choose the fulfillment providers available in each stock location, and provide shipping options using the providers in those locations. + +During checkout, customers only see the payment providers configured for the region, and they can only choose shipping options that can be used to fulfill items to their shipping address. This allows merchants to give customers a localized experience that feels familiar and instills trust. + +Medusa provides official module providers for payment and fulfillment. You can also create custom module providers. + +- [Manage Payment Providers in Medusa Admin](https://docs.medusajs.com/user-guide/settings/regions/index.html.md): Learn how to manage providers in a region. +- [Manage Fulfillment Providers in Medusa Admin](https://docs.medusajs.com/user-guide/settings/locations-and-shipping/locations#manage-fulfillment-providers/index.html.md): Learn how to manage providers in a location. +- [Integrations](https://docs.medusajs.com/integrations/index.html.md): Check out available integrations, including payment module providers. +- [Create Fulfillment Module Provider](https://docs.medusajs.com/references/fulfillment/provider/index.html.md): Learn how to create a fulfillment module provider. + +*** + +## Prices Per Region and Currency + +Merchants set the price of shipping options and product variants per currency and region. This also applies to adding sales or overriding prices for specific conditions. + +Using the tax-inclusive feature, merchants can also specify prices including taxes per currency and region. Medusa then calculates the tax amount applied to a line item in the cart based on the region's tax configurations. + +- [Setting Variant Prices in Medusa Admin](https://docs.medusajs.com/user-guide/products/variants#edit-product-variant-prices/index.html.md): Learn how to set a variant's prices in Medusa Admin. +- [Display Variant Price in Storefront](https://docs.medusajs.com/storefront-development/products/price/index.html.md): Learn how to display the correct product price in a storefront. + +*** + +## Multi-Warehouse Support + +Medusa's [Inventory](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/inventory/index.html.md) and [Stock Location](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/stock-location/index.html.md) Modules provide multi-warehouse features that allow merchants to manage inventory across different locations. Merchants then control which location an item in an order is fulfilled from, allowing them to keep a correct inventory count across locations and sales channels. + +A multi-regional setup lets merchants manage their inventory through Medusa across the different regions they serve. Customers are always shown accurate inventory information based on the location associated with their sales channel. + +- [Manage Stock Locations](https://docs.medusajs.com/user-guide/settings/locations-and-shipping/locations/index.html.md): Learn how to manage stock locations in the Medusa Admin. +- [Manage Inventory](https://docs.medusajs.com/user-guide/inventory/index.html.md): Learn how to manage inventory in the Medusa Admin. + +*** + +## Multi-Lingual Setup + +By integrating a third-party Content Management Systems (CMS), you benefit from rich content features including managing your content in multiple languages. This allows you to cater to customers’ different languages in the regions you serve. + +To integrate a third-party system, create a custom module whose main service connects to that third-party service. You can also follow the [Contentful integration guide](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/integrations/guides/contentful/index.html.md) to learn how to integrate Contentful and benefit from localization features. + +- [Create a Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md): Learn how to create a module in Medusa. +- [Integrate Contentful](https://docs.medusajs.com/integrations/guides/contentful/index.html.md): Integrate Contentful with Localization Features. + + +# Omnichannel Commerce Recipe + +This recipe provides the general steps to build an omnichannel store with Medusa. + +## Overview + +Merchants selling across different channels need an efficient way to manage their commerce data and operations across these channels. Omnichannel commerce solves this problem by allowing merchants to support multiple channels through a single system while maintaining layers of separation. + +Omnichannel commerce also provides customers a seamless shopping experience, regardless of where they’re shopping. Whether they’re buying your products from your online store, a marketplace like Amazon, or through social media, you provide them with a good experience and handle orders consistently. + +Medusa’s modular architecture facilitates building omnichannel commerce, as your server and storefront aren’t tightly coupled. You can also use the [Sales Channel Module](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/sales-channel/index.html.md)'s features to expand your business into other avenues like marketplaces and social commerce. + +*** + +## Create Multiple Storefronts with One Server + +When creating an omnichannel commerce, you build multiple webshops or mobile apps. All these storefronts should provide your customers with a similar experience, allow them to browse products, and place orders. + +Medusa's commerce features are available as REST APIs that storefronts can access to provide these features for customers. This separation also gives you freedom in choosing the tech stack of the storefronts you’re creating. + +[Store REST APIs](https://docs.medusajs.com/api/store): Check out available Store REST APIs in Medusa. + +*** + +## Integrate with Marketplaces and Social Commerce + +Businesses are no longer bound to sell in their stores. They can reach their customers through their different shopping channels. + +One example is marketplaces like Amazon. Customers searching through Amazon to find products are inadvertently searching through many third-party stores connected to Amazon’s marketplace. + +You can implement this example in Medusa with the Sales Channel Module and a custom module. Use the Sales Channel Module's features to set different product availability across sales channels. + +Then, in the custom module, integrate with Amazon’s seller program. You can then build workflows that sync your products with Amazon’s marketplace. + +Another channel that attracts customer sales is social media. You can create a custom module that integrates with social media apps to show your products and sell them to customers. + +- [Sales Channels](https://docs.medusajs.com/commerce-modules/sales-channel/index.html.md): Learn about the Sales Channel Module. +- [Create a Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md): Learn how to create a module. + +[Create a Workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows#1-create-workflow/index.html.md): Learn how to create a workflow. + +*** + +## Optimize Customer Experience + +Implement the customer journey and design for your storefronts that leads to the best customer experience. The Medusa application doesn't impose any restrictions on how the frontend is built. + +Medusa’s architecture also makes it easier to integrate any third-party services to provide a better customer experience: + +- Create a module that integrates the third-party service. +- Build a workflow that performs actions spanning across the third-party service and your Medusa application. +- Expose the workflow's features in API routes. +- Send requests to these API routes from your storefront. + +- [Create a Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md): Learn how to create a module. +- [Create a Workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md): Learn how to create a workflow. + +[Create API Route](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md): Learn how to create an API route. + + +# Order Management System (OMS) Recipe + +This recipe provides an overview of Medusa's features and how to use it as an Order Management System (OMS). + +## Overview + +Building or integrating an OMS brings certain challenges: accepting orders from different sales channels, tracking inventory across the sales channels, integrating third-party fulfillment and payment providers with the OMS, and more. + +Medusa's Commerce Modules and [Framework](https://docs.medusajs.com/docs/learn/fundamentals/framework/index.html.md) for customizations allows you to integrate it within a larger ecosystem. The Commerce Modules provide features to allow businesses to accept orders from any sales channel, benefit from multi-warehouse inventory features, and integrate third-party services for fulfillment, payment, and more. + +[How Siam Makro used Medusa an OMS](https://medusajs.com/blog/makro-pro/). + +*** + +## Source Orders into Medusa + +Sales channels in your commerce ecosystem must route their orders into the OMS. + +![Routing orders into Medusa OMS](https://res.cloudinary.com/dza7lstvk/image/upload/v1709032160/Medusa%20Book/oms-orders_zf5ta9.jpg) + +Medusa's [Store REST APIs](https://docs.medusajs.com/api/store) let you integrate a checkout experience in any storefront. Alternatively, you can use Medusa's [Draft Order APIs](https://docs.medusajs.com/api/admin#draft-orders) to place an order without direct involvement from the customer, such as when placing an order through a POS. + +In addition, you can customize the Medusa application to accept orders through a third-party checkout system. This gives you more flexibility over adding orders to Medusa. + +For example, you can support importing orders into Medusa through a custom API Route that allows batch-inserting orders. Another example is creating a scheduled job that runs at a specified interval and imports orders from a third-party service. + +- [Store REST APIs](https://docs.medusajs.com/api/store#carts): Learn how to use the Store REST APIs to create an order. +- [Create API Route](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md): Learn how to create a custom API Route. + +[Create Scheduled Jobs](https://docs.medusajs.com/docs/learn/fundamentals/scheduled-jobs/index.html.md): Learn how to create a scheduled job. + +*** + +## Route Orders to Third-party Fulfillment Services + +To integrate third-party fulfillment providers with the Medusa application, you can create a fulfillment module provider. + +Medusa uses the Fulfillment Module whenever a fulfillment action is performed, such as when a fulfillment is created for items in an order. The methods of the module's main service use the associated fulfillment module provider to handle the desired fulfillment actions. + +![Fulfilling orders with Medusa OMS](https://res.cloudinary.com/dza7lstvk/image/upload/v1709032184/Medusa%20Book/oms-fulfillment_qfrpdd.jpg) + +In addition, you can create a subscriber that listens to fulfillment-related events, such as the `order.fulfillment_created` event, to perform actions with the third-party fulfillment provider. + +- [Create a Fulfillment Module Provider](https://docs.medusajs.com/references/fulfillment/provider/index.html.md): Learn how to create a fulfillment provider in Medusa. +- [Order Events](https://docs.medusajs.com/references/order/events/index.html.md): Learn about the events emitted related to the Order Module + +[Create a Subscriber](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md): Learn about create a subscriber + +*** + +## Process Payment with Third-Party Providers + +To integrate third-party payment providers with the Medusa application, you can create a payment module provider. Customers can pay for their orders using this providers, and admins can process order payments using it. + +In addition, you can create a subscriber that listen to payment-related events, such as the `payment.captured` event, to perform actions in the third-party payment provider. + +- [Create a Payment Module Provider](https://docs.medusajs.com/references/payment/provider/index.html.md): Learn how to create a payment module provider. +- [Payment Events](https://docs.medusajs.com/references/payment/events/index.html.md): Learn about the events emitted related to the Payment Module + +[Create a Subscriber](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md): Learn about create a subscriber + +*** + +## Track Inventory Across Sales Channels + +Medusa's Inventory, Stock Location, and Sales Channel modules allow merchants to track inventory levels tied to sales channels across stock locations. + +When an order is placed, the item's quantity is reserved from the stock location associated with the order's sales channel. + +Once the item is fulfilled, the reserved quantity is deducted from the item's inventory quantity. + +- [Inventory Module](https://docs.medusajs.com/commerce-modules/inventory/index.html.md): Learn about the Inventory Module's concepts and features. +- [Stock Location Module](https://docs.medusajs.com/commerce-modules/stock-location/index.html.md): Learn about the Stock Location Module's concepts and features. + +[Sales Channel Module](https://docs.medusajs.com/commerce-modules/sales-channel/index.html.md): Learn about the Sales Channel Module's concepts and features. + +*** + +## Handle Returns, Exchanges, and Changes + +Customers can return or exchaneg items in an order. A merchant can also edit an order to add, update, or delete items. + +When changes are made to an order by any of the mentioned actions, the changes are reflected on the order's totals and associated inventory. The integrated fulfillment and payment module providers are used if fulfillment or payment actions are required, such as fulfilling exchanged items. + +Medusa also emits events related to these actions, such as `order.return_requested`. So, you can build a workflow that performs actions with the third-party fulfillment and payment providers, then execute it in a subscriber that's triggered whenever the event is emitted. + +- [Order Changes](https://docs.medusajs.com/commerce-modules/order/order-change/index.html.md): Learn about how to use order changes. +- [Order Events](https://docs.medusajs.com/references/order/events/index.html.md): Learn about the events emitted related to the Order Module +- [Create a Workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md): Learn how to create a workflow. +- [Create a Subscriber](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md): Learn about create a subscriber + + +# Recipes + +This section of the documentation provides recipes for common use cases with example implementations. + +## Recipes + +- [Marketplace](https://docs.medusajs.com/recipes/marketplace/index.html.md) +- [Subscriptions](https://docs.medusajs.com/recipes/subscriptions/index.html.md) +- [Digital Products](https://docs.medusajs.com/recipes/digital-products/index.html.md) +- [Integrate ERP](https://docs.medusajs.com/recipes/erp/index.html.md) +- [B2B](https://docs.medusajs.com/recipes/b2b/index.html.md) +- [Bundled Products](https://docs.medusajs.com/recipes/bundled-products/index.html.md) +- [Commerce Automation](https://docs.medusajs.com/recipes/commerce-automation/index.html.md) +- [Ecommerce](https://docs.medusajs.com/recipes/ecommerce/index.html.md) +- [Multi-Region Store](https://docs.medusajs.com/recipes/multi-region-store/index.html.md) +- [Omnichannel Store](https://docs.medusajs.com/recipes/omnichannel/index.html.md) +- [OMS](https://docs.medusajs.com/recipes/oms/index.html.md) +- [Personalized Products](https://docs.medusajs.com/recipes/personalized-products/index.html.md) +- [POS](https://docs.medusajs.com/recipes/pos/index.html.md) + + +# Personalized Products Recipe + +This recipe provides the general steps to build personalized products in Medusa. + +## Overview + +Personalized products are products that customers can customize based on their need. For example, they can upload an image to print on a shirt or provide a message to include in a letter. + +In Medusa, you can create a custom module defining data models related to personalization with the service to manage them. Then, you can link those data models to existing data models, such as products. + +You also have freedom in how you choose to implement the storefront, allowing you to build a unique experience around your products. + +*** + +## Store Personalized Data + +The Cart Module's `LineItem` data model has a `metadata` property that holds any custom data. You can pass the customer's customization in the request body's `metadata` field when adding a product to the cart. + +For example, if you’re asking customers to enter a message to put in a letter they’re purchasing, use the `metadata` attribute of the `LineItem` data model to set the personalized information entered by the customer: + +```bash +curl -X POST '{backend_url}/store/carts/{id}/line-items' \ +-H 'Content-Type: application/json' \ +-H 'x-publishable-api-key: {your_publishable_api_key}' \ +--data-raw '{ + "variant_id": "variant_123", + "quantity": 1, + "metadata": { + "message": "Hello, World!" + } +}' +``` + +Two line items in the cart having different `metadata` attributes are not considered the same item. So, each line item is managed separately and can have its own quantity. + +In more complex cases, you can create a custom module that stores and manages the personalization data models. You can also create a link between these data models and the `LineItem` data model. + +- [Create a Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md): Learn how to create a module. +- [Define Module Link](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md): Define link between data models. + +*** + +## Build a Custom Storefront + +Medusa's modular architecture removes any restrictions on the your storefront's tech stack, design, and the experience you provide customers. The storefront connects to the Medusa application using the Store API Routes. + +You can build a unique experience around your products that focuses on the customer’s personalization capabilities. + +Medusa provides a Next.js Starter Storefront with basic ecommerce functionalities that can be customized. You can also build your own storefront and use Medusa’s client libraries or Store API Routes to communicate with the Medusa application. + +- [Next.js Starter Storefront](https://docs.medusajs.com/nextjs-starter/index.html.md): Learn how to install the Next.js Starter Storefront. +- [Storefront Development](https://docs.medusajs.com/storefront-development/index.html.md): Find guides to build your own storefront. + +*** + +## Pass Personalized Data to the Order + +If you store the personalized data using a custom module, you can: + +- Create a workflow that handles saving the personalization data. +- Create a custom API Route that executes the workflow. +- Call that API Route from the storefront after adding the item to the cart. +- Consume the `orderCreated` hook of the [completeCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/completeCartWorkflow/index.html.md) to attach the personalized data to the Order Module's `LineItem` data model. + +- [Create a Workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md): Learn how to create a workflow. +- [Create API Route](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md): Learn how to create an API route. +- [Consume a Hook](https://docs.medusajs.com/docs/learn/fundamentals/workflows/workflow-hooks/index.html.md): Learn how to create a hook. + +*** + +## Fulfill Personalized Products in Orders + +The Fulfillment Module handles all logic related to fulfilling orders. It also supports using fulfillment module providers that implement the logic of fulfilling orders with third-party services. + +To fulfill your personalized products with a third-party service or custom logic, you can create a fulfillment module provider. + +The Fulfillment Module registers your fulfillment module provider to use it to fulfill orders. + +- [Fulfillment Module](https://docs.medusajs.com/commerce-modules/fulfillment/index.html.md): Learn about the Fulfillment Module. +- [Create Fulfillment Module Provider](https://docs.medusajs.com/references/fulfillment/provider/index.html.md): Learn how to create a fulfillment module provider. + + +# Point-of-Sale (POS) Recipe + +This recipe provides the general steps to build a Point of Sale (POS) system with Medusa. + +## Overview + +Building a POS system on top of an ecommerce engine introduces challenges related to the tech stack used, data sync across channels, and feature availability relevant to offline sales in a POS, not just online sales. + +Medusa's modular architecture solves these challenges. Any frontend can utilize Medusa's commerce features, such as sales channels or multi-warehouse features, through its REST APIs. + +[How Tekla built a POS system with Medusa](https://medusajs.com/blog/tekla-agilo-pos-case/). + +*** + +## Freedom in Choosing Your POS Tech Stack + +When you build a POS system, you choose the programming language or tool you want to use. + +Medusa's modular architecture removes any restrictions you may have while making this choice. Any client or frontend can connect to the Medusa application using its headless REST APIs. + +![POS Tech Stack](https://res.cloudinary.com/dza7lstvk/image/upload/v1709034046/Medusa%20Book/pos-tech-stack_fy8uiu.jpg) + +[Admin REST APIs](https://docs.medusajs.com/api/admin): Check out available Admin REST APIs in Medusa. + +*** + +## Integrate a Barcode Scanner + +POS systems make the checkout process smoother by integrating a barcode scanner. Merchants scan a product by its barcode to check its details or add it to the customer's purchase. + +The Product Module's [ProductVariant](https://docs.medusajs.com/references/product/models/ProductVariant/index.html.md) data model has the properties to implement this integration, mainly the `barcode` attribute. Other notable properties include `ean`, `upc`, and `hs_code`, among others. + +To search through product variants by their barcode, create a custom API Route and call it within your POS. + +![Example flow of integrating a barcode scanner](https://res.cloudinary.com/dza7lstvk/image/upload/v1709034282/Medusa%20Book/pos-scan-barcode_a8j8ew.jpg) + +- [Product Module](https://docs.medusajs.com/commerce-modules/product/index.html.md): Learn about the Product Module. +- [Create API Route](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md): Learn how to create an API Route. + +*** + +## Access Accurate Inventory Details + +As you manage an online and offline store, it's essential to separate inventory quantity across different locations. + +Medusa's [Inventory](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/inventory/index.html.md), [Stock Location](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/stock-location/index.html.md), and [Sales Channel](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/sales-channel/index.html.md) modules allow merchants to manage the inventory items and their availability across locations and sales channels. + +![Using Multi-warehouse features with POS](https://res.cloudinary.com/dza7lstvk/image/upload/v1709034731/Medusa%20Book/pos-multiwarehouse_r1z48x.jpg) + +Merchants can create a sales channel for their online store and a sales channel for their POS system, then manage the inventory quantity of product variants in each channel. + +This also opens the door for other business opportunities, such as an endless aisle experience. + +Suppose a product isn't available in-store but is available in different warehouses. You can allow customers to purchase that item in-store and deliver it to their address. + +- [Inventory Module](https://docs.medusajs.com/commerce-modules/inventory/index.html.md): Learn about the Inventory Module. +- [Stock Location Module](https://docs.medusajs.com/commerce-modules/stock-location/index.html.md): Learn about the Stock Location Module. + +[Sales Channel Module](https://docs.medusajs.com/commerce-modules/sales-channel/index.html.md): Learn about the Sales Channel Module. + +*** + +## Build an Omni-channel Customer Experience + +Using Medusa's [Customer Module](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/customer/index.html.md), you can retrieve a customer's details from the Medusa application and place an order on the POS under their account. The customer can then view their order details on their online profile as if they had placed the order online. + +In addition, using Medusa's [Promotion Module](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/promotion/index.html.md), store operators can create promotions on the fly for customers using the POS system and apply them to their orders. + +You can also create custom modules to provide features for a better customer experience, such as a rewards system or loyalty points. + +- [Loyalty Points Tutorial](https://docs.medusajs.com/how-to-tutorials/tutorials/loyalty-points/index.html.md): Learn how to create a loyalty points system. +- [Customer Module](https://docs.medusajs.com/commerce-modules/customer/index.html.md): Learn about the Customer Module. +- [Promotion Module](https://docs.medusajs.com/commerce-modules/promotion/index.html.md): Learn about the Promotion Module. +- [Create a Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md): Learn how to create a module. + +*** + +## Accept Payment, Place Order, and Use RMA Features + +Medusa's architecture allows you to integrate any third-party payment provider for your POS and online storefront. For example, you can integrate [Stripe Terminal](https://stripe.com/terminal) to accept in-store payments. + +Once you accept the payment, you can place an order in the POS system using the [Draft Order APIs](https://docs.medusajs.com/api/admin#draft-orders). Draft orders provide similar features to an online checkout experience, including discounts, payment processing, and more. + +Then, merchants can view all orders coming from different sales channels using the Medusa Admin. This keeps logistics and order handling consistent between online and in-store customers. + +- [Create a Payment Module Provider](https://docs.medusajs.com/references/payment/provider/index.html.md): Learn how to create a payment module provider. +- [Admin REST APIs](https://docs.medusajs.com/api/admin): Check out available Admin REST APIs. + + +# Subscriptions Recipe + +In this guide, you'll learn how to support subscription purchases with Medusa. + +When you install a Medusa application, you get a fully-fledged commerce platform with support for customizations. While Medusa doesn't provide subscription-based purchases natively, it provides the Framework to support you in implementing this feature. + +In this guide, you'll customize Medusa to implement subscription-based purchases with the following features: + +1. Subscription-based purchases for a specified interval (monthly or yearly) and period. +2. Customize the admin dashboard to view subscriptions and associated orders. +3. Automatic renewal of the subscription. +4. Automatic subscription expiration tracking. +5. Allow customers to view and cancel their subscriptions. + +This guide uses Stripe as an example to capture the subscription payments. You're free to use a different payment provider or implement your payment logic instead. + +This guide provides an example of an approach to implement subscriptions. You're free to choose a different approach using the Medusa Framework. + +- [Subscription Example Repository](https://github.com/medusajs/examples/tree/main/subscription): Find the full code for this recipe example in this repository. +- [OpenApi Specs for Postman](https://res.cloudinary.com/dza7lstvk/raw/upload/v1721125608/OpenApi/Subscriptions_OpenApi_b371x4.yml): Import this OpenApi Specs file into tools like Postman. + +*** + +## Step 1: Install a Medusa Application + +### Prerequisites + +- [Node.js v20+](https://nodejs.org/en/download) +- [Git CLI tool](https://git-scm.com/downloads) +- [PostgreSQL](https://www.postgresql.org/download/) + +Start by installing the Medusa application on your machine with the following command: + +```bash +npx create-medusa-app@latest +``` + +You'll first be asked for the project's name. Then, when you're asked whether you want to install the Next.js Starter Storefront, choose `Y` for yes. + +Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name, and the Next.js Starter Storefront in a directory with the `{project-name}-storefront` name. + +The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). Learn more about Medusa's architecture in [this documentation](https://docs.medusajs.com/docs/learn/introduction/architecture/index.html.md). + +Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. + +Afterwards, you can log in with the new user and explore the dashboard. The Next.js Starter Storefront is also running at `http://localhost:8000`. + +Check out the [troubleshooting guides](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/troubleshooting/create-medusa-app-errors/index.html.md) for help. + +*** + +## Step 2: Configure Stripe Module Provider + +As mentioned in the introduction, you'll use Stripe as the payment provider for the subscription payments. In this step, you'll configure the [Stripe Module Provider](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/payment-provider/stripe/index.html.md) in your Medusa application. + +### Prerequisites + +- [Stripe account](https://stripe.com/) +- [Stripe Secret API Key](https://support.stripe.com/questions/locate-api-keys-in-the-dashboard) + +To add the Stripe Module Provider to the Medusa configurations, add the following to the `medusa-config.ts` file: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // ... + modules: [ + { + resolve: "@medusajs/medusa/payment", + options: { + providers: [ + { + resolve: "@medusajs/medusa/payment-stripe", + id: "stripe", + options: { + apiKey: process.env.STRIPE_API_KEY, + }, + }, + ], + }, + }, + ], +}) +``` + +The Medusa configurations accept a `modules` property to add modules to your application. You'll learn more about modules in the next section. + +You add the Stripe Module Provider to the [Payment Module](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/index.html.md)'s options. Learn more about these options in the [Payment Module's options documentation](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/module-options/index.html.md). + +You also pass an `apiKey` option to the Stripe Module Provider and set its value to an environment variable. So, add the following to your `.env` file: + +```plain +STRIPE_API_KEY=sk_test_51J... +``` + +Where `sk_test_51J...` is your Stripe Secret API Key. + +Learn more about other Stripe options and configurations in the [Stripe Module Provider documentation](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/payment-provider/stripe/index.html.md). + +### Enable Stripe in Regions + +To allow customers to use Stripe during checkout, you must enable it in at least one region. Customers can only choose from payment providers available in their region. You can enable the payment provider in the Medusa Admin dashboard. + +To do that, start the Medusa application: + +```bash npm2yarn +npm run dev +``` + +Then, open the dashboard at `localhost:9000/app`. After you log in: + +1. Go to Settings -> Regions. +2. Click on a region to edit. +3. Click on the icon at the top right of the first section. +4. In the side window that opens, edit the region's payment provider to choose "Stripe (STRIPE)". +5. Once you're done, click the Save button. + +![Choose "Stripe (STRIPE)" in the payment provider dropdown](https://res.cloudinary.com/dza7lstvk/image/upload/v1740654408/Medusa%20Resources/Screenshot_2025-02-27_at_1.06.12_PM_esql1c.png) + +*** + +## Step 3: Create Subscription Module + +Medusa creates commerce features in modules. For example, product features and data models are created in the Product Module. + +You also create custom commerce data models and features in custom modules. They're integrated into the Medusa application similar to Medusa's modules without side effects. + +So, you'll create a subscription module that holds the data models related to a subscription and allows you to manage them. + +Create the directory `src/modules/subscription`. + +### Create Data Models + +Create the file `src/modules/subscription/models/subscription.ts` with the following content: + +```ts title="src/modules/subscription/models/subscription.ts" highlights={subscriptionHighlights} +import { model } from "@medusajs/framework/utils" +import { SubscriptionInterval, SubscriptionStatus } from "../types" + +const Subscription = model.define("subscription", { + id: model.id().primaryKey(), + status: model.enum(SubscriptionStatus) + .default(SubscriptionStatus.ACTIVE), + interval: model.enum(SubscriptionInterval), + period: model.number(), + subscription_date: model.dateTime(), + last_order_date: model.dateTime(), + next_order_date: model.dateTime().index().nullable(), + expiration_date: model.dateTime().index(), + metadata: model.json().nullable(), +}) + +export default Subscription +``` + +This creates a `Subscription` data model that holds a subscription’s details, including: + +- `interval`: indicates whether the subscription is renewed monthly or yearly. +- `period`: a number indicating how many months/years before a new order is created for the subscription. For example, if `period` is `3` and `interval` is `monthly`, then a new order is created every three months. +- `subscription_date`: when the subscription was created. +- `last_order_date`: when the last time a new order was created for the subscription. +- `next_order_date` : when the subscription’s next order should be created. This property is nullable in case the subscription doesn’t have a next date or has expired. +- `expiration_date`: when the subscription expires. +- `metadata`: any additional data can be held in this JSON property. + +Notice that the data models use enums defined in another file. So, create the file `src/modules/subscription/types/index.ts` with the following content: + +```ts title="src/modules/subscription/types/index.ts" +export enum SubscriptionStatus { + ACTIVE = "active", + CANCELED = "canceled", + EXPIRED = "expired", + FAILED = "failed" +} + +export enum SubscriptionInterval { + MONTHLY = "monthly", + YEARLY = "yearly" +} +``` + +### Create Main Service + +Create the module’s main service in the file `src/modules/subscription/service.ts` with the following content: + +```ts title="src/modules/subscription/service.ts" +import { MedusaService } from "@medusajs/framework/utils" +import Subscription from "./models/subscription" + +class SubscriptionModuleService extends MedusaService({ + Subscription, +}) { +} + +export default SubscriptionModuleService +``` + +The main service extends the service factory to provide data-management features on the `Subscription` data model. + +### Create Module Definition File + +Create the file `src/modules/subscription/index.ts` that holds the module’s definition: + +```ts title="src/modules/subscription/index.ts" +import { Module } from "@medusajs/framework/utils" +import SubscriptionModuleService from "./service" + +export const SUBSCRIPTION_MODULE = "subscriptionModuleService" + +export default Module(SUBSCRIPTION_MODULE, { + service: SubscriptionModuleService, +}) +``` + +This sets the module’s name to `subscriptionModuleService` and its main service to `SubscriptionModuleService`. + +### Register Module in Medusa’s Configuration + +Finally, add the module into `medusa-config.ts`: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // ... + modules: [ + // ... + { + resolve: "./src/modules/subscription", + }, + ], +}) +``` + +### Further Read + +- [How to Create a Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md) +- [How to Create a Data Model](https://docs.medusajs.com/docs/learn/fundamentals/modules#1-create-data-model/index.html.md) +- [Learn more about the service factory](https://docs.medusajs.com/docs/learn/fundamentals/modules/service-factory/index.html.md). + +*** + +## Step 4: Define Links + +Modules are isolated in Medusa, making them reusable, replaceable, and integrable in your application without side effects. + +So, you can't have relations between data models in modules. Instead, you define a link between them. + +Links are relations between data models of different modules that maintain the isolation between the modules. + +In this step, you’ll define links between the Subscription Module’s `Subscription` data model and the data models of Medusa’s Commerce Modules: + +1. Link between the `Subscription` data model and the Cart Module's `Cart` model. +2. Link between the `Subscription` data model and the Customer Module's `Customer` model. +3. Link between the `Subscription` data model and the Order Module's `Order` model. + +### Define a Link to Cart + +To link a subscription to the cart used to make the purchase, create the file `src/links/subscription-cart.ts` with the following content: + +```ts title="src/links/subscription-cart.ts" +import { defineLink } from "@medusajs/framework/utils" +import SubscriptionModule from "../modules/subscription" +import CartModule from "@medusajs/medusa/cart" + +export default defineLink( + SubscriptionModule.linkable.subscription, + CartModule.linkable.cart +) +``` + +This defines a link between the `Subscription` data model and the Cart Module’s `Cart` data model. + +When you create a new order for the subscription, you’ll retrieve the linked cart and use the same shipping and payment details the customer supplied when the purchase was made. + +### Define a Link to Customer + +To link a subscription to the customer who purchased it, create the file `src/links/subscription-customer.ts` with the following content: + +```ts title="src/links/subscription-customer.ts" +import { defineLink } from "@medusajs/framework/utils" +import SubscriptionModule from "../modules/subscription" +import CustomerModule from "@medusajs/medusa/customer" + +export default defineLink( + { + linkable: SubscriptionModule.linkable.subscription.id, + isList: true, + }, + CustomerModule.linkable.customer +) +``` + +This defines a list link to the `Subscription` data model, since a customer may have multiple subscriptions. + +### Define a Link to Order + +To link a subscription to the orders created for it, create the file `src/links/subscription-order.ts` with the following content: + +```ts title="src/links/subscription-order.ts" +import { defineLink } from "@medusajs/framework/utils" +import SubscriptionModule from "../modules/subscription" +import OrderModule from "@medusajs/medusa/order" + +export default defineLink( + SubscriptionModule.linkable.subscription, + { + linkable: OrderModule.linkable.order.id, + isList: true, + } +) +``` + +This defines a list link to the `Order` data model since a subscription has multiple orders. + +### Further Reads + +- [How to Define a Link](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md) + +*** + +## Step 5: Run Migrations + +To create a table for the `Subscription` data model in the database, start by generating the migrations for the Subscription Module with the following command: + +```bash +npx medusa db:generate subscriptionModuleService +``` + +This generates a migration in the `src/modules/subscriptions/migrations` directory. + +Then, to reflect the migration and links in the database, run the following command: + +```bash +npx medusa db:migrate +``` + +*** + +## Step 6: Override createSubscriptions Method in Service + +Since the Subscription Module’s main service extends the service factory, it has a generic `createSubscriptions` method that creates one or more subscriptions. + +In this step, you’ll override it to add custom logic to the subscription creation that sets its date properties. + +### Install moment Library + +Before you start, install the [Moment.js library](https://momentjs.com/) to help manipulate and format dates with the following command: + +```bash npm2yarn +npm install moment +``` + +### Add getNextOrderDate Method + +In `src/modules/subscription/service.ts`, add the following method to `SubscriptionModuleService`: + +```ts title="src/modules/subscription/service.ts" highlights={getNextOrderDateHighlights} +// ... +import moment from "moment" +import { + CreateSubscriptionData, + SubscriptionData, + SubscriptionInterval, +} from "./types" + +class SubscriptionModuleService extends MedusaService({ + Subscription, +}) { + getNextOrderDate({ + last_order_date, + expiration_date, + interval, + period, + }: { + last_order_date: Date + expiration_date: Date + interval: SubscriptionInterval, + period: number + }): Date | null { + const nextOrderDate = moment(last_order_date) + .add( + period, + interval === SubscriptionInterval.MONTHLY ? + "month" : "year" + ) + const expirationMomentDate = moment(expiration_date) + + return nextOrderDate.isAfter(expirationMomentDate) ? + null : nextOrderDate.toDate() + } +} +``` + +This method accepts a subscription’s last order date, expiration date, interval, and period, and uses them to calculate and return the next order date. + +If the next order date, calculated from the last order date, exceeds the expiration date, `null` is returned. + +### Add getExpirationDate Method + +In the same file, add the following method to `SubscriptionModuleService`: + +```ts title="src/modules/subscription/service.ts" +class SubscriptionModuleService extends MedusaService({ + Subscription, +}) { + // ... + getExpirationDate({ + subscription_date, + interval, + period, + }: { + subscription_date: Date, + interval: SubscriptionInterval, + period: number + }) { + return moment(subscription_date) + .add( + period, + interval === SubscriptionInterval.MONTHLY ? + "month" : "year" + ).toDate() + } +} +``` + +The `getExpirationDate` method accepts a subscription’s date, interval, and period to calculate and return its expiration date. + +### Override the createSubscriptions Method + +Before overriding the `createSubscriptions` method, add the following types to `src/modules/subscription/types/index.ts`: + +```ts title="src/modules/subscription/types/index.ts" +import { InferTypeOf } from "@medusajs/framework/types" +import Subscription from "../models/subscription" + +// ... + +export type CreateSubscriptionData = { + interval: SubscriptionInterval + period: number + status?: SubscriptionStatus + subscription_date?: Date + metadata?: Record +} + +export type SubscriptionData = InferTypeOf +``` + +Since the `Subscription` data model is a variable, use `InferTypeOf` to infer its type. + +Then, in `src/modules/subscription/service.ts`, add the following to override the `createSubscriptions` method: + +```ts title="src/modules/subscription/service.ts" +class SubscriptionModuleService extends MedusaService({ + Subscription, +}) { + // ... + + // @ts-expect-error + async createSubscriptions( + data: CreateSubscriptionData | CreateSubscriptionData[] + ): Promise { + const input = Array.isArray(data) ? data : [data] + + const subscriptions = await Promise.all( + input.map(async (subscription) => { + const subscriptionDate = subscription.subscription_date || new Date() + const expirationDate = this.getExpirationDate({ + subscription_date: subscriptionDate, + interval: subscription.interval, + period: subscription.period, + }) + + return await super.createSubscriptions({ + ...subscription, + subscription_date: subscriptionDate, + last_order_date: subscriptionDate, + next_order_date: this.getNextOrderDate({ + last_order_date: subscriptionDate, + expiration_date: expirationDate, + interval: subscription.interval, + period: subscription.period, + }), + expiration_date: expirationDate, + }) + }) + ) + + return subscriptions + } +} +``` + +The `createSubscriptions` calculates for each subscription the expiration and next order dates using the methods created earlier. It creates and returns the subscriptions. + +This method is used in the next step. + +*** + +## Step 7: Create Subscription Workflow + +To implement and expose a feature that manipulates data, you create a workflow that uses services to implement the functionality, then create an API route that executes that workflow. + +In this step, you’ll create the workflow that you’ll execute when a customer purchases a subscription. + +The workflow accepts a cart’s ID, and it has three steps: + +1. Create the order from the cart. +2. Create a subscription. +3. Link the subscription to the order, cart, and customer. + +Medusa provides the first and last steps in the `@medusajs/medusa/core-flows` package, so you only need to implement the second step. + +### Create a Subscription Step (Second Step) + +Create the file `src/workflows/create-subscription/steps/create-subscription.ts` with the following content: + +```ts title="src/workflows/create-subscription/steps/create-subscription.ts" highlights={createSubscriptionsHighlights} collapsibleLines="1-7" expandMoreLabel="Show Imports" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { Modules } from "@medusajs/framework/utils" +import { LinkDefinition } from "@medusajs/framework/types" +import { SubscriptionInterval } from "../../../modules/subscription/types" +import SubscriptionModuleService from "../../../modules/subscription/service" +import { SUBSCRIPTION_MODULE } from "../../../modules/subscription" + +type StepInput = { + cart_id: string + order_id: string + customer_id?: string + subscription_data: { + interval: SubscriptionInterval + period: number + } +} + +const createSubscriptionStep = createStep( + "create-subscription", + async ({ + cart_id, + order_id, + customer_id, + subscription_data, + }: StepInput, { container }) => { + const subscriptionModuleService: SubscriptionModuleService = + container.resolve(SUBSCRIPTION_MODULE) + const linkDefs: LinkDefinition[] = [] + + const subscription = await subscriptionModuleService.createSubscriptions({ + ...subscription_data, + metadata: { + main_order_id: order_id, + }, + }) + + // TODO add links + + return new StepResponse({ + subscription: subscription[0], + linkDefs, + }, { + subscription: subscription[0], + }) + }, async ({ subscription }, { container }) => { + // TODO implement compensation + } +) + +export default createSubscriptionStep +``` + +This step receives the IDs of the cart, order, and customer, along with the subscription’s details. + +In this step, you use the `createSubscriptions` method to create the subscription. In the `metadata` property, you set the ID of the order created on purchase. + +The step returns the created subscription as well as an array of links to create. To add the links to be created in the returned array, replace the first `TODO` with the following: + +```ts title="src/workflows/create-subscription/steps/create-subscription.ts" highlights={createSubscriptionsLinkHighlights} +linkDefs.push({ + [SUBSCRIPTION_MODULE]: { + "subscription_id": subscription[0].id, + }, + [Modules.ORDER]: { + "order_id": order_id, + }, +}) + +linkDefs.push({ + [SUBSCRIPTION_MODULE]: { + "subscription_id": subscription[0].id, + }, + [Modules.CART]: { + "cart_id": cart_id, + }, +}) + +if (customer_id) { + linkDefs.push({ + [SUBSCRIPTION_MODULE]: { + "subscription_id": subscription[0].id, + }, + [Modules.CUSTOMER]: { + "customer_id": customer_id, + }, + }) +} +``` + +This adds links between: + +1. The subscription and the order. +2. The subscription and the cart. +3. The subscription and the customer, if a customer is associated with the cart. + +The step also has a compensation function to undo the step’s changes if an error occurs. So, replace the second `TODO` with the following: + +```ts title="src/workflows/create-subscription/steps/create-subscription.ts" +const subscriptionModuleService: SubscriptionModuleService = + container.resolve(SUBSCRIPTION_MODULE) + +await subscriptionModuleService.cancelSubscriptions(subscription.id) +``` + +The compensation function receives the subscription as a parameter. It cancels the subscription. + +### Create Workflow + +Create the file `src/workflows/create-subscription/index.ts` with the following content: + +```ts title="src/workflows/create-subscription/index.ts" highlights={createSubscriptionWorkflowHighlights} collapsibleLines="1-13" expandMoreLabel="Show Imports" +import { + createWorkflow, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { + createRemoteLinkStep, + completeCartWorkflow, + useQueryGraphStep, +} from "@medusajs/medusa/core-flows" +import { + SubscriptionInterval, +} from "../../modules/subscription/types" +import createSubscriptionStep from "./steps/create-subscription" + +type WorkflowInput = { + cart_id: string, + subscription_data: { + interval: SubscriptionInterval + period: number + } +} + +const createSubscriptionWorkflow = createWorkflow( + "create-subscription", + (input: WorkflowInput) => { + const { id } = completeCartWorkflow.runAsStep({ + input: { + id: input.cart_id, + }, + }) + + const { data: orders } = useQueryGraphStep({ + entity: "order", + fields: [ + "id", + "status", + "summary", + "currency_code", + "customer_id", + "display_id", + "region_id", + "email", + "total", + "subtotal", + "tax_total", + "discount_total", + "discount_subtotal", + "discount_tax_total", + "original_total", + "original_tax_total", + "item_total", + "item_subtotal", + "item_tax_total", + "original_item_total", + "original_item_subtotal", + "original_item_tax_total", + "shipping_total", + "shipping_subtotal", + "shipping_tax_total", + "original_shipping_tax_total", + "original_shipping_subtotal", + "original_shipping_total", + "created_at", + "updated_at", + "credit_lines.*", + "items.*", + "items.tax_lines.*", + "items.adjustments.*", + "items.detail.*", + "items.variant.*", + "items.variant.product.*", + "shipping_address.*", + "billing_address.*", + "shipping_methods.*", + "shipping_methods.tax_lines.*", + "shipping_methods.adjustments.*", + "payment_collections.*", + ], + filters: { + id, + }, + options: { + throwIfKeyNotFound: true, + }, + }) + + 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 new WorkflowResponse({ + subscription: subscription, + order: orders[0], + }) + } +) + +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](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md) 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. + +The workflow returns the created subscription and order. + +### Further Reads + +- [How to Create a Workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md) +- [Learn more about the compensation function](https://docs.medusajs.com/docs/learn/fundamentals/workflows/compensation-function/index.html.md) +- [How to use Link](https://docs.medusajs.com/docs/learn/fundamentals/module-links/link/index.html.md) + +*** + +## Step 8: Custom Complete Cart API Route + +To create a subscription when a customer completes their purchase, you need to expose an endpoint that executes the subscription workflow. To do that, you'll create an API route. + +An [API Route](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md) is an endpoint that exposes commerce features to external applications and clients, such as storefronts. + +In this step, you’ll create a custom API route similar to the [Complete Cart API route](https://docs.medusajs.com/api/store#carts_postcartsidcomplete) that uses the workflow you previously created to complete the customer's purchase and create a subscription. + +Create the file `src/api/store/carts/[id]/subscribe/route.ts` with the following content: + +```ts title="src/api/store/carts/[id]/subscribe/route.ts" highlights={completeCartHighlights} collapsibleLines="1-10" expandMoreLabel="Show Imports" +import { + MedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { + ContainerRegistrationKeys, + MedusaError, +} from "@medusajs/framework/utils" +import createSubscriptionWorkflow from "../../../../../workflows/create-subscription" + +export const POST = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: [cart] } = await query.graph({ + entity: "cart", + fields: [ + "metadata", + ], + filters: { + id: [req.params.id], + }, + }) + + const { metadata } = cart + + if (!metadata?.subscription_interval || !metadata.subscription_period) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Please set the subscription's interval and period first." + ) + } + + const { result } = await createSubscriptionWorkflow( + req.scope + ).run({ + input: { + cart_id: req.params.id, + subscription_data: { + interval: metadata.subscription_interval, + period: metadata.subscription_period, + }, + }, + }) + + res.json({ + type: "order", + ...result, + }) +} +``` + +Since the file exports a `POST` function, you're exposing a `POST` API route at `/store/carts/[id]/subscribe`. + +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. + +In the next step, you'll customize the Next.js Starter Storefront, allowing you to test out the subscription feature. + +### Further Reads + +- [How to Create an API Route](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md) + +*** + +## Intermission: Payment Flow Overview + +Before continuing with the customizations, you must understand the payment changes you need to make to support subscriptions. In this guide, you'll get a general overview, but you can refer to the [Payment in Storefront documentation](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/storefront-development/checkout/payment/index.html.md) for more details. + +By default, the checkout flow requires you to create a [payment collection](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/payment-collection/index.html.md), then a [payment session](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/payment-session/index.html.md) in that collection. When you create the payment session, that subsequently performs the necessary action to initialize the payment in the payment provider. For example, it creates a payment intent in Stripe. + +![Diagram showcasing payment flow overview](https://res.cloudinary.com/dza7lstvk/image/upload/v1740657136/Medusa%20Resources/subscriptions-stripe_ycw4ig.jpg) + +To support subscriptions, you need to support capturing the payment each time the subscription renews. When creating the payment session using the [Initialize Payment Session API route](https://docs.medusajs.com/api/store#payment-collections_postpaymentcollectionsidpaymentsessions), you must pass the data that your payment provider requires to support capturing the payment again in the future. You can pass the data that the provider requires in the `data` property. + +If you're using a custom payment provider, you can handle that additional data in the [initiatePayment method](https://docs.medusajs.com/references/payment/provider#initiatepayment/index.html.md) of your provider's service. + +When you create the payment session, Medusa creates an account holder for the customer. An account holder represents a customer's saved payment information, including saved methods, in a third-party provider and may hold data from that provider. Learn more in the [Account Holder](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/account-holder/index.html.md) documentation. + +The account holder allows you to retrieve the saved payment method and use it to capture the payment when the subscription renews. You'll see how this works later when you implement the logic to renew the subscription. + +*** + +## Step 9: Add Subscriptions to Next.js Starter Storefront + +In this step, you'll customize the checkout flow in the [Next.js Starter storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md), which you installed in the first step, to: + +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). + +### Add Subscription Step + +Start by adding the function to update the subscription data in the cart. Add to the file `src/lib/data/cart.ts` the following: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="orange" +export enum SubscriptionInterval { + MONTHLY = "monthly", + YEARLY = "yearly" +} + +export async function updateSubscriptionData( + subscription_interval: SubscriptionInterval, + subscription_period: number +) { + const cartId = getCartId() + + if (!cartId) { + throw new Error("No existing cart found when placing an order") + } + + await updateCart({ + metadata: { + subscription_interval, + subscription_period, + }, + }) + revalidateTag("cart") +} +``` + +This updates the cart's `metadata` with the subscription details. + +Then, you'll add the subscription form that shows as part of the checkout to select the subscription interval and period. Create the file `src/modules/checkout/components/subscriptions/index.tsx` with the following content: + +```tsx title="src/modules/checkout/components/subscriptions/index.tsx" badgeLabel="Storefront" badgeColor="orange" collapsibleLines="1-12" expandMoreLabel="Show Imports" +"use client" + +import { Button, clx, Heading, Text } from "@medusajs/ui" +import { CheckCircleSolid } from "@medusajs/icons" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { useCallback, useState } from "react" +import Divider from "../../../common/components/divider" +import Input from "../../../common/components/input" +import NativeSelect from "../../../common/components/native-select" +import { capitalize } from "lodash" +import { updateSubscriptionData } from "../../../../lib/data/cart" + +export enum SubscriptionInterval { + MONTHLY = "monthly", + YEARLY = "yearly" +} + +const SubscriptionForm = () => { + const [interval, setInterval] = useState( + SubscriptionInterval.MONTHLY + ) + const [period, setPeriod] = useState(1) + const [isLoading, setIsLoading] = useState(false) + + const searchParams = useSearchParams() + const router = useRouter() + const pathname = usePathname() + + const isOpen = searchParams.get("step") === "subscription" + + const createQueryString = useCallback( + (name: string, value: string) => { + const params = new URLSearchParams(searchParams) + params.set(name, value) + + return params.toString() + }, + [searchParams] + ) + + const handleEdit = () => { + router.push(pathname + "?" + createQueryString("step", "subscription"), { + scroll: false, + }) + } + + const handleSubmit = async () => { + setIsLoading(true) + + updateSubscriptionData(interval, period) + .then(() => { + setIsLoading(false) + router.push(pathname + "?step=delivery", { scroll: false }) + }) + } + + return ( +
    +
    + + Subscription Details + {!isOpen && } + + {!isOpen && ( + + + + )} +
    +
    +
    +
    + + setInterval(e.target.value as SubscriptionInterval) + } + required + autoComplete="interval" + > + {Object.values(SubscriptionInterval).map( + (intervalOption, index) => ( + + ) + )} + + + setPeriod(parseInt(e.target.value)) + } + required + type="number" + /> +
    + + +
    +
    + +
    + ) +} + +export default SubscriptionForm +``` + +This adds a component that displays a form to choose the subscription's interval and period during checkout. When the customer submits the form, you use the `updateSubscriptionData` function that sends a request to the Medusa application to update the cart with the subscription details. + +Next, you want the subscription step to show after the address step. So, change the last line of the `setAddresses` function in `src/lib/data/cart.ts` to redirect to the subscription step once the customer enters their address: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="orange" +export async function setAddresses(currentState: unknown, formData: FormData) { + // ... + redirect( + `/${formData.get("shipping_address.country_code")}/checkout?step=subscription` + ) +} +``` + +And to show the subscription form during checkout, add the `SubscriptionForm` in `src/modules/checkout/templates/checkout-form/index.tsx` after the `Addresses` wrapper component: + +```tsx title="src/modules/checkout/templates/checkout-form/index.tsx" badgeLabel="Storefront" badgeColor="orange" +// other imports... +import SubscriptionForm from "@modules/checkout/components/subscriptions" + +export default async function CheckoutForm({ + cart, + customer, +}: { + cart: HttpTypes.StoreCart | null + customer: HttpTypes.StoreCustomer | null +}) { + // ... + + return ( +
    + {/* ... */} + {/* After Addresses, before Shipping */} +
    + +
    + {/* ... */} +
    + ) +} +``` + +### Pass Additional Data to Payment Provider + +As explained in the [Payment Flow Overview](#intermission-payment-flow-overview), you need to pass additional data to the payment provider to support subscriptions. + +For Stripe, you need to pass the `setup_future_usage` property in the `data` object when you create the payment session. This property allows you to capture the payment in the future, as explained in [Stripe's documentation](https://docs.stripe.com/payments/payment-intents#future-usage). + +To pass this data, you'll make changes in the `src/modules/checkout/components/payment/index.tsx` file. In this file, the `initiatePaymentSession` is used in two places. In each of them, pass the `data` property as follows: + +```tsx title="src/modules/checkout/components/payment/index.tsx" badgeLabel="Storefront" badgeColor="orange" +await initiatePaymentSession(cart, { + provider_id: method, + data: { + setup_future_usage: "off_session", + }, +}) +``` + +If you're integrating with a custom payment provider, you can instead pass the required data for that provider in the `data` object. + +The payment method can now be used later to capture the payment when the subscription renews. + +### Test Cart Completion and Subscription Creation + +To test out the cart completion flow: + +1. In the Medusa application's directory, run the following command to start the application: + +```bash npm2yarn +npm run dev +``` + +2. In the Next.js Starter's directory, run the following command to start the storefront: + +```bash npm2yarn +npm run dev +``` + +3. Add a product to the cart and place an order. During checkout, you'll see a Subscription Details step to fill out the interval and period. + +*** + +## Step 10: Add Admin API Routes for Subscription + +In this step, you’ll add two API routes for admin users: + +1. One to list all subscriptions. +2. One to retrieve a subscription. + +### List Subscriptions Admin API Route + +The list subscriptions API route should allow clients to retrieve subscriptions with pagination. An API route can be configured to accept pagination fields, such as `limit` and `offset`, then use them with [Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md) to paginate the retrieved data. + +You'll start with the API route. To create it, create the file `src/api/admin/subscriptions/route.ts` with the following content: + +```ts title="src/api/admin/subscriptions/route.ts" highlights={listSubscriptionsAdminHighlight} +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { + data: subscriptions, + metadata: { count, take, skip }, + } = await query.graph({ + entity: "subscription", + ...req.queryConfig, + }) + + res.json({ + subscriptions, + count, + limit: take, + offset: skip, + }) +} +``` + +This adds a `GET` API route at `/admin/subscriptions`. In the route handler, you use Query to retrieve the subscriptions. Notice that you pass the `req.queryConfig` object to the `query.graph` method. This object contains the pagination fields, such as `limit` and `offset`, which are combined from the configurations you'll add in the middleware, and the optional query parameters in the request. + +Then, you return the subscriptions, along with the: + +- `count`: The total number of subscriptions. +- `limit`: The maximum number of subscriptions returned. +- `offset`: The number of subscriptions skipped before retrieving the subscriptions. + +These fields are useful for clients to paginate the subscriptions. + +### Add Query Configuration Middleware + +To configure the pagination and retrieved fields within the route handler, and to allow passing query parameters that change these configurations in the request, you need to add the `validateAndTransformQuery` [middleware](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/middlewares/index.html.md) to the route. + +To add a middleware, create the file `src/api/middlewares.ts` with the following content: + +```ts title="src/api/middlewares.ts" +import { + validateAndTransformQuery, + defineMiddlewares, +} from "@medusajs/framework/http" +import { createFindParams } from "@medusajs/medusa/api/utils/validators" + +export const GetCustomSchema = createFindParams() + +export default defineMiddlewares({ + routes: [ + { + matcher: "/admin/subscriptions", + method: "GET", + middlewares: [ + validateAndTransformQuery( + GetCustomSchema, + { + defaults: [ + "id", + "subscription_date", + "expiration_date", + "status", + "metadata.*", + "orders.*", + "customer.*", + ], + isList: true, + } + ), + ], + }, + ], +}) +``` + +You add the `validateAndTransformQuery` middleware to `GET` requests sent to routes starting with `/admin/subscriptions`. This middleware accepts the following parameters: + +- A validation schema indicating which query parameters are accepted. You create the schema with [Zod](https://zod.dev/). Medusa has a createFindParams utility that generates a Zod schema accepting four query parameters: + - `fields`: The fields and relations to retrieve in the returned resources. + - `offset`: The number of items to skip before retrieving the returned items. + - `limit`: The maximum number of items to return. + - `order`: The fields to order the returned items by in ascending or descending order. +- A Query configuration object. It accepts the following properties: + - `defaults`: An array of default fields and relations to retrieve in each resource. + - `isList`: A boolean indicating whether a list of items are returned in the response. + +The middleware combines your default configurations with the query parameters in the request to determine the fields to retrieve and the pagination settings. + +Refer to the [Request Query Configuration](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query#request-query-configurations/index.html.md) documentation to learn more about this middleware and the query configurations. + +### Get Subscription Admin API Route + +Next, you'll add the API route to retrieve a single subscription. So, create the file `src/api/admin/subscriptions/[id]/route.ts` with the following content: + +```ts title="src/api/admin/subscriptions/[id]/route.ts" highlights={getSubscriptionsAdminHighlight} +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: [subscription] } = await query.graph({ + entity: "subscription", + fields: [ + "*", + "orders.*", + "customer.*", + ], + filters: { + id: [req.params.id], + }, + }) + + res.json({ + subscription, + }) +} +``` + +This adds a `GET` API route at `/admin/subscriptions/[id]`, where `[id]` is the ID of the subscription to retrieve. + +In the route handler, you retrieve a subscription by its ID using Query and return it in the response. + +You can also use Query configuration as explained for the previous route. + +In the next section, you’ll extend the Medusa Admin and use these API routes to show the subscriptions. + +*** + +## Step 11: Extend Admin + +The Medusa Admin is customizable, allowing you to inject widgets into existing pages or add UI routes to create new pages. + +In this step, you’ll add two UI routes: + +1. One to view all subscriptions. +2. One to view a single subscription. + +### Create Types File + +Before creating the UI routes, create the file `src/admin/types/index.ts` that holds types used by the UI routes: + +```ts title="src/admin/types/index.ts" +import { + OrderDTO, + CustomerDTO, +} from "@medusajs/framework/types" + +export enum SubscriptionStatus { + ACTIVE = "active", + CANCELED = "canceled", + EXPIRED = "expired", + FAILED = "failed" +} + +export enum SubscriptionInterval { + MONTHLY = "monthly", + YEARLY = "yearly" +} + +export type SubscriptionData = { + id: string + status: SubscriptionStatus + interval: SubscriptionInterval + subscription_date: string + last_order_date: string + next_order_date: string | null + expiration_date: string + metadata: Record | null + orders?: OrderDTO[] + customer?: CustomerDTO +} + +``` + +You define types for the subscription status and interval, as well as a `SubscriptionData` type that represents the subscription data. The `SubscriptionData` type includes the subscription's ID, status, interval, dates, metadata, and related orders and customer. You'll use these types for the subscriptions retrieved from the server. + +### Configure JS SDK + +Medusa provides a [JS SDK](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/js-sdk/index.html.md) that facilitates sending requests to the server. You can use this SDK in any JavaScript client-side application, including your admin customizations. + +To configure the JS SDK, create the file `src/admin/lib/sdk.ts` with the following content: + +```ts title="src/admin/lib/sdk.ts" +import Medusa from "@medusajs/js-sdk" + +export const sdk = new Medusa({ + baseUrl: import.meta.env.VITE_BACKEND_URL || "/", + debug: import.meta.env.DEV, + auth: { + type: "session", + }, +}) +``` + +This initializes the SDK, setting the following options: + +- `baseUrl`: The URL of the Medusa server. You use the Vite environment variable `VITE_BACKEND_URL`. +- `debug`: A boolean indicating whether to log debug information. You use the Vite environment variable `DEV`. +- `auth`: An object indicating the authentication type. You use the session authentication type, which is the recommended approach for admin customizations. + +Learn more about other customizations in the [JS SDK documentation](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/js-sdk/index.html.md). + +### Create Subscriptions List UI Route + +You'll now create the subscriptions list UI route. Since you'll show the subscriptions in a table, you'll use the [DataTable component](https://docs.medusajs.com/ui/components/data-table/index.html.md) from Medusa UI. It facilitates displaying data in a tabular format with sorting, filtering, and pagination. + +Start by creating the file `src/admin/routes/subscriptions/page.tsx` with the following content: + +```tsx title="src/admin/routes/subscriptions/page.tsx" highlights={list1Highlights} collapsibleLines="1-9" expandMoreLabel="Show Imports" +import { defineRouteConfig } from "@medusajs/admin-sdk" +import { ClockSolid } from "@medusajs/icons" +import { Container, Heading, Badge, createDataTableColumnHelper, useDataTable, DataTablePaginationState, DataTable } from "@medusajs/ui" +import { useMemo, useState } from "react" +import { SubscriptionData, SubscriptionStatus } from "../../types" +import { useQuery } from "@tanstack/react-query" +import { sdk } from "../../lib/sdk" +import { useNavigate } from "react-router-dom" + +const getBadgeColor = (status: SubscriptionStatus) => { + switch(status) { + case SubscriptionStatus.CANCELED: + return "orange" + case SubscriptionStatus.FAILED: + return "red" + case SubscriptionStatus.EXPIRED: + return "grey" + default: + return "green" + } +} + +const getStatusTitle = (status: SubscriptionStatus) => { + return status.charAt(0).toUpperCase() + + status.substring(1) +} + +const columnHelper = createDataTableColumnHelper() + +const columns = [ + columnHelper.accessor("id", { + header: "#", + }), + columnHelper.accessor("metadata.main_order_id", { + header: "Main Order", + }), + columnHelper.accessor("customer.email", { + header: "Customer", + }), + columnHelper.accessor("subscription_date", { + header: "Subscription Date", + cell: ({ getValue }) => { + return getValue().toLocaleString() + }, + }), + columnHelper.accessor("expiration_date", { + header: "Expiry Date", + cell: ({ getValue }) => { + return getValue().toLocaleString() + }, + }), + columnHelper.accessor("status", { + header: "Status", + cell: ({ getValue }) => { + return ( + + {getStatusTitle(getValue())} + + ) + }, + }), +] + +const SubscriptionsPage = () => { + // TODO add implementation +} + +export const config = defineRouteConfig({ + label: "Subscriptions", + icon: ClockSolid, +}) + +export default SubscriptionsPage +``` + +First, you define two helper functions: `getBadgeColor` to get the color for the status badge, and `getStatusTitle` to capitalize the status for the badge text. + +Then, you use the `createDataTableColumnHelper` utility to create a column helper. This utility simplifies defining columns for the data table. You define the columns for the data table using the helper, specifying the accessor, header, and cell for each column. + +The UI route file must export a React component, `SubscriptionsPage`, that shows the content of the UI route. You'll implement this component in a bit. + +Finally, you can export the route configuration using the `defineRouteConfig` function, which shows the UI route in the sidebar with the specified label and icon. + +In the `SubscriptionsPage` component, you'll fetch the subscriptions and show them in a data table. So, replace the `SubscriptionsPage` component with the following: + +```tsx title="src/admin/routes/subscriptions/page.tsx" +const SubscriptionsPage = () => { + const navigate = useNavigate() + const [pagination, setPagination] = useState({ + pageSize: 4, + pageIndex: 0, + }) + + const query = useMemo(() => { + return new URLSearchParams({ + limit: `${pagination.pageSize}`, + offset: `${pagination.pageIndex * pagination.pageSize}`, + }) + }, [pagination]) + + const { data, isLoading } = useQuery<{ + subscriptions: SubscriptionData[], + count: number + }>({ + queryFn: () => sdk.client.fetch(`/admin/subscriptions?${query.toString()}`), + queryKey: ["subscriptions", query.toString()], + }) + + const table = useDataTable({ + columns, + data: data?.subscriptions || [], + getRowId: (subscription) => subscription.id, + rowCount: data?.count || 0, + isLoading, + pagination: { + state: pagination, + onPaginationChange: setPagination, + }, + onRowClick(event, row) { + navigate(`/subscriptions/${row.id}`) + }, + }) + + + return ( + + + + Subscriptions + + + {/** This component will render the pagination controls **/} + + + + ) +} +``` + +In the component, you first initialize a `pagination` state variable of type `DataTablePaginationState`. This is necessary for the table to manage pagination. + +Then, you use the `useQuery` hook from the `@tanstack/react-query` package to fetch the subscriptions. In the query function, you use the JS SDK to send a request to the `/admin/subscriptions` API route with the pagination query parameters. + +Next, you use the `useDataTable` hook to create a data table instance. You pass the columns, subscriptions data, row count, loading state, and pagination settings to the hook. You also navigate to the subscription details page when a row is clicked. + +Finally, you render the data table with the subscriptions data, along with the pagination controls. + +The subscriptions UI route will now show a table of subscriptions, and when you click on the ID of any of them, you can view its individual page that you'll create next. + +### Create a Single Subscription UI Route + +To create the UI route or page that shows the details of a single subscription, create the file `src/admin/routes/subscriptions/[id]/page.tsx` with the following content: + +```tsx title="src/admin/routes/subscriptions/[id]/page.tsx" +import { + Container, + Heading, + Table, +} from "@medusajs/ui" +import { useParams, Link } from "react-router-dom" +import { SubscriptionData } from "../../../types/index.js" +import { useQuery } from "@tanstack/react-query" +import { sdk } from "../../../lib/sdk.js" + +const SubscriptionPage = () => { + const { id } = useParams() + const { data, isLoading } = useQuery<{ + subscription: SubscriptionData + }>({ + queryFn: () => sdk.client.fetch(`/admin/subscriptions/${id}`), + queryKey: ["subscription", id], + }) + + return ( + + {isLoading && Loading...} + {data?.subscription && ( + <> + Orders of Subscription #{data.subscription.id} + + + + # + Date + View Order + + + + {data.subscription.orders?.map((order) => ( + + {order.id} + {(new Date(order.created_at)).toDateString()} + + + View Order + + + + ))} + +
    + + )} +
    + ) +} + +export default SubscriptionPage +``` + +This creates the React component used to display a subscription’s details page. Again, you use the `useQuery` hook to fetch the subscription data using the JS SDK. You pass the subscription ID from the route parameters to the hook. + +Then, you render the subscription’s orders in a table. For each order, you show the ID, date, and a link to view the order. + +### Test the UI Routes + +To test the UI routes, run the Medusa application and go to `http://localhost:9000/app`. + +After you log-in, you’ll find a new sidebar item “Subscriptions”. Once you click on it, you’ll see the list of subscription purchases. + +To view a subscription’s details, click on its row, which opens the subscription details page. This page contains the subscription’s orders. + +### Further Reads + +- [How to Create UI Routes](https://docs.medusajs.com/docs/learn/fundamentals/admin/ui-routes/index.html.md) +- [DataTable component](https://docs.medusajs.com/ui/components/data-table/index.html.md) + +*** + +## Step 12: Create New Subscription Orders Workflow + +In this step, you’ll create a workflow to create a new subscription order. Later, you’ll execute this workflow in a scheduled job. + +The workflow has eight steps: + +```mermaid +graph TD + useQueryGraphStep["Retrieve Cart (useQueryGraphStep by Medusa)"] --> createPaymentCollectionStep["createPaymentCollectionStep (Medusa)"] + createPaymentCollectionStep["createPaymentCollectionStep (Medusa)"] --> getPaymentMethodStep + getPaymentMethodStep --> createPaymentSessionsWorkflow["createPaymentSessionsWorkflow (Medusa)"] + createPaymentSessionsWorkflow["createPaymentSessionsWorkflow (Medusa)"] --> authorizePaymentSessionStep["authorizePaymentSessionStep (Medusa)"] + authorizePaymentSessionStep["authorizePaymentSessionStep (Medusa)"] --> createSubscriptionOrderStep + createSubscriptionOrderStep --> createRemoteLinkStep["Create Links (createRemoteLinkStep by Medusa)"] + createRemoteLinkStep["Create Links (createRemoteLinkStep by Medusa)"] --> capturePaymentStep["capturePaymentStep (Medusa)"] + capturePaymentStep["capturePaymentStep (Medusa)"] --> updateSubscriptionStep +``` + +1. Retrieve the subscription’s linked cart. Medusa provides a `useQueryGraphStep` in the `@medusajs/medusa/core-flows` package that can be used as a step. +2. Create a payment collection for the new order. Medusa provides a `createPaymentCollectionsStep` in the `@medusajs/medusa/core-flows` package that you can use. +3. Get the customer's saved payment method. This payment method will be used to charge the customer. +4. Create payment sessions in the payment collection. Medusa provides a `createPaymentSessionsWorkflow` in the `@medusajs/medusa/core-flows` package that can be used as a step. +5. Authorize the payment session. Medusa also provides the `authorizePaymentSessionStep` in the `@medusajs/medusa/core-flows` package, which can be used. +6. Create the subscription’s new order. +7. Create links between the subscription and the order using the `createRemoteLinkStep` provided in the `@medusajs/medusa/core-flows` package. +8. Capture the order’s payment using the `capturePaymentStep` provided by Medusa in the `@medusajs/medusa/core-flows` package. +9. Update the subscription’s `last_order_date` and `next_order_date` properties. + +You’ll only implement the third, sixth, and ninth steps. + +### Create getPaymentMethodStep (Third Step) + +To charge the customer using their payment method saved in Stripe, you need to retrieve that payment method. As explained in the [Payment Flow Overview](#intermission-payment-flow-overview), you customized the storefront to pass the `setup_future_usage` option to Stripe. So, the payment method was saved in Stripe and linked to the customer's [account holder](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/account-holder/index.html.md), allowing you to retrieve it later and re-capture the payment. + +To create the step, create the file `src/workflows/create-subscription-order/steps/get-payment-method.ts` with the following content: + +```tsx title="src/workflows/create-subscription-order/steps/get-payment-method.ts" +import { MedusaError, Modules } from "@medusajs/framework/utils" +import { AccountHolderDTO, CustomerDTO, PaymentMethodDTO } from "@medusajs/framework/types" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" + +export interface GetPaymentMethodStepInput { + customer: CustomerDTO & { + account_holder: AccountHolderDTO + } +} + +// Since we know we are using Stripe, we can get the correct creation date from their data. +const getLatestPaymentMethod = (paymentMethods: PaymentMethodDTO[]) => { + return paymentMethods.sort( + (a, b) => + ((b.data?.created as number) ?? 0) - ((a.data?.created as number) ?? 0) + )[0] +} + +export const getPaymentMethodStep = createStep( + "get-payment-method", + async ({ customer }: GetPaymentMethodStepInput, { container }) => { + // TODO implement step + } +) +``` + +You create a `getLatestPaymentMethod` function that receives an array of payment methods and returns the latest one based on the `created` date in the `data` field. This is based off of Stripe's payment method data, so if you're using a different payment provider, you may need to adjust this function. + +Then, you create the `getPaymentMethodStep` that receives the customer's data and account holder as an input. + +Next, you'll add the implemenation of the step. Replace `getPaymentMethodStep` with the following: + +```tsx title="src/workflows/create-subscription-order/steps/get-payment-method.ts" +export const getPaymentMethodStep = createStep( + "get-payment-method", + async ({ customer }: GetPaymentMethodStepInput, { container }) => { + const paymentModuleService = container.resolve(Modules.PAYMENT) + + if (!customer.account_holder) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "No account holder found for the customer while retrieving payment method" + ) + } + + const paymentMethods = await paymentModuleService.listPaymentMethods( + { + // you can change to other payment provider + provider_id: "pp_stripe_stripe", + context: { + account_holder: customer.account_holder, + }, + } + ) + + if (!paymentMethods.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "At least one saved payment method is required for performing a payment" + ) + } + + const paymentMethodToUse = getLatestPaymentMethod(paymentMethods) + + return new StepResponse( + paymentMethodToUse, + customer.account_holder + ) + } +) +``` + +In the step, you first check that the customer has an account holder, and throw an error otherwise. Then, you list the customer's payment methods using the Payment Module service's `listPaymentMethods` method. You filter the payment methods to retrieve only the ones from the Stripe provider. So, if you're using a different payment provider, you may need to adjust the `provider_id` value. + +If the customer doesn't have any payment methods, you throw an error. Otherwise, you return the latest payment method found using the `getLatestPaymentMethod` function. + +### Create createSubscriptionOrderStep (Sixth Step) + +Create the file `src/workflows/create-subscription-order/steps/create-subscription-order.ts` with the following content: + +```ts title="src/workflows/create-subscription-order/steps/create-subscription-order.ts" highlights={createSubscriptionOrderStep1Highlights} collapsibleLines="1-14" expandMoreLabel="Show Imports" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { + CartWorkflowDTO, + PaymentCollectionDTO, + IOrderModuleService, + LinkDefinition, +} from "@medusajs/framework/types" +import { + Modules, +} from "@medusajs/framework/utils" +import { createOrderWorkflow } from "@medusajs/medusa/core-flows" +import { SubscriptionData } from "../../../modules/subscription/types" +import { SUBSCRIPTION_MODULE } from "../../../modules/subscription" + +type StepInput = { + subscription: SubscriptionData + cart: CartWorkflowDTO + payment_collection: PaymentCollectionDTO +} + +function getOrderData(cart: CartWorkflowDTO) { + // TODO format order's data +} + +const createSubscriptionOrderStep = createStep( + "create-subscription-order", + async ({ + subscription, cart, payment_collection, + }: StepInput, + { container, context }) => { + const linkDefs: LinkDefinition[] = [] + + const { result: order } = await createOrderWorkflow(container) + .run({ + input: getOrderData(cart), + context, + }) + + // TODO add links to linkDefs + + return new StepResponse({ + order, + linkDefs, + }, { + order, + }) + }, + async ({ order }, { container }) => { + // TODO add compensation function + } +) + +export default createSubscriptionOrderStep +``` + +This creates a `createSubscriptionOrderStep` that uses the `createOrdersWorkflow`, which Medusa provides in the `@medusajs/medusa/core-flows` package. The step returns the created order and an array of links to be created. + +In this step, you use a `getOrderData` function to format the order’s input data. + +Replace the `getOrderData` function definition with the following: + +```ts title="src/workflows/create-subscription-order/steps/create-subscription-order.ts" +function getOrderData(cart: CartWorkflowDTO) { + return { + region_id: cart.region_id, + customer_id: cart.customer_id, + sales_channel_id: cart.sales_channel_id, + email: cart.email, + currency_code: cart.currency_code, + shipping_address: { + ...cart.shipping_address, + id: null, + }, + billing_address: { + ...cart.billing_address, + id: null, + }, + items: cart.items, + shipping_methods: cart.shipping_methods.map((method) => ({ + name: method.name, + amount: method.amount, + is_tax_inclusive: method.is_tax_inclusive, + shipping_option_id: method.shipping_option_id, + data: method.data, + tax_lines: method.tax_lines.map((taxLine) => ({ + description: taxLine.description, + tax_rate_id: taxLine.tax_rate_id, + code: taxLine.code, + rate: taxLine.rate, + provider_id: taxLine.provider_id, + })), + adjustments: method.adjustments.map((adjustment) => ({ + code: adjustment.code, + amount: adjustment.amount, + description: adjustment.description, + promotion_id: adjustment.promotion_id, + provider_id: adjustment.provider_id, + })), + })), + } +} +``` + +This formats the order’s data using the cart originally used to make the subscription purchase. + +Next, to add links to the returned `linkDefs` array, replace the `TODO` in the step with the following: + +```ts title="src/workflows/create-subscription-order/steps/create-subscription-order.ts" highlights={createSubscriptionOrderStep2Highlights} +linkDefs.push({ + [Modules.ORDER]: { + order_id: order.id, + }, + [Modules.PAYMENT]: { + payment_collection_id: payment_collection.id, + }, +}, +{ + [SUBSCRIPTION_MODULE]: { + subscription_id: subscription.id, + }, + [Modules.ORDER]: { + order_id: order.id, + }, +}) +``` + +This adds links to be created into the `linkDefs` array between the new order and payment collection, and the new order and its subscription. + +Finally, replace the `TODO` in the compensation function to cancel the order in case of an error: + +```ts title="src/workflows/create-subscription-order/steps/create-subscription-order.ts" +const orderModuleService: IOrderModuleService = container.resolve( + Modules.ORDER +) + +await orderModuleService.cancel(order.id) +``` + +### Create updateSubscriptionStep (Ninth Step) + +Before creating the seventh step, add in `src/modules/subscription/service.ts` the following new method: + +```ts title="src/modules/subscription/service.ts" +class SubscriptionModuleService extends MedusaService({ + Subscription, +}) { + // ... + async recordNewSubscriptionOrder(id: string) { + const subscription = await this.retrieveSubscription(id) + + const orderDate = new Date() + + return await this.updateSubscriptions({ + id, + last_order_date: orderDate, + next_order_date: this.getNextOrderDate({ + last_order_date: orderDate, + expiration_date: subscription.expiration_date, + interval: subscription.interval, + period: subscription.period, + }), + }) + } +} +``` + +The `recordNewSubscriptionOrder` method updates a subscription’s `last_order_date` with the current date and calculates the next order date using the `getNextOrderDate` method added previously. + +Then, to create the step that updates a subscription after its order is created, create the file `src/workflows/create-subscription-order/steps/update-subscription.ts` with the following content: + +```ts title="src/workflows/create-subscription-order/steps/update-subscription.ts" highlights={updateSubscriptionStepHighlights} collapsibleLines="1-9" expandMoreLabel="Show Imports" +import { + createStep, + StepResponse, +} from "@medusajs/framework/workflows-sdk" +import { + SUBSCRIPTION_MODULE, +} from "../../../modules/subscription" +import SubscriptionModuleService from "../../../modules/subscription/service" + +type StepInput = { + subscription_id: string +} + +const updateSubscriptionStep = createStep( + "update-subscription", + async ({ subscription_id }: StepInput, { container }) => { + const subscriptionModuleService: SubscriptionModuleService = + container.resolve( + SUBSCRIPTION_MODULE + ) + + const prevSubscriptionData = await subscriptionModuleService + .retrieveSubscription( + subscription_id + ) + + const subscription = await subscriptionModuleService + .recordNewSubscriptionOrder( + subscription_id + ) + + return new StepResponse({ + subscription, + }, { + prev_data: prevSubscriptionData, + }) + }, + async ({ + prev_data, + }, { container }) => { + // TODO add compensation + } +) + +export default updateSubscriptionStep +``` + +This creates the `updateSubscriptionStep` that updates the subscriber using the `recordNewSubscriptionOrder` method of the Subscription Module’s main service. It returns the updated subscription. + +Before updating the subscription, the step retrieves the old data and passes it to the compensation function to undo the changes on the subscription. + +So, replace the `TODO` in the compensation function with the following: + +```ts title="src/workflows/create-subscription-order/steps/update-subscription.ts" +const subscriptionModuleService: SubscriptionModuleService = + container.resolve( + SUBSCRIPTION_MODULE + ) + +await subscriptionModuleService.updateSubscriptions({ + id: prev_data.id, + last_order_date: prev_data.last_order_date, + next_order_date: prev_data.next_order_date, +}) +``` + +This updates the subscription’s `last_order_date` and `next_order_date` properties to the values before the update. + +### Create Workflow + +Finally, create the file `src/workflows/create-subscription-order/index.ts` with the following content: + +```ts title="src/workflows/create-subscription-order/index.ts" highlights={createSubscriptionOrderWorkflowHighlights} collapsibleLines="1-18" expandMoreLabel="Show Imports" +import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { + useQueryGraphStep, + createPaymentSessionsWorkflow, + createRemoteLinkStep, + capturePaymentStep, +} from "@medusajs/medusa/core-flows" +import { + SubscriptionData, +} from "../../modules/subscription/types" +import { + authorizePaymentSessionStep, + createPaymentCollectionsStep, +} from "@medusajs/medusa/core-flows" +import createSubscriptionOrderStep from "./steps/create-subscription-order" +import updateSubscriptionStep from "./steps/update-subscription" +import { getPaymentMethodStep } from "./steps/get-payment-method" + +type WorkflowInput = { + subscription: SubscriptionData +} + +const createSubscriptionOrderWorkflow = createWorkflow( + "create-subscription-order", + (input: WorkflowInput) => { + const { data: subscriptions } = useQueryGraphStep({ + entity: "subscription", + fields: [ + "*", + "cart.*", + "cart.items.*", + "cart.items.tax_lines.*", + "cart.items.adjustments.*", + "cart.shipping_address.*", + "cart.billing_address.*", + "cart.shipping_methods.*", + "cart.shipping_methods.tax_lines.*", + "cart.shipping_methods.adjustments.*", + "cart.payment_collection.*", + "cart.payment_collection.payment_sessions.*", + "cart.customer.*", + "cart.customer.account_holder.*", + ], + filters: { + id: input.subscription.id, + }, + options: { + throwIfKeyNotFound: true, + }, + }) + + const paymentCollectionData = transform({ + subscriptions, + }, (data) => { + const cart = data.subscriptions[0].cart + return { + currency_code: cart.currency_code, + amount: cart.payment_collection.amount, + metadata: cart.payment_collection.metadata, + } + }) + + const payment_collection = createPaymentCollectionsStep([ + paymentCollectionData, + ])[0] + + const defaultPaymentMethod = getPaymentMethodStep({ + customer: subscriptions[0].cart.customer, + }) + + const paymentSessionData = transform({ + payment_collection, + subscriptions, + defaultPaymentMethod, + }, (data) => { + return { + payment_collection_id: data.payment_collection.id, + provider_id: "pp_stripe_stripe", + customer_id: data.subscriptions[0].cart.customer.id, + data: { + payment_method: data.defaultPaymentMethod.id, + off_session: true, + confirm: true, + capture_method: "automatic", + }, + } + }) + + const paymentSession = createPaymentSessionsWorkflow.runAsStep({ + input: paymentSessionData, + }) + + const payment = authorizePaymentSessionStep({ + id: paymentSession.id, + context: paymentSession.context, + }) + + const { order, linkDefs } = createSubscriptionOrderStep({ + subscription: input.subscription, + cart: carts[0], + payment_collection, + }) + + createRemoteLinkStep(linkDefs) + + capturePaymentStep({ + payment_id: payment.id, + amount: payment.amount, + }) + + updateSubscriptionStep({ + subscription_id: input.subscription.id, + }) + + return new WorkflowResponse({ + order, + }) + } +) + +export default createSubscriptionOrderWorkflow +``` + +The workflow runs the following steps: + +1. `useQueryGraphStep` to retrieve the details of the cart linked to the subscription. +2. `createPaymentCollectionsStep` to create a payment collection using the same information in the cart. +3. `getPaymentMethodStep` to get the customer's saved payment method. +4. `createPaymentSessionsWorkflow` to create a payment session in the payment collection from the previous step. You prepare the data to create the payment session using [transform](https://docs.medusajs.com/docs/learn/fundamentals/workflows/variable-manipulation/index.html.md) from the Workflows SDK. + - Since you're capturing the payment with Stripe, you must pass in the payment session's `data` object the following properties: + - `payment_method`: the ID of the payment method saved in Stripe. + - `off_session`: `true` to indicate that the payment is [off-session](https://docs.stripe.com/payments/payment-intents#future-usage). + - `confirm`: `true` to confirm the payment. + - `capture_method`: `automatic` to automatically capture the payment. + - If you're using a payment provider other than Stripe, you'll need to adjust the `provider_id` value and the `data` object properties depending on what the provider expects. +5. `authorizePaymentSessionStep` to authorize the payment session created from the first step. +6. `createSubscriptionOrderStep` to create the new order for the subscription. +7. `createRemoteLinkStep` to create links returned by the previous step. +8. `capturePaymentStep` to capture the order’s payment. +9. `updateSubscriptionStep` to update the subscription’s `last_order_date` and `next_order_date`. + +A workflow's constructor function has some constraints in implementation, which is why you need to use `transform` for data manipulation. Learn more about these constraints in [this documentation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/constructor-constraints/index.html.md). + +In the next step, you’ll execute the workflow in a scheduled job. + +### Further Reads + +- [Payment Module](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/index.html.md) + +*** + +## Step 13: Create New Subscription Orders Scheduled Job + +A scheduled job is an asynchronous function executed at a specified interval pattern. Use scheduled jobs to execute a task at a regular interval. + +In this step, you’ll create a scheduled job that runs once a day. It finds all subscriptions whose `next_order_date` property is the current date and uses the workflow from the previous step to create an order for them. + +Create the file `src/jobs/create-subscription-orders.ts` with the following content: + +```ts title="src/jobs/create-subscription-orders.ts" highlights={createSubscriptionOrdersJob1Highlights} collapsibleLines="1-7" expandMoreLabel="Show Imports" +import { MedusaContainer } from "@medusajs/framework/types" +import SubscriptionModuleService from "../modules/subscription/service" +import { SUBSCRIPTION_MODULE } from "../modules/subscription" +import moment from "moment" +import createSubscriptionOrderWorkflow from "../workflows/create-subscription-order" +import { SubscriptionStatus } from "../modules/subscription/types" + +export default async function createSubscriptionOrdersJob( + container: MedusaContainer +) { + const subscriptionModuleService: SubscriptionModuleService = + container.resolve(SUBSCRIPTION_MODULE) + const logger = container.resolve("logger") + + let page = 0 + const limit = 20 + let pagesCount = 0 + + do { + const beginningToday = moment(new Date()).set({ + second: 0, + minute: 0, + hour: 0, + }) + .toDate() + const endToday = moment(new Date()).set({ + second: 59, + minute: 59, + hour: 23, + }) + .toDate() + + const [subscriptions, count] = await subscriptionModuleService + .listAndCountSubscriptions({ + next_order_date: { + $gte: beginningToday, + $lte: endToday, + }, + status: SubscriptionStatus.ACTIVE, + }, { + skip: page * limit, + take: limit, + }) + + // TODO create orders for subscriptions + + if (!pagesCount) { + pagesCount = count / limit + } + + page++ + } while (page < pagesCount - 1) +} + +export const config = { + name: "create-subscription-orders", + schedule: "0 0 * * *", // Every day at midnight +} +``` + +This creates a scheduled job that runs once a day. + +In the scheduled job, you retrieve subscriptions whose `next_order_date` is between the beginning and end of today, and whose `status` is `active`. You also support paginating the subscriptions in case there are more than `20` matching those filters. + +To create orders for the subscriptions returned, replace the `TODO` with the following: + +```ts title="src/jobs/create-subscription-orders.ts" +await Promise.all( + subscriptions.map(async (subscription) => { + try { + const { result } = await createSubscriptionOrderWorkflow(container) + .run({ + input: { + subscription, + }, + }) + + logger.info(`Created new order ${ + result.order.id + } for subscription ${subscription.id}`) + } catch (e) { + logger.error( + `Error creating a new order for subscription ${subscription.id}`, + e + ) + } + }) +) +``` + +This loops over the returned subscriptions and executes the `createSubscriptionOrderWorkflow` from the previous step to create the order. + +### Further Reads + +- [How to Create a Scheduled Job](https://docs.medusajs.com/docs/learn/fundamentals/scheduled-jobs/index.html.md) + +*** + +## Step 14: Expire Subscriptions Scheduled Job + +In this step, you’ll create a scheduled job that finds subscriptions whose `expiration_date` is the current date and marks them as expired. + +Before creating the scheduled job, add in `src/modules/subscription/service.ts` a new method: + +```ts title="src/modules/subscription/service.ts" +class SubscriptionModuleService extends MedusaService({ + Subscription, +}) { + // ... + async expireSubscription(id: string | string[]): Promise { + const input = Array.isArray(id) ? id : [id] + + return await this.updateSubscriptions({ + selector: { + id: input, + }, + data: { + next_order_date: null, + status: SubscriptionStatus.EXPIRED, + }, + }) + } +} +``` + +The `expireSubscription` updates the following properties of the specified subscriptions: + +1. Set `next_order_date` to `null` as there are no more orders. +2. Set the `status` to `expired`. + +Then, create the file `src/jobs/expire-subscription-orders.ts` with the following content: + +```ts title="src/jobs/expire-subscription-orders.ts" highlights={expireSubscriptionOrdersJobHighlights} collapsibleLines="1-6" expandMoreLabel="Show Imports" +import { MedusaContainer } from "@medusajs/framework/types" +import SubscriptionModuleService from "../modules/subscription/service" +import { SUBSCRIPTION_MODULE } from "../modules/subscription" +import moment from "moment" +import { SubscriptionStatus } from "../modules/subscription/types" + +export default async function expireSubscriptionOrdersJob( + container: MedusaContainer +) { + const subscriptionModuleService: SubscriptionModuleService = + container.resolve(SUBSCRIPTION_MODULE) + const logger = container.resolve("logger") + + let page = 0 + const limit = 20 + let pagesCount = 0 + + do { + const beginningToday = moment(new Date()).set({ + second: 0, + minute: 0, + hour: 0, + }) + .toDate() + const endToday = moment(new Date()).set({ + second: 59, + minute: 59, + hour: 23, + }) + .toDate() + + const [subscriptions, count] = await subscriptionModuleService + .listAndCountSubscriptions({ + expiration_date: { + $gte: beginningToday, + $lte: endToday, + }, + status: SubscriptionStatus.ACTIVE, + }, { + skip: page * limit, + take: limit, + }) + + const subscriptionIds = subscriptions.map((subscription) => subscription.id) + + await subscriptionModuleService.expireSubscription(subscriptionIds) + + logger.log(`Expired ${subscriptionIds}.`) + + if (!pagesCount) { + pagesCount = count / limit + } + + page++ + } while (page < pagesCount - 1) +} + +export const config = { + name: "expire-subscriptions", + schedule: "0 0 * * *", // Every day at midnight +} +``` + +This scheduled job runs once a day. + +In the scheduled job, you find all subscriptions whose `expiration_date` is between the beginning and end of today and their status is `active`. Then, you use the `expireSubscription` method to expire those subscriptions. + +You also implement pagination in case there are more than `20` expired subscriptions. + +*** + +## Step 15: Add Customer API Routes + +In this step, you’ll add two API routes for authenticated customers: + +1. View their list of subscriptions. +2. Cancel a subscription. + +### Create Subscriptions List API Route + +Create the file `src/api/store/customers/me/subscriptions/route.ts` with the following content: + +```ts title="src/api/store/customers/me/subscriptions/route.ts" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: [customer] } = await query.graph({ + entity: "customer", + fields: [ + "subscriptions.*", + ], + filters: { + id: [req.auth_context.actor_id], + }, + }) + + res.json({ + subscriptions: customer.subscriptions, + }) +} +``` + +This adds an API route at `/store/customers/me/subscriptions`. + +In the route handler, you retrieve the authenticated customer’s subscriptions using Query and return them in the response. + +### Cancel Subscription API Route + +Before creating this API route, add in `src/modules/subscription/service.ts` the following new method: + +```ts title="src/modules/subscription/service.ts" +class SubscriptionModuleService extends MedusaService({ + Subscription, +}) { + // ... + + async cancelSubscriptions( + id: string | string[]): Promise { + const input = Array.isArray(id) ? id : [id] + + return await this.updateSubscriptions({ + selector: { + id: input, + }, + data: { + next_order_date: null, + status: SubscriptionStatus.CANCELED, + }, + }) + } +} +``` + +The `cancelSubscriptions` method updates the specified subscribers to set their `next_order_date` to `null` and their status to `canceled`. + +Then, create the file `src/api/store/customers/me/subscriptions/[id]/route.ts` with the following content: + +```ts title="src/api/store/customers/me/subscriptions/[id]/route.ts" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import SubscriptionModuleService from "../../../../../../modules/subscription/service" +import { + SUBSCRIPTION_MODULE, +} from "../../../../../../modules/subscription" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const subscriptionModuleService: SubscriptionModuleService = + req.scope.resolve(SUBSCRIPTION_MODULE) + + const subscription = await subscriptionModuleService.cancelSubscriptions( + req.params.id + ) + + res.json({ + subscription, + }) +} +``` + +This adds an API route at `/store/customers/me/subscriptions/[id]`. In the route handler, you use the `cancelSubscriptions` method added above to cancel the subscription whose ID is passed as a path parameter. + +### Test it Out + +To test out the above API routes, first, log in as a customer with the following request: + +```bash +curl -X POST 'http://localhost:9000/auth/customer/emailpass' \ +-H 'Content-Type: application/json' \ +--data-raw '{ + "email": "customer@gmail.com", + "password": "supersecret" +}' +``` + +Make sure to replace the `email` and `password` with the correct credentials. + +If you don’t have a customer account, create one either using the Next.js Starter storefront or by following [this guide](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/storefront-development/customers/register/index.html.md). + +Then, send a `GET` request to `/store/customers/me/subscriptions` to retrieve the customer’s subscriptions: + +```bash +curl 'http://localhost:9000/store/customers/me/subscriptions' \ +-H 'Authorization: Bearer {token}' \ +-H 'x-publishable-api-key: {your_publishable_api_key}' +``` + +Where `{token}` is the token retrieved from the previous request. + +To cancel a subscription, send a `POST` request to `/store/customers/me/subscriptions/[id]`, replacing the `[id]` with the ID of the subscription to cancel: + +```bash +curl -X POST 'http://localhost:9000/store/customers/me/subscriptions/01J2VB8TVC14K29FREQ2DRS6NA' \ +-H 'Authorization: Bearer {token}' \ +-H 'x-publishable-api-key: {your_publishable_api_key}' +``` + +*** + +## Next Steps + +The next steps of this example depend on your use case. This section provides some insight into implementing them. + +### Use Existing Features + +To manage the orders created for a subscription, or other functionalities, use Medusa’s existing [Admin API routes](https://docs.medusajs.com/api/admin). + +### Link Subscriptions to Other Data Models + +If your use case requires a subscription to have relations to other existing data models, you can create links to them, similar to [step four](#step-4-define-links). + +For example, you can link a subscription to a promotion to offer a subscription-specific discount. + +### Storefront Development + +Medusa provides a Next.js Starter storefront that you can customize to your use case. You can also create a custom storefront. To learn how visit the [Storefront Development](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/storefront-development/index.html.md) section. + + +# Subscriptions Recipe + +This recipe provides the general steps to build subscription-based purchase with Medusa. + +## Overview + +Subscription-based purchase allows customers to purchase products for a specified period, and the payment and fulfillment is processed within a regular interval in that period. + +For example, a customer can purchase a book subscription box for a period of three months. Each month, the payment is captured for that order and, if the payment is successful, the fulfillment is processed. + +Medusa's [Framework](https://docs.medusajs.com/docs/learn/fundamentals/framework/index.html.md) for customizations facilitates building subscription-based purchases. You can create a Subscription Module that implements data models for subscriptions, and link those data models to existing ones such as products and orders. + +You can also expose custom features using API routes, and implement complex flows using workflows. + +[How Goodchef built subscription-based purchases with Medusa](https://medusajs.com/blog/goodchef/). + +*** + +## Save Subscription Details + +Subscriptions have details related to the subscription interval, subscription period, and more. + +To store the subscription details, you can create a data model in a new subscription module. The module's main service provides data management feature of the data model. + +You can link the subscription data model to models of other modules, such as the Order Module's `Order` data model. + +- [Create a Module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md): Learn how to create a module. +- [Create a Data Model](https://docs.medusajs.com/docs/learn/fundamentals/modules#1-create-data-model/index.html.md): Learn how to create a data model. + +*** + +## Link Subscription to Existing Data Models + +Define a module link that links a data model from your subscription module with a data model from another module. + +For example, you can link the subscription data model to the Order Module's `Order` data model. + +If you want to create subscriptions on the product level, you can link the subscription data model to the Product Module's `Product` data model. + +[Define a Module Link](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md): Learn how to define a module link. + +*** + +## Implement Subscription Approach + +There are different ways to implement subscriptions in your Medusa application. This recipe covers two options. + +### Option 1: Custom Subscription Logic + +By implementing the subscription logic within your application, you have full control over the subscription logic. You'll also be independent of payment providers, providing customers with more than one payment provider. + +Implementing the logic depends on your use case, but you'll mainly implement the following: + +1. Create a workflow that completes a cart and creates a subscription for the order. +2. Create an API route that executes the workflow. +3. Create a scheduled job that checks daily for subscriptions that need renewal. +4. Create another scheduled job that checks daily for subscriptions that are expired. + +- [Create a Workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md): Learn how to create a workflow. +- [Create an API Route](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md): Learn how to create an API route. + +[Create a Scheduled Job](https://docs.medusajs.com/docs/learn/fundamentals/scheduled-jobs#1-create-a-scheduled-job/index.html.md): Learn how to create a scheduled job. + +### Option 2: Using Stripe Subscriptions + +Stripe provides a [subscription payments feature](https://stripe.com/docs/billing/subscriptions/overview) that allows you to authorize payment on a subscription basis within Stripe. Stripe then handles checking for recurring payments and capturing payment at the specified interval. + +This approach allows you to delegate the complications of implementing the subscription logic to Stripe, but doesn't support using other payment providers. + +Although Medusa provides a [Stripe Payment Module Provider](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/payment-provider/stripe/index.html.md), it doesn't handle subscriptions. You can create a custom Stripe Subscription Module Provider instead. + +[Create Payment Module Provider](https://docs.medusajs.com/references/payment/provider/index.html.md): Learn how to create a payment module provider. + +*** + +## Customize Admin Dashboard + +Based on your use case, you may need to customize the Medusa Admin to add new widgets or pages. + +For example, you can create a page that lists all subscriptions or a widget that shows an order's subscription information. + +The Medusa Admin is an extensible application within your Medusa application. You can customize it by: + +- **Widgets**: Adding widgets to existing pages, such as the order page. +- **UI Routes**: Adding new pages to the Medusa Admin, such as a page to manage subscriptions. +- **Settings Pages**: Adding new pages to the Medusa Admin settings, such as a page to manage subscription settings. + +- [Create Admin Widget](https://docs.medusajs.com/docs/learn/fundamentals/admin/widgets/index.html.md): Add widgets into existing admin pages. +- [Create Admin UI Routes](https://docs.medusajs.com/docs/learn/fundamentals/admin/ui-routes/index.html.md): Add new pages to your Medusa Admin. + +[Create Admin Setting Page](https://docs.medusajs.com/docs/learn/fundamentals/admin/ui-routes#create-settings-page/index.html.md): Add new page to the Medusa Admin settings. + +*** + +## Customize or Build Storefront + +Medusa provides a Next.js Starter Storefront to use with your application. You can customize it to for your subscription use case, such as allowing customers to manage their subscriptions. + +Alternatively, you can build your own storefront using the Medusa APIs. This headless approach gives you the flexibility to build a custom storefront without limitations on which tech stack you use, or the design of the storefront. + +- [Next.js Starter Storefront](https://docs.medusajs.com/nextjs-starter/index.html.md): Learn how to install and customize the Next.js Starter Storefront. +- [Storefront Development](https://docs.medusajs.com/storefront-development/index.html.md): Find guides to build your own storefront. diff --git a/www/apps/book/scripts/prepare.mjs b/www/apps/book/scripts/prepare.mjs index e9d517463a..1dbbb4cf71 100644 --- a/www/apps/book/scripts/prepare.mjs +++ b/www/apps/book/scripts/prepare.mjs @@ -284,6 +284,9 @@ async function main() { }, allowedFilesPatterns: [/^(?!.*\/(colors|icons|hooks)\/).*$/], }, + { + dir: path.join(process.cwd(), "..", "resources", "app", "recipes"), + }, ], }) } diff --git a/www/apps/resources/app/examples/guides/custom-item-price/page.mdx b/www/apps/resources/app/examples/guides/custom-item-price/page.mdx index f61c04f6d3..22059c7cb0 100644 --- a/www/apps/resources/app/examples/guides/custom-item-price/page.mdx +++ b/www/apps/resources/app/examples/guides/custom-item-price/page.mdx @@ -518,8 +518,8 @@ Create the file `src/workflows/add-custom-to-cart.ts` with the following content ![The directory structure after adding the workflow file.](https://res.cloudinary.com/dza7lstvk/image/upload/v1738251380/Medusa%20Resources/custom-item-price-5_zorahv.jpg) export const workflowHighlights = [ - ["18", "useQueryGraphStep", "Retrieve the cart's details."], - ["24", "useQueryGraphStep", "Retrieve the variant's details."], + ["17", "useQueryGraphStep", "Retrieve the cart's details."], + ["23", "useQueryGraphStep", "Retrieve the variant's details."], ] ```ts title="src/workflows/add-custom-to-cart.ts" highlights={workflowHighlights} @@ -539,7 +539,6 @@ type AddCustomToCartWorkflowInput = { export const addCustomToCartWorkflow = createWorkflow( "add-custom-to-cart", ({ cart_id, item }: AddCustomToCartWorkflowInput) => { - // @ts-ignore const { data: carts } = useQueryGraphStep({ entity: "cart", filters: { id: cart_id }, @@ -651,7 +650,6 @@ import { WorkflowResponse } from "@medusajs/framework/workflows-sdk" And replace the last `TODO` in the workflow with the following: ```ts title="src/workflows/add-custom-to-cart.ts" -// @ts-ignore const { data: updatedCarts } = useQueryGraphStep({ entity: "cart", filters: { id: cart_id }, diff --git a/www/apps/resources/app/examples/guides/quote-management/page.mdx b/www/apps/resources/app/examples/guides/quote-management/page.mdx index 2e6080dae6..b6c935a6a4 100644 --- a/www/apps/resources/app/examples/guides/quote-management/page.mdx +++ b/www/apps/resources/app/examples/guides/quote-management/page.mdx @@ -635,8 +635,8 @@ You can now create the workflow using the steps provided by Medusa and your cust To create the workflow, create the file `src/workflows/create-request-for-quote.ts` with the following content: export const createRequestForQuoteHighlights = [ - ["25", "useQueryGraphStep", "Retrieve the cart's details."], - ["46", "useQueryGraphStep", "Retrieve the customer's details."] + ["24", "useQueryGraphStep", "Retrieve the cart's details."], + ["45", "useQueryGraphStep", "Retrieve the customer's details."] ] ```ts title="src/workflows/create-request-for-quote.ts" highlights={createRequestForQuoteHighlights} collapsibleLines="1-20" expandButtonLabel="Show Imports" @@ -663,7 +663,6 @@ type WorkflowInput = { export const createRequestForQuoteWorkflow = createWorkflow( "create-request-for-quote", (input: WorkflowInput) => { - // @ts-ignore const { data: carts } = useQueryGraphStep({ entity: "cart", fields: [ @@ -2584,9 +2583,9 @@ You can now implement the merchant-rejection workflow. Create the file `src/work ![Diagram showcasing the directory structure after adding the merchant reject quote workflow file](https://res.cloudinary.com/dza7lstvk/image/upload/v1741159969/Medusa%20Resources/quote-36_l1ffxm.jpg) export const merchantRejectionWorkflowHighlights = [ - ["15", "useQueryGraphStep", "Retrieve the quote's details."], - ["24", "validateQuoteNotAccepted", "Validate that the quote isn't already accepted by the customer."], - ["29", "updateQuotesStep", "Update the quote's status to `merchant_rejected`."] + ["14", "useQueryGraphStep", "Retrieve the quote's details."], + ["23", "validateQuoteNotAccepted", "Validate that the quote isn't already accepted by the customer."], + ["28", "updateQuotesStep", "Update the quote's status to `merchant_rejected`."] ] ```ts title="src/workflows/merchant-reject-quote.ts" highlights={merchantRejectionWorkflowHighlights} @@ -2603,7 +2602,6 @@ type WorkflowInput = { export const merchantRejectQuoteWorkflow = createWorkflow( "merchant-reject-quote-workflow", (input: WorkflowInput) => { - // @ts-ignore const { data: quotes } = useQueryGraphStep({ entity: "quote", fields: ["id", "status"], @@ -2905,7 +2903,6 @@ type WorkflowInput = { export const merchantSendQuoteWorkflow = createWorkflow( "merchant-send-quote-workflow", (input: WorkflowInput) => { - // @ts-ignore const { data: quotes } = useQueryGraphStep({ entity: "quote", fields: ["id", "status"], @@ -3282,9 +3279,9 @@ Create the file `src/workflows/customer-reject-quote.ts` with the following cont ![Directory structure after adding the customer reject quote workflow file](https://res.cloudinary.com/dza7lstvk/image/upload/v1741164371/Medusa%20Resources/quote-41_fgpqhz.jpg) export const customerRejectQuoteHighlights = [ - ["16", "useQueryGraphStep", "Retrieve the quote's details."], - ["25", "validateQuoteNotAccepted", "Validate that the quote isn't already accepted by the customer."], - ["30", "updateQuotesStep", "Update the quote's status to `customer_rejected`."] + ["15", "useQueryGraphStep", "Retrieve the quote's details."], + ["24", "validateQuoteNotAccepted", "Validate that the quote isn't already accepted by the customer."], + ["29", "updateQuotesStep", "Update the quote's status to `customer_rejected`."] ] ```ts title="src/workflows/customer-reject-quote.ts" highlights={customerRejectQuoteHighlights} @@ -3302,7 +3299,6 @@ type WorkflowInput = { export const customerRejectQuoteWorkflow = createWorkflow( "customer-reject-quote-workflow", (input: WorkflowInput) => { - // @ts-ignore const { data: quotes } = useQueryGraphStep({ entity: "quote", fields: ["id", "status"], @@ -3511,11 +3507,11 @@ You can now implement the workflow that accepts a quote for a customer. Create t ![Directory structure after adding the customer accept quote workflow file](https://res.cloudinary.com/dza7lstvk/image/upload/v1741166025/Medusa%20Resources/quote-44_c09ts9.jpg) export const customerAcceptQuoteHighlights = [ - ["21", "useQueryGraphStep", "Retrieve the quote's details."], - ["30", "validateQuoteCanAcceptStep", "Validate that the quote can be accepted."], - ["35", "updateQuotesStep", "Update the quote's status to `accepted`."], - ["40", "confirmOrderEditRequestWorkflow", "Confirm the changes made on the draft order, such as changes to item quantities and prices."], - ["47", "updateOrderWorkflow", "Update the draft order to change its status and convert it into an order."] + ["20", "useQueryGraphStep", "Retrieve the quote's details."], + ["29", "validateQuoteCanAcceptStep", "Validate that the quote can be accepted."], + ["34", "updateQuotesStep", "Update the quote's status to `accepted`."], + ["39", "confirmOrderEditRequestWorkflow", "Confirm the changes made on the draft order, such as changes to item quantities and prices."], + ["46", "updateOrderWorkflow", "Update the draft order to change its status and convert it into an order."] ] ```ts title="src/workflows/customer-accept-quote.ts" highlights={customerAcceptQuoteHighlights} @@ -3538,7 +3534,6 @@ type WorkflowInput = { export const customerAcceptQuoteWorkflow = createWorkflow( "customer-accept-quote-workflow", (input: WorkflowInput) => { - // @ts-ignore const { data: quotes } = useQueryGraphStep({ entity: "quote", fields: ["id", "draft_order_id", "status"], diff --git a/www/apps/resources/app/how-to-tutorials/tutorials/abandoned-cart/page.mdx b/www/apps/resources/app/how-to-tutorials/tutorials/abandoned-cart/page.mdx index 5b8d1d1327..112a47bae6 100644 --- a/www/apps/resources/app/how-to-tutorials/tutorials/abandoned-cart/page.mdx +++ b/www/apps/resources/app/how-to-tutorials/tutorials/abandoned-cart/page.mdx @@ -558,11 +558,9 @@ const { updated_at: { $lt: oneDayAgo, }, - // @ts-ignore email: { $ne: null, }, - // @ts-ignore completed_at: null, }, pagination: { diff --git a/www/apps/resources/app/how-to-tutorials/tutorials/first-purchase-discounts/page.mdx b/www/apps/resources/app/how-to-tutorials/tutorials/first-purchase-discounts/page.mdx index edcf046994..9cfcd6367d 100644 --- a/www/apps/resources/app/how-to-tutorials/tutorials/first-purchase-discounts/page.mdx +++ b/www/apps/resources/app/how-to-tutorials/tutorials/first-purchase-discounts/page.mdx @@ -202,12 +202,12 @@ To create the workflow, create the file `src/workflows/apply-first-purchase-prom export const workflowHighlights = [ ["7", "cart_id", "Receive cart ID as input."], - ["14", "carts", "Retrieve the cart's details."], - ["23", "promotions", "Retrieve the first-purchase promotion's details."], - ["32", "when", "Check if the first-purchase promotion can be applied."], - ["42", "updateCartPromotionsStep", "Add the first-purchase promotion to the cart."], - ["51", "updatedCarts", "Retrieve the updated cart's details."], - ["59", "WorkflowResponse", "Return the updated cart's details."] + ["13", "carts", "Retrieve the cart's details."], + ["21", "promotions", "Retrieve the first-purchase promotion's details."], + ["29", "when", "Check if the first-purchase promotion can be applied."], + ["39", "updateCartPromotionsStep", "Add the first-purchase promotion to the cart."], + ["47", "updatedCarts", "Retrieve the updated cart's details."], + ["55", "WorkflowResponse", "Return the updated cart's details."] ] ```ts title="src/workflows/apply-first-purchase-promo.ts" highlights={workflowHighlights} @@ -223,7 +223,6 @@ type WorkflowInput = { export const applyFirstPurchasePromoWorkflow = createWorkflow( "apply-first-purchase-promo", (input: WorkflowInput) => { - // @ts-ignore const { data: carts } = useQueryGraphStep({ entity: "cart", fields: ["promotions.*", "customer.*", "customer.orders.*"], @@ -232,12 +231,10 @@ export const applyFirstPurchasePromoWorkflow = createWorkflow( } }) - // @ts-ignore const { data: promotions } = useQueryGraphStep({ entity: "promotion", fields: ["code"], filters: { - // @ts-ignore code: FIRST_PURCHASE_PROMOTION_CODE } }).config({ name: "retrieve-promotions" }) @@ -260,7 +257,6 @@ export const applyFirstPurchasePromoWorkflow = createWorkflow( }) // retrieve updated cart - // @ts-ignore const { data: updatedCarts } = useQueryGraphStep({ entity: "cart", fields: ["*", "promotions.*"], diff --git a/www/apps/resources/app/how-to-tutorials/tutorials/loyalty-points/page.mdx b/www/apps/resources/app/how-to-tutorials/tutorials/loyalty-points/page.mdx index cca793290c..6eceb63981 100644 --- a/www/apps/resources/app/how-to-tutorials/tutorials/loyalty-points/page.mdx +++ b/www/apps/resources/app/how-to-tutorials/tutorials/loyalty-points/page.mdx @@ -835,14 +835,14 @@ Now that you have all the steps, you can create the workflow that uses them. To create the workflow, create the file `src/workflows/handle-order-points.ts` with the following content: export const handleOrderPointsHighlights = [ - ["18", "useQueryGraphStep", "Retrieve the order's details."], - ["38", "validateCustomerExistsStep", "Validate that the customer is registered."], - ["42", "getCartLoyaltyPromoStep", "Retrieve the cart's loyalty promotion."], - ["46", "when", "Check whether the order's cart has a loyalty promotion."], - ["51", "deductPurchasePointsStep", "Deduct points from the customer's loyalty points."], - ["56", "updatePromotionsStep", "Deactivate the cart's loyalty promotion."], - ["65", "when", "Check whether the order's cart doesn't have a loyalty promotion."], - ["70", "addPurchaseAsPointsStep", "Add points to the customer's loyalty points."], + ["17", "useQueryGraphStep", "Retrieve the order's details."], + ["37", "validateCustomerExistsStep", "Validate that the customer is registered."], + ["41", "getCartLoyaltyPromoStep", "Retrieve the cart's loyalty promotion."], + ["45", "when", "Check whether the order's cart has a loyalty promotion."], + ["50", "deductPurchasePointsStep", "Deduct points from the customer's loyalty points."], + ["55", "updatePromotionsStep", "Deactivate the cart's loyalty promotion."], + ["64", "when", "Check whether the order's cart doesn't have a loyalty promotion."], + ["69", "addPurchaseAsPointsStep", "Add points to the customer's loyalty points."], ] ```ts title="src/workflows/handle-order-points.ts" highlights={handleOrderPointsHighlights} collapsibleLines="1-9" expandButtonLabel="Show Imports" @@ -862,7 +862,6 @@ type WorkflowInput = { export const handleOrderPointsWorkflow = createWorkflow( "handle-order-points", ({ order_id }: WorkflowInput) => { - // @ts-ignore const { data: orders } = useQueryGraphStep({ entity: "order", fields: [ @@ -1394,10 +1393,10 @@ You can now create the workflow that applies a loyalty promotion to the cart. To create the workflow, create the file `src/workflows/apply-loyalty-on-cart.ts` with the following content: export const applyLoyaltyOnCartWorkflowHighlights = [ - ["45", "useQueryGraphStep", "Retrieve the cart's details."], - ["56", "validateCustomerExistsStep", "Validate that the customer is registered."], - ["60", "getCartLoyaltyPromoStep", "Retrieve the cart's loyalty promotion."], - ["65", "getCartLoyaltyPromoAmountStep", "Get the amount to be discounted based on the loyalty points."], + ["44", "useQueryGraphStep", "Retrieve the cart's details."], + ["55", "validateCustomerExistsStep", "Validate that the customer is registered."], + ["59", "getCartLoyaltyPromoStep", "Retrieve the cart's loyalty promotion."], + ["64", "getCartLoyaltyPromoAmountStep", "Get the amount to be discounted based on the loyalty points."], ] ```ts title="src/workflows/apply-loyalty-on-cart.ts" highlights={applyLoyaltyOnCartWorkflowHighlights} collapsibleLines="1-24" expandButtonLabel="Show Imports" @@ -1444,7 +1443,6 @@ const fields = [ export const applyLoyaltyOnCartWorkflow = createWorkflow( "apply-loyalty-on-cart", (input: WorkflowInput) => { - // @ts-ignore const { data: carts } = useQueryGraphStep({ entity: "cart", fields, @@ -1559,7 +1557,7 @@ export const createLoyaltyPromoStepHighlights = [ ["5", "transform", "Prepare the data to update the cart's promotions."], ["25", "updateCartPromotionsWorkflow", "Update the cart's promotions with the new loyalty promotion."], ["29", "updateCartsStep", "Update the cart to store the ID of the loyalty promotion in the metadata."], - ["38", "useQueryGraphStep", "Retrieve the cart's details again."], + ["37", "useQueryGraphStep", "Retrieve the cart's details again."], ] ```ts title="src/workflows/apply-loyalty-on-cart.ts" highlights={createLoyaltyPromoStepHighlights} @@ -1599,7 +1597,6 @@ updateCartsStep([ ]) // retrieve cart with updated promotions -// @ts-ignore const { data: updatedCarts } = useQueryGraphStep({ entity: "cart", fields, @@ -1800,13 +1797,13 @@ Since you already have all the steps, you can create the workflow. To create the workflow, create the file `src/workflows/remove-loyalty-from-cart.ts` with the following content: export const removeLoyaltyFromCartWorkflowHighlights = [ - ["36", "useQueryGraphStep", "Retrieve the cart's details."], - ["44", "getCartLoyaltyPromoStep", "Retrieve the cart's loyalty promotion."], - ["49", "updateCartPromotionsWorkflow", "Update the cart's promotions to remove the loyalty promotion."], - ["57", "transform", "Prepare the new metadata to remove the loyalty promotion ID."], - ["68", "updateCartsStep", "Update the cart to remove the loyalty promotion ID from the metadata."], - ["75", "updatePromotionsStep", "Deactivate the loyalty promotion."], - ["84", "useQueryGraphStep", "Retrieve the cart's details again."], + ["35", "useQueryGraphStep", "Retrieve the cart's details."], + ["43", "getCartLoyaltyPromoStep", "Retrieve the cart's loyalty promotion."], + ["48", "updateCartPromotionsWorkflow", "Update the cart's promotions to remove the loyalty promotion."], + ["56", "transform", "Prepare the new metadata to remove the loyalty promotion ID."], + ["67", "updateCartsStep", "Update the cart to remove the loyalty promotion ID from the metadata."], + ["74", "updatePromotionsStep", "Deactivate the loyalty promotion."], + ["82", "useQueryGraphStep", "Retrieve the cart's details again."], ] ```ts title="src/workflows/remove-loyalty-from-cart.ts" collapsibleLines="1-15" expandButtonLabel="Show Imports" highlights={removeLoyaltyFromCartWorkflowHighlights} @@ -1844,7 +1841,6 @@ const fields = [ export const removeLoyaltyFromCartWorkflow = createWorkflow( "remove-loyalty-from-cart", (input: WorkflowInput) => { - // @ts-ignore const { data: carts } = useQueryGraphStep({ entity: "cart", fields, @@ -1892,7 +1888,6 @@ export const removeLoyaltyFromCartWorkflow = createWorkflow( ]) // retrieve cart with updated promotions - // @ts-ignore const { data: updatedCarts } = useQueryGraphStep({ entity: "cart", fields, diff --git a/www/apps/resources/app/how-to-tutorials/tutorials/product-reviews/page.mdx b/www/apps/resources/app/how-to-tutorials/tutorials/product-reviews/page.mdx index cb89a150a3..733b42c575 100644 --- a/www/apps/resources/app/how-to-tutorials/tutorials/product-reviews/page.mdx +++ b/www/apps/resources/app/how-to-tutorials/tutorials/product-reviews/page.mdx @@ -505,7 +505,6 @@ export const createReviewWorkflow = createWorkflow( "create-review", (input: CreateReviewInput) => { // Check product exists - // @ts-ignore useQueryGraphStep({ entity: "product", fields: ["id"], @@ -520,7 +519,6 @@ export const createReviewWorkflow = createWorkflow( // Create the review const review = createReviewStep(input) - // @ts-ignore return new WorkflowResponse({ review, }) @@ -1522,8 +1520,8 @@ export const GetStoreReviewsHighlights = [ ["10", "GetStoreReviewsSchema", "Define the query parameters schema."], ["12", "GET", "Expose a GET API route."], ["26", "query.graph", "Retrieve reviews with pagination."], - ["31", "status", "Only retrieve approved reviews."], - ["41", "average_rating", "Retrieve the average rating of the product."], + ["30", "status", "Only retrieve approved reviews."], + ["40", "average_rating", "Retrieve the average rating of the product."], ] ```ts title="src/api/store/products/[id]/reviews/route.ts" highlights={GetStoreReviewsHighlights} collapsibleLines="1-9" expandButtonLabel="Show Imports" @@ -1556,7 +1554,6 @@ export const GET = async ( entity: "review", filters: { product_id: id, - // @ts-ignore status: "approved", }, ...req.queryConfig, diff --git a/www/apps/resources/app/how-to-tutorials/tutorials/re-order/page.mdx b/www/apps/resources/app/how-to-tutorials/tutorials/re-order/page.mdx index 865f6a26d7..2245e8003e 100644 --- a/www/apps/resources/app/how-to-tutorials/tutorials/re-order/page.mdx +++ b/www/apps/resources/app/how-to-tutorials/tutorials/re-order/page.mdx @@ -179,7 +179,7 @@ This workflow uses steps from Medusa's `@medusajs/medusa/core-flows` package. So To create the workflow, create the file `src/workflows/reorder.ts` with the following content: export const workflowHighlights1 = [ - ["20", "useQueryGraphStep", "Retrieve the order's details."], + ["19", "useQueryGraphStep", "Retrieve the order's details."], ] ```ts title="src/workflows/reorder.ts" highlights={workflowHighlights1} @@ -201,7 +201,6 @@ type ReorderWorkflowInput = { export const reorderWorkflow = createWorkflow( "reorder", ({ order_id }: ReorderWorkflowInput) => { - // @ts-ignore const { data: orders } = useQueryGraphStep({ entity: "order", fields: [ @@ -359,12 +358,11 @@ Finally, you need to retrieve the cart's details and return them as the workflow Replace the `TODO` in the workflow with the following: export const workflowHighlights4 = [ - ["2", "useQueryGraphStep", "Retrieve the cart's details."], - ["28", "WorkflowResponse", "Return the cart's details."], + ["1", "useQueryGraphStep", "Retrieve the cart's details."], + ["27", "WorkflowResponse", "Return the cart's details."], ] ```ts title="src/workflows/reorder.ts" highlights={workflowHighlights4} -// @ts-ignore const { data: carts } = useQueryGraphStep({ entity: "cart", fields: [ diff --git a/www/apps/resources/app/integrations/guides/algolia/page.mdx b/www/apps/resources/app/integrations/guides/algolia/page.mdx index 655dd889c4..cb4f4b2d4a 100644 --- a/www/apps/resources/app/integrations/guides/algolia/page.mdx +++ b/www/apps/resources/app/integrations/guides/algolia/page.mdx @@ -533,7 +533,6 @@ type SyncProductsWorkflowInput = { export const syncProductsWorkflow = createWorkflow( "sync-products", ({ filters, limit, offset }: SyncProductsWorkflowInput) => { - // @ts-ignore const { data, metadata } = useQueryGraphStep({ entity: "product", fields: ["id", "title", "description", "handle", "thumbnail", "categories.*", "tags.*"], @@ -542,7 +541,6 @@ export const syncProductsWorkflow = createWorkflow( skip: offset, }, filters: { - // @ts-ignore status: "published", ...filters, }, diff --git a/www/apps/resources/app/integrations/guides/contentful/page.mdx b/www/apps/resources/app/integrations/guides/contentful/page.mdx index 3b00167328..fab4275731 100644 --- a/www/apps/resources/app/integrations/guides/contentful/page.mdx +++ b/www/apps/resources/app/integrations/guides/contentful/page.mdx @@ -1405,7 +1405,6 @@ type WorkflowInput = { export const createProductsContentfulWorkflow = createWorkflow( { name: "create-products-contentful-workflow" }, (input: WorkflowInput) => { - // @ts-ignore const { data } = useQueryGraphStep({ entity: "product", fields: [ diff --git a/www/apps/resources/app/integrations/guides/mailchimp/page.mdx b/www/apps/resources/app/integrations/guides/mailchimp/page.mdx index e858d69796..ce5aa81124 100644 --- a/www/apps/resources/app/integrations/guides/mailchimp/page.mdx +++ b/www/apps/resources/app/integrations/guides/mailchimp/page.mdx @@ -1172,9 +1172,9 @@ You'll create a workflow that retrieves the latest products added to your Medusa Create the file `src/workflows/send-newsletter.ts` with the following content: export const workflowHighlights = [ - ["15", "useQueryGraphStep", "Retrieve the products created in the last 7 days."], - ["32", "when", "Check whether there are new products."], - ["34", "sendNotificationsStep", "Create a notification with the new products data."], + ["14", "useQueryGraphStep", "Retrieve the products created in the last 7 days."], + ["31", "when", "Check whether there are new products."], + ["33", "sendNotificationsStep", "Create a notification with the new products data."], ] ```ts title="src/workflows/send-newsletter.ts" highlights={workflowHighlights} @@ -1191,7 +1191,6 @@ import { export const sendNewProductsNewsletter = createWorkflow( "send-new-products-newsletter", (input) => { - // @ts-ignore const { data: products } = useQueryGraphStep({ entity: "product", fields: [ diff --git a/www/apps/resources/app/integrations/guides/resend/page.mdx b/www/apps/resources/app/integrations/guides/resend/page.mdx index a96d46c10f..11c8985cce 100644 --- a/www/apps/resources/app/integrations/guides/resend/page.mdx +++ b/www/apps/resources/app/integrations/guides/resend/page.mdx @@ -1199,9 +1199,9 @@ You'll now create the workflow that uses the `sendNotificationStep` to send the Create the file `src/workflows/send-order-confirmation.ts` with the following content: export const workflowHighlights = [ - ["12", "sendOrderConfirmationWorkflow", "Create the workflow that sends an order confirmation email."], - ["16", "useQueryGraphStep", "Retrieve the order's details."], - ["44", "sendNotificationStep", "Send the order confirmation email."] + ["13", "sendOrderConfirmationWorkflow", "Create the workflow that sends an order confirmation email."], + ["15", "useQueryGraphStep", "Retrieve the order's details."], + ["43", "sendNotificationStep", "Send the order confirmation email."] ] ```ts title="src/workflows/send-order-confirmation.ts" highlights={workflowHighlights} @@ -1219,7 +1219,6 @@ type WorkflowInput = { export const sendOrderConfirmationWorkflow = createWorkflow( "send-order-confirmation", ({ id }: WorkflowInput) => { - // @ts-ignore const { data: orders } = useQueryGraphStep({ entity: "order", fields: [ diff --git a/www/apps/resources/app/integrations/guides/sanity/page.mdx b/www/apps/resources/app/integrations/guides/sanity/page.mdx index 0acda0052d..43196c6a9b 100644 --- a/www/apps/resources/app/integrations/guides/sanity/page.mdx +++ b/www/apps/resources/app/integrations/guides/sanity/page.mdx @@ -722,7 +722,6 @@ export const syncStep = createStep( fields: [ "id", "title", - // @ts-ignore "sanity_product.*", ], filters, diff --git a/www/apps/resources/app/integrations/guides/segment/page.mdx b/www/apps/resources/app/integrations/guides/segment/page.mdx index 9798a160df..3dfeda2f1e 100644 --- a/www/apps/resources/app/integrations/guides/segment/page.mdx +++ b/www/apps/resources/app/integrations/guides/segment/page.mdx @@ -507,9 +507,9 @@ Next, you'll create the workflow that tracks the order placement event. To create the workflow, create the file `src/workflows/track-order-placed.ts` with the following content: export const workflowHighlights = [ - ["16", "useQueryGraphStep", "Retrieve the order's details."], - ["35", "transform", "Prepare data for tracking."], - ["58", "trackEventStep", "Track the order placement event in Segment."] + ["15", "useQueryGraphStep", "Retrieve the order's details."], + ["34", "transform", "Prepare data for tracking."], + ["57", "trackEventStep", "Track the order placement event in Segment."] ] ```ts title="src/workflows/track-order-placed.ts" highlights={workflowHighlights} @@ -527,7 +527,6 @@ type WorkflowInput = { export const trackOrderPlacedWorkflow = createWorkflow( "track-order-placed", ({ id }: WorkflowInput) => { - // @ts-ignore const { data: orders } = useQueryGraphStep({ entity: "order", fields: [ diff --git a/www/apps/resources/app/integrations/guides/slack/page.mdx b/www/apps/resources/app/integrations/guides/slack/page.mdx index 94b5f5c26b..9087b8a0b4 100644 --- a/www/apps/resources/app/integrations/guides/slack/page.mdx +++ b/www/apps/resources/app/integrations/guides/slack/page.mdx @@ -557,8 +557,8 @@ Medusa provides both steps in its `@medusajs/medusa/core-flows` package. So, to create the workflow, create the file `src/workflows/order-placed-notification.ts` with the following content: export const orderPlacedNotificationWorkflowHighlights = [ - ["14", "useQueryGraphStep", "Retrieve the order details using the Query Graph."], - ["35", "sendNotificationsStep", "Send a notification to Slack with the order details."] + ["13", "useQueryGraphStep", "Retrieve the order details using the Query Graph."], + ["34", "sendNotificationsStep", "Send a notification to Slack with the order details."] ] ```ts title="src/workflows/order-placed-notification.ts" highlights={orderPlacedNotificationWorkflowHighlights} @@ -574,7 +574,6 @@ type WorkflowInput = { export const orderPlacedNotificationWorkflow = createWorkflow( "order-placed-notification", ({ id }: WorkflowInput) => { - // @ts-ignore const { data: orders } = useQueryGraphStep({ entity: "order", fields: [ diff --git a/www/apps/resources/app/recipes/bundled-products/examples/standard/page.mdx b/www/apps/resources/app/recipes/bundled-products/examples/standard/page.mdx index f0bd702b0e..1336f920c3 100644 --- a/www/apps/resources/app/recipes/bundled-products/examples/standard/page.mdx +++ b/www/apps/resources/app/recipes/bundled-products/examples/standard/page.mdx @@ -622,7 +622,7 @@ export const createBundledProductWorkflowHighlights = [ ["38", "createRemoteLinkStep", "Create a link between the bundle and the Medusa product."], ["47", "transform", "Prepare the data to create links between bundle items and Medusa products."], ["61", "createRemoteLinkStep", "Create the links between the bundle items and the Medusa products."], - ["67", "useQueryGraphStep", "Retrieve the created bundle and its items."], + ["66", "useQueryGraphStep", "Retrieve the created bundle and its items."], ] ```ts title="src/workflows/create-bundled-product.ts" highlights={createBundledProductWorkflowHighlights} @@ -691,7 +691,6 @@ export const createBundledProductWorkflow = createWorkflow( }) // retrieve bundled product with items - // @ts-ignore const { data } = useQueryGraphStep({ entity: "bundle", fields: ["*", "items.*"], @@ -1890,10 +1889,10 @@ 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 = [ - ["29", "useQueryGraphStep", "Retrieve the bundle, its items, and their products and variants."], - ["45", "prepareBundleCartDataStep", "Validate and prepare the items to be added to the cart."], - ["51", "addToCartWorkflow", "Add the items in the bundle to the cart."], - ["59", "useQueryGraphStep", "Retrieve the updated cart."], + ["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."], ] ```ts title="src/workflows/add-bundle-to-cart.ts" highlights={addBundleToCartWorkflowHighlights} collapsibleLines="1-14" expandButtonLabel="Show Imports" @@ -1924,7 +1923,6 @@ type AddBundleToCartWorkflowInput = { export const addBundleToCartWorkflow = createWorkflow( "add-bundle-to-cart", ({ cart_id, bundle_id, quantity, items }: AddBundleToCartWorkflowInput) => { - // @ts-ignore const { data } = useQueryGraphStep({ entity: "bundle", fields: [ @@ -1954,7 +1952,6 @@ export const addBundleToCartWorkflow = createWorkflow( }, }) - // @ts-ignore const { data: updatedCarts } = useQueryGraphStep({ entity: "cart", filters: { id: cart_id }, @@ -2804,7 +2801,7 @@ 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."], - ["51", "useQueryGraphStep", "Retrieve the updated cart."], + ["50", "useQueryGraphStep", "Retrieve the updated cart."], ] ```ts title="src/workflows/remove-bundle-from-cart.ts" collapsibleLines="1-10" expandButtonLabel="Show Imports" highlights={removeBundleFromCartWorkflowHighlights} @@ -2857,7 +2854,6 @@ export const removeBundleFromCartWorkflow = createWorkflow( }) // retrieve cart again - // @ts-ignore const { data: updatedCarts } = useQueryGraphStep({ entity: "cart", fields: [ diff --git a/www/apps/resources/app/recipes/commerce-automation/restock-notification/page.mdx b/www/apps/resources/app/recipes/commerce-automation/restock-notification/page.mdx index 3c315c4f5e..7329da6440 100644 --- a/www/apps/resources/app/recipes/commerce-automation/restock-notification/page.mdx +++ b/www/apps/resources/app/recipes/commerce-automation/restock-notification/page.mdx @@ -612,7 +612,7 @@ export const subscriptionWorkflow1Highlights = [ ["16", "createWorkflow", "Create a workflow."], ["23", "transform", "Set the customer ID to an empty string if not provided."], ["28", "when", "If email is not set, try to retrieve customer by its ID."], - ["48", "transform", "Set the email either to the one in the input or the specified customer's email."], + ["47", "transform", "Set the email either to the one in the input or the specified customer's email."], ] ```ts title="src/workflows/create-restock-subscription/index.ts" highlights={subscriptionWorkflow1Highlights} @@ -650,7 +650,6 @@ export const createRestockSubscriptionWorkflow = createWorkflow( return !customer.email } ).then(() => { - // @ts-ignore const { data } = useQueryGraphStep({ entity: "customer", fields: ["email"], @@ -693,13 +692,13 @@ Next, replace the `TODO` with the following: export const subscriptionWorkflow2Highlights = [ ["1", "validateVariantOutOfStockStep", "Validate that the variant is out of stock in the specified sales channel."], - ["7", "useQueryGraphStep", "Get the restock subscription to check if it already exists."], - ["17", "when", "Perform an action if the restock subscription doesn't exist."], - ["21", "createRestockSubscriptionStep", "Create the restock subscription if it doesn't exist."], - ["29", "when", "Perform an action if the restock subscription exists."], - ["33", "updateRestockSubscriptionStep", "Update the restock subscription if it exists."], - ["40", "useQueryGraphStep", "Retrieve the restock subscription again to return it."], - ["50", "WorkflowResponse", "Return the restock subscription."] + ["6", "useQueryGraphStep", "Get the restock subscription to check if it already exists."], + ["16", "when", "Perform an action if the restock subscription doesn't exist."], + ["20", "createRestockSubscriptionStep", "Create the restock subscription if it doesn't exist."], + ["28", "when", "Perform an action if the restock subscription exists."], + ["32", "updateRestockSubscriptionStep", "Update the restock subscription if it exists."], + ["38", "useQueryGraphStep", "Retrieve the restock subscription again to return it."], + ["48", "WorkflowResponse", "Return the restock subscription."] ] ```ts title="src/workflows/create-restock-subscription/index.ts" highlights={subscriptionWorkflow2Highlights} @@ -708,7 +707,6 @@ validateVariantOutOfStockStep({ sales_channel_id, }) -// @ts-ignore const { data: restockSubscriptions } = useQueryGraphStep({ entity: "restock_subscription", fields: ["*"], @@ -741,7 +739,6 @@ when({ restockSubscriptions }, ({ restockSubscriptions }) => { }) }) -// @ts-ignore const { data: restockSubscription } = useQueryGraphStep({ entity: "restock_subscription", fields: ["*"], @@ -1365,7 +1362,6 @@ export const sendRestockNotificationsWorkflow = createWorkflow( return filters }) - // @ts-ignore const { data: restockedSubscriptionsWithEmails } = useQueryGraphStep({ entity: "restock_subscription", fields: ["*", "product_variant.*"], diff --git a/www/apps/resources/app/troubleshooting/_sections/query/expression-type-error.mdx b/www/apps/resources/app/troubleshooting/_sections/query/expression-type-error.mdx index c027484d4f..112d6d7102 100644 --- a/www/apps/resources/app/troubleshooting/_sections/query/expression-type-error.mdx +++ b/www/apps/resources/app/troubleshooting/_sections/query/expression-type-error.mdx @@ -1,5 +1,11 @@ import { CodeTab, CodeTabs } from "docs-ui" + + +This issue has been resolved in [Medusa v2.8.5+](https://github.com/medusajs/medusa/releases/tag/v2.8.5). If you're facing this error, please [update your Medusa application](!docs!/learn/update) and run the `dev` command. If the issue persists, please [report it on GitHub](https://github.com/medusajs/medusa/issues/new/choose). + + + If you get the following error while using `query.graph` or `useQueryGraphStep`: ```bash diff --git a/www/apps/resources/generated/edit-dates.mjs b/www/apps/resources/generated/edit-dates.mjs index 56575223d0..a2bc59ee3d 100644 --- a/www/apps/resources/generated/edit-dates.mjs +++ b/www/apps/resources/generated/edit-dates.mjs @@ -3135,7 +3135,7 @@ export const generatedEditDates = { "references/product/interfaces/product.FilterableProductProps/page.mdx": "2025-06-25T10:11:42.703Z", "references/types/HttpTypes/interfaces/types.HttpTypes.AdminBatchProductVariantRequest/page.mdx": "2024-12-09T13:21:34.309Z", "references/types/WorkflowTypes/ProductWorkflow/interfaces/types.WorkflowTypes.ProductWorkflow.ExportProductsDTO/page.mdx": "2025-06-25T10:11:40.398Z", - "app/integrations/guides/sanity/page.mdx": "2025-05-20T07:51:40.716Z", + "app/integrations/guides/sanity/page.mdx": "2025-06-26T12:41:14.249Z", "references/api_key/types/api_key.FindConfigOrder/page.mdx": "2024-11-25T17:49:28.715Z", "references/auth/types/auth.FindConfigOrder/page.mdx": "2024-11-25T17:49:28.887Z", "references/cart/types/cart.FindConfigOrder/page.mdx": "2024-11-25T17:49:29.455Z", @@ -5524,7 +5524,7 @@ export const generatedEditDates = { "references/workflows/classes/workflows.WorkflowResponse/page.mdx": "2025-04-11T09:04:53.140Z", "references/workflows/interfaces/workflows.ApplyStepOptions/page.mdx": "2025-05-20T07:51:41.181Z", "references/workflows/types/workflows.WorkflowData/page.mdx": "2024-12-23T13:57:08.059Z", - "app/integrations/guides/resend/page.mdx": "2025-05-20T07:51:40.716Z", + "app/integrations/guides/resend/page.mdx": "2025-06-26T12:38:47.063Z", "references/api_key_models/variables/api_key_models.ApiKey/page.mdx": "2025-06-25T10:11:44.704Z", "references/cart/ICartModuleService/methods/cart.ICartModuleService.updateShippingMethods/page.mdx": "2025-06-25T10:11:35.302Z", "references/cart/interfaces/cart.UpdateShippingMethodDTO/page.mdx": "2025-06-25T10:11:35.052Z", @@ -5565,7 +5565,7 @@ export const generatedEditDates = { "references/modules/sales_channel_models/page.mdx": "2024-12-10T14:55:13.205Z", "references/types/DmlTypes/types/types.DmlTypes.KnownDataTypes/page.mdx": "2024-12-17T16:57:19.922Z", "references/types/DmlTypes/types/types.DmlTypes.RelationshipTypes/page.mdx": "2024-12-10T14:54:55.435Z", - "app/recipes/commerce-automation/restock-notification/page.mdx": "2025-05-20T07:51:40.719Z", + "app/recipes/commerce-automation/restock-notification/page.mdx": "2025-06-26T12:40:40.542Z", "app/integrations/guides/shipstation/page.mdx": "2025-05-20T07:51:40.717Z", "app/nextjs-starter/guides/customize-stripe/page.mdx": "2025-05-20T07:51:40.717Z", "references/core_flows/Cart/Workflows_Cart/functions/core_flows.Cart.Workflows_Cart.listShippingOptionsForCartWithPricingWorkflow/page.mdx": "2025-06-25T10:11:28.364Z", @@ -5873,7 +5873,7 @@ export const generatedEditDates = { "app/commerce-modules/payment/account-holder/page.mdx": "2025-04-07T07:31:20.235Z", "app/troubleshooting/test-errors/page.mdx": "2025-01-31T13:08:42.639Z", "app/commerce-modules/product/variant-inventory/page.mdx": "2025-04-25T13:25:02.408Z", - "app/examples/guides/custom-item-price/page.mdx": "2025-05-20T07:51:40.712Z", + "app/examples/guides/custom-item-price/page.mdx": "2025-06-26T11:53:06.748Z", "references/core_flows/Cart/Steps_Cart/functions/core_flows.Cart.Steps_Cart.validateShippingStep/page.mdx": "2025-04-11T09:04:35.729Z", "references/core_flows/Cart/Steps_Cart/variables/core_flows.Cart.Steps_Cart.validateShippingStepId/page.mdx": "2025-02-11T11:36:39.228Z", "references/core_flows/Payment_Collection/Steps_Payment_Collection/functions/core_flows.Payment_Collection.Steps_Payment_Collection.createPaymentAccountHolderStep/page.mdx": "2025-02-24T10:48:31.714Z", @@ -5998,7 +5998,7 @@ export const generatedEditDates = { "references/core_flows/types/core_flows.UpdateRequestItemReturnValidationStepInput/page.mdx": "2025-06-25T10:11:34.024Z", "references/core_flows/types/core_flows.UpdateReturnShippingMethodValidationStepInput/page.mdx": "2025-06-25T10:11:34.036Z", "references/core_flows/types/core_flows.UpdateReturnValidationStepInput/page.mdx": "2025-06-25T10:11:34.046Z", - "app/examples/guides/quote-management/page.mdx": "2025-05-20T07:51:40.713Z", + "app/examples/guides/quote-management/page.mdx": "2025-06-26T12:37:46.148Z", "references/cart/interfaces/cart.CartCreditLineDTO/page.mdx": "2025-03-04T13:33:48.207Z", "references/cart/interfaces/cart.UpdateLineItemWithoutSelectorDTO/page.mdx": "2025-06-25T10:11:35.041Z", "references/cart_models/variables/cart_models.CreditLine/page.mdx": "2025-06-25T10:11:44.745Z", @@ -6037,7 +6037,7 @@ export const generatedEditDates = { "app/nextjs-starter/guides/revalidate-cache/page.mdx": "2025-05-01T15:33:42.490Z", "app/storefront-development/cart/totals/page.mdx": "2025-03-27T14:47:14.252Z", "app/storefront-development/checkout/order-confirmation/page.mdx": "2025-03-27T14:29:45.669Z", - "app/how-to-tutorials/tutorials/product-reviews/page.mdx": "2025-05-20T07:51:40.713Z", + "app/how-to-tutorials/tutorials/product-reviews/page.mdx": "2025-06-26T12:34:50.976Z", "app/troubleshooting/data-models/default-fields/page.mdx": "2025-03-21T06:59:06.775Z", "app/troubleshooting/medusa-admin/blocked-request/page.mdx": "2025-03-21T06:53:34.854Z", "app/troubleshooting/nextjs-starter-rewrites/page.mdx": "2025-03-21T07:09:08.901Z", @@ -6047,11 +6047,11 @@ export const generatedEditDates = { "app/troubleshooting/storefront-pak-sc/page.mdx": "2025-03-21T07:08:57.546Z", "app/troubleshooting/workflow-errors/step-x-defined/page.mdx": "2025-03-21T07:09:02.741Z", "app/troubleshooting/workflow-errors/when-then/page.mdx": "2025-03-21T08:35:45.145Z", - "app/how-to-tutorials/tutorials/abandoned-cart/page.mdx": "2025-05-20T07:51:40.713Z", - "app/integrations/guides/algolia/page.mdx": "2025-06-16T09:29:42.556Z", + "app/how-to-tutorials/tutorials/abandoned-cart/page.mdx": "2025-06-26T11:45:57.112Z", + "app/integrations/guides/algolia/page.mdx": "2025-06-26T12:33:36.673Z", "app/integrations/guides/magento/page.mdx": "2025-05-20T07:51:40.716Z", "app/js-sdk/auth/overview/page.mdx": "2025-03-28T08:05:32.622Z", - "app/how-to-tutorials/tutorials/loyalty-points/page.mdx": "2025-06-24T08:50:10.114Z", + "app/how-to-tutorials/tutorials/loyalty-points/page.mdx": "2025-06-26T11:58:07.874Z", "references/js_sdk/admin/Admin/properties/js_sdk.admin.Admin.plugin/page.mdx": "2025-04-11T09:04:55.084Z", "references/js_sdk/admin/Customer/methods/js_sdk.admin.Customer.createAddress/page.mdx": "2025-05-20T07:51:40.936Z", "references/js_sdk/admin/Customer/methods/js_sdk.admin.Customer.deleteAddress/page.mdx": "2025-04-11T09:04:54.015Z", @@ -6197,7 +6197,7 @@ export const generatedEditDates = { "references/core_flows/interfaces/core_flows.ValidateDraftOrderStepInput/page.mdx": "2025-06-25T10:11:32.719Z", "app/commerce-modules/product/guides/variant-inventory/page.mdx": "2025-04-25T14:22:42.329Z", "app/troubleshooting/validation-error/page.mdx": "2025-04-25T14:14:57.568Z", - "app/integrations/guides/contentful/page.mdx": "2025-05-20T07:51:40.715Z", + "app/integrations/guides/contentful/page.mdx": "2025-06-26T11:55:43.353Z", "references/modules/events/page.mdx": "2025-06-05T19:05:53.162Z", "references/module_events/module_events.Auth/page.mdx": "2025-05-20T07:51:40.956Z", "references/module_events/module_events.Cart/page.mdx": "2025-05-20T07:51:40.956Z", @@ -6482,7 +6482,7 @@ export const generatedEditDates = { "references/types/interfaces/types.WebhookActionResult/page.mdx": "2025-05-20T07:51:41.086Z", "references/types/interfaces/types.WebhookActionData/page.mdx": "2025-05-20T07:51:41.086Z", "app/commerce-modules/tax/tax-provider/page.mdx": "2025-05-20T07:51:40.711Z", - "app/recipes/bundled-products/examples/standard/page.mdx": "2025-06-23T15:54:25.807Z", + "app/recipes/bundled-products/examples/standard/page.mdx": "2025-06-26T11:52:18.819Z", "app/recipes/bundled-products/page.mdx": "2025-05-20T07:51:40.718Z", "app/infrastructure-modules/analytics/local/page.mdx": "2025-05-29T07:08:55.749Z", "app/infrastructure-modules/analytics/page.mdx": "2025-05-26T14:48:25.803Z", @@ -6526,7 +6526,7 @@ export const generatedEditDates = { "references/events/Payment/variables/events.Payment.PaymentEvents/page.mdx": "2025-05-28T13:06:45.060Z", "references/events/events.Payment/page.mdx": "2025-05-28T13:06:45.059Z", "references/module_events/module_events.Payment/page.mdx": "2025-06-05T19:05:53.217Z", - "app/integrations/guides/segment/page.mdx": "2025-05-26T14:46:22.757Z", + "app/integrations/guides/segment/page.mdx": "2025-06-26T12:56:50.931Z", "references/core_flows/Draft_Order/Steps_Draft_Order/functions/core_flows.Draft_Order.Steps_Draft_Order.deleteDraftOrdersStep/page.mdx": "2025-06-05T19:05:37.066Z", "references/core_flows/Draft_Order/Steps_Draft_Order/variables/core_flows.Draft_Order.Steps_Draft_Order.deleteDraftOrdersStepId/page.mdx": "2025-06-05T19:05:37.064Z", "references/core_flows/Draft_Order/Workflows_Draft_Order/functions/core_flows.Draft_Order.Workflows_Draft_Order.deleteDraftOrdersWorkflow/page.mdx": "2025-06-25T10:11:28.944Z", @@ -6544,14 +6544,15 @@ export const generatedEditDates = { "references/utils/Payment/variables/utils.Payment.PaymentEvents/page.mdx": "2025-06-05T19:05:53.491Z", "references/utils/types/utils.NormalizedRow/page.mdx": "2025-06-05T19:05:53.365Z", "references/utils/utils.Payment/page.mdx": "2025-06-05T19:05:53.489Z", - "app/integrations/guides/slack/page.mdx": "2025-06-03T09:37:42.528Z", + "app/integrations/guides/slack/page.mdx": "2025-06-26T12:57:20.880Z", "app/integrations/guides/sentry/page.mdx": "2025-06-16T10:11:29.955Z", - "app/integrations/guides/mailchimp/page.mdx": "2025-06-24T08:08:35.034Z", - "app/how-to-tutorials/tutorials/first-purchase-discounts/page.mdx": "2025-06-25T10:44:38.113Z", + "app/integrations/guides/mailchimp/page.mdx": "2025-06-26T11:59:15.303Z", + "app/how-to-tutorials/tutorials/first-purchase-discounts/page.mdx": "2025-06-26T11:55:27.175Z", "references/types/CommonTypes/interfaces/types.CommonTypes.CookieOptions/page.mdx": "2025-06-25T10:11:37.088Z", "references/types/types/types.RemoteQueryFilterOperators/page.mdx": "2025-06-25T10:11:39.849Z", "references/types/ModulesSdkTypes/types/types.ModulesSdkTypes.InternalRemoteQueryFilters/page.mdx": "2025-06-25T10:11:39.832Z", "references/workflows/IDistributedTransactionStorage/methods/workflows.IDistributedTransactionStorage.clearExpiredExecutions/page.mdx": "2025-06-25T10:11:44.945Z", "references/types/interfaces/types.InitiatePaymentOutput/page.mdx": "2025-06-25T10:11:39.942Z", - "references/types/interfaces/types.UpdatePaymentOutput/page.mdx": "2025-06-25T10:11:39.945Z" + "references/types/interfaces/types.UpdatePaymentOutput/page.mdx": "2025-06-25T10:11:39.945Z", + "app/how-to-tutorials/tutorials/re-order/page.mdx": "2025-06-26T12:38:24.308Z" } \ No newline at end of file diff --git a/www/apps/resources/generated/generated-commerce-modules-sidebar.mjs b/www/apps/resources/generated/generated-commerce-modules-sidebar.mjs index 482700981e..12ff6194c6 100644 --- a/www/apps/resources/generated/generated-commerce-modules-sidebar.mjs +++ b/www/apps/resources/generated/generated-commerce-modules-sidebar.mjs @@ -1079,6 +1079,14 @@ const generatedgeneratedCommerceModulesSidebarSidebar = { "sort_sidebar": "alphabetize", "description": "Learn how to use the Cart Module in your customizations on the Medusa application server.", "children": [ + { + "loaded": true, + "isPathHref": true, + "type": "ref", + "title": "Add Gift Message", + "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/gift-message", + "children": [] + }, { "loaded": true, "isPathHref": true, diff --git a/www/apps/resources/generated/generated-tools-sidebar.mjs b/www/apps/resources/generated/generated-tools-sidebar.mjs index eeefc5ee8e..df35670957 100644 --- a/www/apps/resources/generated/generated-tools-sidebar.mjs +++ b/www/apps/resources/generated/generated-tools-sidebar.mjs @@ -803,6 +803,14 @@ const generatedgeneratedToolsSidebarSidebar = { "autogenerate_as_ref": true, "sort_sidebar": "alphabetize", "children": [ + { + "loaded": true, + "isPathHref": true, + "type": "ref", + "title": "Add Gift Message", + "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/gift-message", + "children": [] + }, { "loaded": true, "isPathHref": true,