- Get Started
- Product
- Resources
- Tools & SDKs
- Framework
- Reference
- User Guide
- Get Started
- Product
- Resources
- Tools & SDKs
- Framework
- Reference
- User Guide
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.
Medusa's framework for customization 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.
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:
You'll first be asked for the project's name. You can also optionally choose to install the Next.js starter storefront.
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.
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.
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. 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:
1type Options = {2 apiKey: string3}4 5export default class ErpModuleService {6 private options: Options7 private client8 9 constructor({}, options: Options) {10 this.options = options11 // TODO initialize client that connects to ERP12 }13 14 async getProducts() {15 // assuming client has a method to fetch products16 return this.client.getProducts()17 }18 19 // TODO add more methods20}
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 on how to create a module. You can also refer to the Odoo Integration guide 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 to allow clients to trigger the workflow's execution.
- Subscribers to trigger the workflow when an event occurs.
- Scheduled jobs 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:
1import { 2 createStep, createWorkflow, StepResponse, 3 transform, WorkflowResponse,4} from "@medusajs/framework/workflows-sdk"5import { createProductsWorkflow } from "@medusajs/medusa/core-flows"6 7const getProductsFromErpStep = createStep(8 "get-products-from-erp",9 async (_, { container }) => {10 const erpModuleService = container.resolve("erp")11 12 const products = await erpModuleService.getProducts()13 14 return new StepResponse(products)15 }16)17 18export const syncFromErpWorkflow = createWorkflow(19 "sync-from-erp",20 () => {21 const erpProducts = getProductsFromErpStep()22 23 const productsToCreate = transform({24 erpProducts,25 }, (data) => {26 // TODO prepare ERP products to be created in Medusa27 return data.erpProducts.map((erpProduct) => {28 return {29 title: erpProduct.title,30 external_id: erpProduct.id,31 variants: erpProduct.variants.map((variant) => ({32 title: variant.title,33 metadata: {34 external_id: variant.id,35 },36 })),37 // other data...38 }39 })40 })41 42 createProductsWorkflow.runAsStep({43 input: {44 products: productsToCreate,45 },46 })47 48 return new WorkflowResponse({49 erpProducts,50 })51 }52)
In the above file, you first create a getProductsFromErpStep
that resolves the ERP Module's service from the Medusa container, 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.
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:
1import {2 MedusaContainer,3} from "@medusajs/framework/types"4import { syncFromErpWorkflow } from "../workflows/sync-from-erp"5 6export default async function syncProductsJob(container: MedusaContainer) {7 await syncFromErpWorkflow(container).run({})8}9 10export const config = {11 name: "daily-product-sync",12 schedule: "0 0 * * *", // Every day at midnight13}
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.
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.
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:
1import { MedusaError } from "@medusajs/framework/utils"2import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"3 4export type GetVariantErpPriceStepInput = {5 variant_external_id: string6 currencyCode: string7 quantity: number8}9 10export const getVariantErpPriceStep = createStep(11 "get-variant-erp-price",12 async (input: GetVariantErpPriceStepInput, { container }) => {13 const { variant_external_id, currencyCode, quantity } = input14 15 const erpModuleService = container.resolve("erp")16 17 const price = await erpModuleService.getErpPrice(18 variant_external_id, 19 currencyCode20 )21 22 return new StepResponse(23 price * quantity24 )25 }26)
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 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:
1import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"2import { addToCartWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows"3import { getVariantErpPriceStep } from "./steps/get-erp-price"4 5type AddCustomToCartWorkflowInput = {6 cart_id: string7 item: {8 variant_id: string9 quantity: number10 }11}12 13export const addCustomToCartWorkflow = createWorkflow(14 "add-custom-to-cart",15 ({ cart_id, item }: AddCustomToCartWorkflowInput) => {16 // Retrieve the cart's currency code17 const { data: carts } = useQueryGraphStep({18 entity: "cart",19 filters: { id: cart_id },20 fields: ["id", "currency_code"],21 })22 23 // Retrieve the variant's metadata to get its ERP ID24 const { data: variants } = useQueryGraphStep({25 entity: "variant",26 fields: [27 "id",28 "metadata",29 ],30 filters: {31 id: item.variant_id,32 },33 options: {34 throwIfKeyNotFound: true,35 },36 }).config({ name: "retrieve-variant" })37 38 // get the variant's price from the ERP39 const price = getVariantErpPriceStep({40 variant_external_id: variants[0].metadata?.external_id as string,41 currencyCode: carts[0].currency_code,42 quantity: item.quantity,43 })44 45 // prepare to add the variant to the cart46 const itemToAdd = transform({47 item,48 price,49 variants,50 }, (data) => {51 return [{52 ...data.item,53 unit_price: data.price,54 metadata: data.variants[0].metadata,55 }]56 })57 58 // add the variant to the cart59 addToCartWorkflow.runAsStep({60 input: {61 items: itemToAdd,62 cart_id,63 },64 })65 }66)
In this workflow, you first fetch the details of the cart and the variant from the database using the useQueryGraphStep 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, 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 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:
1import { MedusaRequest, MedusaResponse } from "@medusajs/framework"2import { HttpTypes } from "@medusajs/framework/types"3import { addCustomToCartWorkflow } from "../../../../../workflows/add-custom-to-cart"4 5export const POST = async (6 req: MedusaRequest, 7 res: MedusaResponse8) => {9 const { id } = req.params10 const item = req.body11 12 await addCustomToCartWorkflow(req.scope)13 .run({14 input: {15 cart_id: id,16 item,17 },18 })19 20 res.status(200).json({ success: true })21}
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. You can also add request body validation as explained in this documentation.
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. 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 to check if the customer is allowed to purchase the product. For example:
1import { MedusaError } from "@medusajs/framework/utils"2import { addToCartWorkflow } from "@medusajs/medusa/core-flows"3 4addToCartWorkflow.hooks.validate(5 async ({ input, cart }, { container }) => {6 const erpModuleService = container.resolve("erp")7 const productModuleService = container.resolve("product")8 const customerModuleService = container.resolve("customer")9 10 const customer = cart.customer_id ? await customerModuleService.retrieveCustomer(cart.customer_id) : undefined11 const productVariants = await productModuleService.listProductVariants({12 id: input.items.map((item) => item.variant_id).filter(Boolean) as string[],13 }, {14 relations: ["product"],15 })16 17 await Promise.all(18 productVariants.map(async (productVariant) => {19 if (!productVariant.product?.external_id) {20 // product isn't in ERP21 return22 }23 24 const isAllowed = await erpModuleService.canCompanyPurchaseProduct(25 productVariant.product.external_id,26 customer?.company_name || undefined27 )28 29 if (!isAllowed) {30 throw new MedusaError(31 MedusaError.Types.NOT_ALLOWED,32 `Company ${customer?.company_name || ""} is not allowed to purchase product ${productVariant.product.id}`33 )34 }35 })36 )37 }38)
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. 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.
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:
- A workflow that syncs the order from Medusa to the ERP system. You can execute this workflow in a subscriber that is triggered when an order is created in Medusa.
- 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:
1import { createStep, createWorkflow, StepResponse, WorkflowResponse } from "@medusajs/framework/workflows-sdk"2import { updateOrderWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows"3import { OrderDTO } from "@medusajs/framework/types"4 5type StepInput = {6 order: OrderDTO7}8 9export const syncOrderToErpStep = createStep(10 "sync-order-to-erp",11 async ({ order }: StepInput, { container }) => {12 const erpModuleService = container.resolve("erp")13 14 const erpOrderId = await erpModuleService.createOrder(order)15 16 return new StepResponse(erpOrderId, erpOrderId)17 },18 async (erpOrderId, { container }) => {19 if (!erpOrderId) {20 return21 }22 23 const erpModuleService = container.resolve("erp")24 await erpModuleService.deleteOrder(erpOrderId)25 }26)27 28type WorkflowInput = {29 order_id: string30}31 32export const syncOrderToErpWorkflow = createWorkflow(33 "sync-order-to-erp",34 ({ order_id }: WorkflowInput) => {35 // @ts-ignore36 const { data: orders } = useQueryGraphStep({37 entity: "order",38 fields: [39 "*",40 "shipping_address.*",41 "billing_address.*",42 "items.*",43 ],44 filters: {45 id: order_id,46 },47 options: {48 throwIfKeyNotFound: true,49 },50 })51 52 const erpOrderId = syncOrderToErpStep({53 order: orders[0] as unknown as OrderDTO,54 })55 56 updateOrderWorkflow.runAsStep({57 input: {58 id: order_id,59 user_id: "",60 metadata: {61 external_id: erpOrderId,62 },63 },64 })65 66 return new WorkflowResponse(erpOrderId)67})
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.
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:
1import type {2 SubscriberArgs,3 SubscriberConfig,4} from "@medusajs/framework"5import { syncOrderToErpWorkflow } from "../workflows/sync-order-to-erp"6 7export default async function productCreateHandler({8 event: { data },9 container,10}: SubscriberArgs<{ id: string }>) {11 const { result } = await syncOrderToErpWorkflow(container)12 .run({13 input: {14 order_id: data.id,15 },16 })17 18 console.log(`Order synced to ERP with id: ${result}`)19}20 21export const config: SubscriberConfig = {22 event: "order.placed",23}
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.
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:
1import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"2import { updateOrderWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows"3 4type Input = {5 order_erp_data: any6}7 8export const syncOrderFromErpWorkflow = createWorkflow(9 "sync-order-from-erp",10 ({ order_erp_data }: Input) => {11 const { data: orders } = useQueryGraphStep({12 entity: "order",13 fields: ["*"],14 filters: {15 // @ts-ignore16 metadata: {17 external_id: order_erp_data.id,18 },19 },20 })21 22 const orderUpdateData = transform({23 order_erp_data,24 orders,25 }, (data) => {26 return {27 id: data.orders[0].id,28 user_id: "",29 status: data.order_erp_data.status,30 }31 })32 33 const order = updateOrderWorkflow.runAsStep({34 input: orderUpdateData,35 })36 37 return new WorkflowResponse(order)38 }39)
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:
1import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"2import { syncOrderFromErpWorkflow } from "../../workflows/sync-order-from-erp"3 4export async function POST(5 req: MedusaRequest,6 res: MedusaResponse7) {8 const webhookData = req.rawBody9 10 // TODO construct the order object from the webhook data11 12 // execute the workflow13 await syncOrderFromErpWorkflow(req.scope).run({14 input: {15 order_erp_data, // pass constructed order object16 },17 })18}
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
:
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.
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 use case, you can use the validate
hook of the addToCartWorkflow
or the completeCartWorkflow to check the product's availability in the ERP system. For example:
1import { MedusaError } from "@medusajs/framework/utils"2import { addToCartWorkflow } from "@medusajs/medusa/core-flows"3 4addToCartWorkflow.hooks.validate(5 async ({ input }, { container }) => {6 const erpModuleService = container.resolve("erp")7 const productModuleService = container.resolve("product")8 9 const productVariants = await productModuleService.listProductVariants({10 id: input.items.map((item) => item.variant_id).filter(Boolean) as string[],11 }, {12 relations: ["product"],13 })14 15 await Promise.all(16 productVariants.map(async (productVariant) => {17 const erpVariant = await erpModuleService.getQty(productVariant.metadata?.external_id)18 const item = input.items.find((item) => item.variant_id === productVariant.id)!19 20 if (erpVariant.qty_available < item.quantity && !erpVariant.allow_out_of_stock_order) {21 throw new MedusaError(22 MedusaError.Types.NOT_ALLOWED,23 `Not enough stock for product ${productVariant.product?.id}`24 )25 }26 })27 )28 }29)
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 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.