Integrate Avalara (AvaTax) for Tax Calculation

In this tutorial, you'll learn how to integrate Avalara with Medusa to handle tax calculations.

When you install a Medusa application, you get a fully-fledged commerce platform with a Framework for customization. Medusa's architecture supports integrating third-party services, such as tax providers, allowing you to build custom features around core commerce flows.

Avalara is a leading provider of tax compliance solutions, including sales tax calculation, filing, and remittance. By integrating Avalara with Medusa, you can calculate taxes during checkout with accurate rates based on customer location.

Summary#

By following this tutorial, you'll learn how to:

  • Install and set up Medusa.
  • Create the Avalara Tax Module Provider that calculates taxes using Avalara.
  • Create transactions in Avalara when an order is placed.
  • Sync products to Avalara to manage their tax codes and classifications.

You can follow this tutorial whether you're new to Medusa or an advanced Medusa developer.

Diagram showing Avalara integration with Medusa for tax calculation during checkout

Example Repository
Find the full code of the guide in this repository.

Step 1: Install a Medusa Application#

Start by installing the Medusa application on your machine with the following command:

Terminal
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, choose "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 separate directory named {project-name}-storefront.

Why is the storefront installed separately? The Medusa application consists 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. Learn more in Medusa's Architecture documentation.

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.

Ran into Errors? Check out the troubleshooting guides for help.

Step 2: Create Avalara Tax Module Provider#

To integrate third-party services into Medusa, you create a custom module. A module is a reusable package with functionalities related to a single feature or domain.

Medusa's Tax Module implements concepts and functionalities related to taxes, but delegates tax calculations to external services through Tax Module Providers.

In this step, you'll integrate Avalara as a Tax Module Provider. Later, you'll use it to calculate taxes in your Medusa application.

Note: Refer to the Modules documentation to learn more about modules in Medusa.

a. Install AvaTax SDK#

First, install the AvaTax SDK package to interact with Avalara's API. Run the following command in your Medusa application's directory:

b. Create Module Directory#

A module is created under the src/modules directory of your Medusa application. So, create the directory src/modules/avalara.

c. Define Module Options#

Next, define a TypeScript type for the Avalara Module options. These options configure the module when it's registered in the Medusa application.

Create the file src/modules/avalara/types.ts with the following content:

src/modules/avalara/types.ts
1export type ModuleOptions = {2  username?: string3  password?: string4  appName?: string5  appVersion?: string6  appEnvironment?: string7  machineName?: string8  timeout?: number9  companyCode?: string10  companyId?: number11}

The module options include:

  • username: The Avalara account ID or username.
  • password: The Avalara license key or password.
  • appName: The name of your application. Defaults to medusa.
  • appVersion: The version of your application. Defaults to 1.0.0.
  • appEnvironment: The environment of your application, either production or sandbox. Defaults to sandbox.
  • machineName: The name of the machine where your application is running. Defaults to medusa.
  • timeout: The timeout for API requests in milliseconds. Defaults to 3000.
  • companyCode: The Avalara company code to use for tax calculations. If not provided, the default company in Avalara is used.
  • companyId: The Avalara company ID, which is necessary later when creating items in Avalara.

You'll learn how to set these options when you register the module.

d. Create Avalara Service#

A module has a service that contains its logic. For Tax Module Providers, the service implements the logic to calculate taxes using the third-party service.

To create the service for the Avalara Tax Module Provider, create the file src/modules/avalara/service.ts with the following content:

src/modules/avalara/service.ts
1import { ITaxProvider } from "@medusajs/framework/types"2import Avatax from "avatax"3import { MedusaError } from "@medusajs/framework/utils"4import { ModuleOptions } from "./types"5
6type InjectedDependencies = {7  // Add any dependencies you want to inject via the module container8}9
10class AvalaraTaxModuleProvider implements ITaxProvider {11  static identifier = "avalara"12  private readonly avatax: Avatax13  private readonly options: ModuleOptions14
15  constructor({}: InjectedDependencies, options: ModuleOptions) {16    this.options = options17    if (!options?.username || !options?.password || !options?.companyId) {18      throw new MedusaError(19        MedusaError.Types.INVALID_DATA,20        "Avalara module options are required: username, password and companyId"21      )22    }23    this.avatax = new Avatax({24      appName: options.appName || "medusa",25      appVersion: options.appVersion || "1.0.0",26      machineName: options.machineName || "medusa",27      environment: options.appEnvironment === "production" ? "production" : "sandbox",28      timeout: options.timeout || 3000,29    }).withSecurity({30      username: options.username,31      password: options.password,32    })33  }34  getIdentifier(): string {35    return AvalaraTaxModuleProvider.identifier36  }37}38
39export default AvalaraTaxModuleProvider

A Tax Module Provider's service must implement the ITaxProvider interface. It must also have an identifier static property with the unique identifier of the provider.

The constructor of a module's service receives the following parameters:

  1. An object with the dependencies to resolve from the Module's container.
  2. An object with the module options passed to the provider when it's registered.

In the constructor, you validate that the required options are provided. Then, you create an instance of the Avatax client using the provided options.

You also define the getIdentifier method required by the ITaxProvider interface, which returns the provider's identifier.

getTaxLines Method

Next, you'll implement the getTaxLines method required by the ITaxProvider interface. This method calculates the tax lines for line items and shipping methods. Medusa uses this method during checkout to calculate taxes.

Before creating the method, you'll create a createTransaction method that creates a transaction in Avalara to calculate taxes.

First, add the following import at the top of the file:

src/modules/avalara/service.ts
import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel"

Then, add the following in the AvalaraTaxModuleProvider class:

src/modules/avalara/service.ts
1class AvalaraTaxModuleProvider implements ITaxProvider {2  // ...3  async createTransaction(model: CreateTransactionModel) {4    try {5      const response = await this.avatax.createTransaction({6        model,7        include: "Details",8      })9
10      return response11    } catch (error) {12      throw new MedusaError(13        MedusaError.Types.UNEXPECTED_STATE,14        `An error occurred while creating transaction for Avalara: ${error}`15      )16    }17  }18}

This method receives the details of the transaction to create in Avalara. It calls the avatax.createTransaction method to create the transaction. If the transaction's type ends with Order, Avalara will calculate and return the tax details only. If it ends with Invoice, Avalara will save the transaction.

You return the response from Avalara, which contains the tax details.

Tip: Refer to Avalara's documentation for details on other accepted parameters when creating a transaction.

You'll now add the getTaxLines method to calculate tax lines using Avalara.

First, add the following imports at the top of the file:

src/modules/avalara/service.ts
1import { 2  ItemTaxCalculationLine, 3  ItemTaxLineDTO, 4  ShippingTaxCalculationLine, 5  ShippingTaxLineDTO, 6  TaxCalculationContext,7} from "@medusajs/framework/types"

Then, add the method to the AvalaraTaxModuleProvider class:

src/modules/avalara/service.ts
1class AvalaraTaxModuleProvider implements ITaxProvider {2  // ...3
4  async getTaxLines(5    itemLines: ItemTaxCalculationLine[], 6    shippingLines: ShippingTaxCalculationLine[],7    context: TaxCalculationContext8  ): Promise<(ItemTaxLineDTO | ShippingTaxLineDTO)[]> {9    try {10      const currencyCode = (11        itemLines[0]?.line_item.currency_code || shippingLines[0]?.shipping_line.currency_code12      )?.toUpperCase()13      const response = await this.createTransaction({14        lines: [15          ...(itemLines.length ? itemLines.map((line) => {16            const quantity = Number(line.line_item.quantity) ?? 017            return {18              number: line.line_item.id,19              quantity,20              amount: quantity * (Number(line.line_item.unit_price) ?? 0),21              taxCode: line.rates.find((rate) => rate.is_default)?.code ?? "",22              itemCode: line.line_item.product_id,23            }24          }) : []),25          ...(shippingLines.length ? shippingLines.map((line) => {26            return {27              number: line.shipping_line.id,28              quantity: 1,29              amount: Number(line.shipping_line.unit_price) ?? 0,30              taxCode: line.rates.find((rate) => rate.is_default)?.code ?? "",31            }32          }) : []),33        ],34        date: new Date(),35        customerCode: context.customer?.id ?? "",36        addresses: {37          "singleLocation": {38            line1: context.address.address_1 ?? "",39            line2: context.address.address_2 ?? "",40            city: context.address.city ?? "",41            region: context.address.province_code ?? "",42            postalCode: context.address.postal_code ?? "",43            country: context.address.country_code.toUpperCase() ?? "",44          },45        },46        currencyCode,47        type: DocumentType.SalesOrder,48      })49
50      // TODO return tax lines51    } catch (error) {52      throw new MedusaError(53        MedusaError.Types.UNEXPECTED_STATE,54        `An error occurred while getting tax lines from Avalara: ${error}`55      )56    }57  }58}

The getTaxLines method receives the following parameters:

  • itemLines: An array of line items to calculate taxes for.
  • shippingLines: An array of shipping methods to calculate taxes for.
  • context: Additional context for tax calculation, such as customer and address information.

In the method, you create a transaction in Avalara for both line items and shipping methods using the avatax.createTransaction method. Since you set the type to DocumentType.SalesOrder, Avalara will calculate and return the tax details for the provided items and shipping methods without saving the transaction.

Tip: Refer to Avalara's documentation for details on other accepted parameters when creating a transaction.

Next, you'll extract the tax lines from the response and return them in the expected format. Replace the // TODO return tax lines comment with the following:

src/modules/avalara/service.ts
1const taxLines: (ItemTaxLineDTO | ShippingTaxLineDTO)[] = []2response?.lines?.forEach((line) => {3  line.details?.forEach((detail) => {4    const isShippingLine = shippingLines.find(5      (sLine) => sLine.shipping_line.id === line.lineNumber6    ) !== undefined7    const commonData = {8      rate: (detail.rate ?? 0) * 100,9      name: detail.taxName ?? "",10      code: line.taxCode || detail.rateTypeCode || detail.signatureCode || "",11      provider_id: this.getIdentifier(),12    }13    if (!isShippingLine) {14      taxLines.push({15        ...commonData,16        line_item_id: line.lineNumber ?? "",17      })18    } else {19      taxLines.push({20        ...commonData,21        shipping_line_id: line.lineNumber ?? "",22      })23    }24  })25})26
27return taxLines

This code extracts the tax details from the Avalara response and constructs an array of tax lines in the expected format. Finally, it returns the array of tax lines.

e. Export Module Definition#

You've finished implementing the Avalara Tax Module Provider's service and its required method.

The final piece of a module is its definition, which you export in an index.ts file at the module's root directory. This definition tells Medusa the module's details, including its service.

To create the module's definition, create the file src/modules/avalara/index.ts with the following content:

src/modules/avalara/index.ts
1import AvalaraTaxModuleProvider from "./service"2import { 3  ModuleProvider, 4  Modules,5} from "@medusajs/framework/utils"6
7export default ModuleProvider(Modules.TAX, {8  services: [AvalaraTaxModuleProvider],9})

You use ModuleProvider from the Modules SDK to create the module provider's definition. It accepts two parameters:

  1. The name of the module that this provider belongs to, which is Modules.TAX in this case.
  2. An object with a required services property indicating the Module Provider's services.

f. Add Module Provider to Medusa's Configuration#

After finishing the module, add it to Medusa's configuration to start using it.

In medusa-config.ts, add a modules property:

medusa-config.ts
1module.exports = defineConfig({2  // ...3  modules: [4    {5      resolve: "@medusajs/medusa/tax",6      options: {7        providers: [8          {9            resolve: "./src/modules/avalara",10            id: "avalara",11            options: {12              username: process.env.AVALARA_USERNAME,13              password: process.env.AVALARA_PASSWORD,14              appName: process.env.AVALARA_APP_NAME,15              appVersion: process.env.AVALARA_APP_VERSION,16              appEnvironment: process.env.AVALARA_APP_ENVIRONMENT,17              machineName: process.env.AVALARA_MACHINE_NAME,18              timeout: process.env.AVALARA_TIMEOUT,19              companyCode: process.env.AVALARA_COMPANY_CODE,20              companyId: process.env.AVALARA_COMPANY_ID,21            },22          },23        ],24      },25    },26  ],27})

To pass a Tax Module Provider to the Tax Module, add the modules property to the Medusa configuration and pass the Tax Module in its value.

The Tax Module accepts a providers option, which is an array of Tax Module Providers to register.

You register the Avalara Tax Module Provider and pass it the expected options.

g. Set Environment Variables#

Finally, set the required environment variables in your .env file:

.env
AVALARA_USERNAME=AVALARA_PASSWORD=AVALARA_APP_ENVIRONMENT=production # or sandboxAVALARA_COMPANY_ID=

You set the following variables:

  1. AVALARA_USERNAME: Your Avalara account ID. You can retrieve it from the Avalara dashboard by clicking on "Account" at the top right. It's at the top of the dropdown.

Avalara Account ID in the account dropdown at the top right of the Avalara dashboard

  1. AVALARA_PASSWORD: Your Avalara license key. To retrieve it from the Avalara dashboard:
    1. From the sidebar, click on "Integrations."
    2. Choose the "License Keys" tab.
    3. Click on the "Generate new key" button.
    4. Confirm generating the key.
    5. Copy the generated key.

The Avalara license key tab with the "Generate new key" button

  1. AVALARA_APP_ENVIRONMENT: The environment of your application, which can be either production or sandbox. Set it based on your Avalara account type. Note that Avalara provides separate accounts for production and sandbox environments.
  2. AVALARA_COMPANY_ID: The company ID in Avalara for creating products for tax code management. To retrieve it from the Avalara dashboard:
    1. From the sidebar, click on "Settings" → "All settings."
    2. Find the "Companies" card and click on "Manage."
    3. Click on the company you want to use.
    4. Copy the company ID from the URL. It's the number at the end of the URL, after /companies/.

You can also set other optional environment variables for further configuration. Refer to Avalara's documentation for more details about these options.


Step 3: Test Tax Calculation with Avalara#

You'll test the Avalara integration using the Next.js Starter Storefront that you installed earlier. You'll proceed through checkout and verify that taxes are calculated using Avalara.

Prerequisite: Set Region's Provider to Avalara#

Before testing the integration, configure the regions you want to use Avalara for tax calculations.

First, run the following command in your Medusa application's directory to start the server:

Then:

  1. Go to http://localhost:9000/admin and log in to the Medusa Admin dashboard.
  2. Go to "Settings" → "Tax Regions."
  3. Select the country you want to configure Avalara for. You can repeat these steps for multiple countries.
  4. In the first section, click on the icon at the top right and choose "Edit."
  5. In the "Tax Provider" dropdown, select "Avalara (AVALARA)."
  6. Click on "Save."

Setting Avalara as the tax provider for a region in the Medusa Admin dashboard

Test Checkout with Avalara#

Now you can test the checkout process in the Next.js Starter Storefront.

Reminder: 

The Next.js Starter Storefront was installed in a separate directory from Medusa. The directory name is {your-project}-storefront.

If your Medusa application's directory is medusa-avalara, find the storefront by going back to the parent directory and changing to the medusa-avalara-storefront directory:

Terminal
cd ../medusa-avalara-storefront # change based on your project name

While the Medusa server is running, open another terminal window in the storefront's directory and run the following command to start the storefront:

Then:

  1. Go to http://localhost:8000 to open the storefront.
  2. Go to "Menu" → "Store" and click on a product.
  3. Select the product's options if any, then click on "Add to cart."
  4. Click on the cart icon at the top right to open the cart.
  5. Click on "Go to checkout."
  6. Enter the shipping information and click on "Continue to delivery." The tax amount will update in the right section.

Taxes calculated during checkout in the Next.js Starter Storefront

  1. In the Delivery step, select a shipping method. This will update the tax amount based on the shipping method's price.

Taxes updated based on the selected shipping method during checkout in the Next.js Starter Storefront

You can now complete the checkout with the taxes calculated by Avalara.


Step 4: Create Transactions in Avalara on Order Placement#

Avalara allows you to create transactions when an order is placed. This helps you keep track of sales and tax liabilities in Avalara.

In this step, you'll implement the logic to create an Avalara transaction when an order is placed in Medusa. You will:

  1. Add a method in the Avalara Tax Module Provider's service to uncommit a transaction. This is useful for rolling back the transaction if an error occurs or the order is canceled.
  2. Create a workflow that creates a transaction in Avalara for an order.
  3. Create a subscriber that listens to the order.placed event and triggers the workflow.

a. Add Uncommit Transaction Method#

First, you'll add a method in the Avalara Tax Module Provider's service to uncommit a transaction.

In src/modules/avalara/service.ts, add the following method to the AvalaraTaxModuleProvider class:

src/modules/avalara/service.ts
1class AvalaraTaxModuleProvider implements ITaxProvider {2  // ...3  async uncommitTransaction(transactionCode: string) {4    try {5      const response = await this.avatax.uncommitTransaction({6        companyCode: this.options.companyCode!,7        transactionCode: transactionCode,8      })9
10      return response11    }12    catch (error) {13      throw new MedusaError(14        MedusaError.Types.UNEXPECTED_STATE,15        `An error occurred while uncommitting transaction for Avalara: ${error}`16      )17    }18  }19}

This method receives the code of the transaction to uncommit. It calls the avatax.uncommitTransaction method to uncommit the transaction in Avalara.

b. Create Order Transaction Workflow#

Next, you'll create a workflow that creates an order transaction. A workflow is a series of 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 execution progress, define roll-back logic, and configure other advanced features.

Note: Learn more about workflows in the Workflows documentation.

The workflow to create an Avalara transaction has the following steps:

Workflow hook

Step conditioned by when

View step details

Medusa provides the first and last step out of the box. You only need to create the createTransactionStep.

createTransactionStep

The createTransactionStep creates a transaction in Avalara.

To create the step, create the file src/workflows/steps/create-transaction.ts with the following content:

src/workflows/steps/create-transaction.ts
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import AvalaraTaxModuleProvider from "../../modules/avalara/service"3import { DocumentType } from "avatax/lib/enums/DocumentType"4
5type StepInput = {6  lines: {7    number: string8    quantity: number9    amount: number10    taxCode: string11    itemCode?: string12  }[]13  date: Date14  customerCode: string15  addresses: {16    singleLocation: {17      line1: string18      line2: string19      city: string20      region: string21      postalCode: string22      country: string23    }24  }25  currencyCode: string26  type: DocumentType27}28
29export const createTransactionStep = createStep(30  "create-transaction",31  async (input: StepInput, { container }) => {32    const taxModuleService = container.resolve("tax")33    const avalaraProviderService = taxModuleService.getProvider(34      `tp_${AvalaraTaxModuleProvider.identifier}_avalara`35    ) as AvalaraTaxModuleProvider36
37    const response = await avalaraProviderService.createTransaction(input)38
39    return new StepResponse(response, response)40  },41  async (data, { container }) => {42    if (!data?.code) {43      return44    }45    const taxModuleService = container.resolve("tax")46    const avalaraProviderService = taxModuleService.getProvider(47      `tp_${AvalaraTaxModuleProvider.identifier}_avalara`48    ) as AvalaraTaxModuleProvider49
50    await avalaraProviderService.uncommitTransaction(data.code)51  }52)

You create a step with createStep from the Workflows SDK. It accepts three parameters:

  1. The step's unique name, which is create-transaction.
  2. An async function that receives two parameters:
    • The step's input, which is an object containing the transaction's details.
    • An object that has properties including the Medusa container, which is a registry of Framework and commerce tools that you can access in the step.
  3. An async compensation function that runs if an error occurs during the workflow's execution. It rolls back changes made in the step.

In the step function, you retrieve the Avalara Tax Module Provider's service from the Tax Module. Then, you call its createTransaction method to create a transaction in Avalara.

A step function must return a StepResponse instance. The StepResponse constructor accepts two parameters:

  1. The step's output, which is the created transaction.
  2. Data to pass to the step's compensation function.

In the compensation function, you uncommit the created transaction if an error occurs during the workflow's execution.

Create Transaction Workflow

You'll now create the workflow. Create the file src/workflows/create-order-transaction.ts with the following content:

src/workflows/create-order-transaction.ts
1import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"2import { updateOrderWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows"3import { createTransactionStep } from "./steps/create-transaction"4import AvalaraTaxModuleProvider from "../modules/avalara/service"5import { DocumentType } from "avatax/lib/enums/DocumentType"6
7type WorkflowInput = {8  order_id: string9}10
11export const createOrderTransactionWorkflow = createWorkflow(12  "create-order-transaction-workflow",13  (input: WorkflowInput) => {14    const { data: orders } = useQueryGraphStep({15      entity: "order",16      fields: [17        "id",18        "currency_code",19        "items.quantity",20        "items.id",21        "items.unit_price",22        "items.product_id",23        "items.tax_lines.id",24        "items.tax_lines.description",25        "items.tax_lines.code",26        "items.tax_lines.rate",27        "items.tax_lines.provider_id",28        "items.variant.sku",29        "shipping_methods.id",30        "shipping_methods.amount",31        "shipping_methods.tax_lines.id",32        "shipping_methods.tax_lines.description",33        "shipping_methods.tax_lines.code",34        "shipping_methods.tax_lines.rate",35        "shipping_methods.tax_lines.provider_id",36        "shipping_methods.shipping_option_id",37        "customer.id",38        "customer.email",39        "customer.metadata",40        "customer.groups.id",41        "shipping_address.id",42        "shipping_address.address_1",43        "shipping_address.address_2",44        "shipping_address.city",45        "shipping_address.postal_code",46        "shipping_address.country_code",47        "shipping_address.region_code",48        "shipping_address.province",49        "shipping_address.metadata",50      ],51      filters: {52        id: input.order_id,53      },54    })55
56    // TODO create transaction57  }58)

You create a workflow using createWorkflow from the Workflows SDK. It accepts the workflow's unique name as a first parameter.

As a second parameter, it accepts a constructor function, which is the workflow's implementation. The function can accept input, which in this case is the order ID.

So far, you retrieve the order's details using the useQueryGraphStep. It uses Query under the hood to retrieve data across modules.

Next, you'll prepare the transaction input, create the transaction, and update the order with the transaction code. Replace the // TODO create transaction comment with the following:

src/workflows/create-order-transaction.ts
1const transactionInput = transform({ orders }, ({ orders }) => {2  const providerId = `tp_${AvalaraTaxModuleProvider.identifier}_avalara`3  return {4    lines: [5      ...(orders[0]?.items?.map((item) => {6        return {7          number: item?.id ?? "",8          quantity: item?.quantity ?? 0,9          amount: item?.unit_price ?? 0,10          taxCode: item?.tax_lines?.find(11            (taxLine) => taxLine?.provider_id === providerId12          )?.code ?? "",13          itemCode: item?.product_id ?? "",14        }15      }) ?? []),16      ...(orders[0]?.shipping_methods?.map((shippingMethod) => {17        return {18          number: shippingMethod?.id ?? "",19          quantity: 1,20          amount: shippingMethod?.amount ?? 0,21          taxCode: shippingMethod?.tax_lines?.find(22            (taxLine) => taxLine?.provider_id === providerId23          )?.code ?? "",24        }25      }) ?? []),26    ],27    date: new Date(),28    customerCode: orders[0]?.customer?.id ?? "",29    addresses: {30      singleLocation: {31        line1: orders[0]?.shipping_address?.address_1 ?? "",32        line2: orders[0]?.shipping_address?.address_2 ?? "",33        city: orders[0]?.shipping_address?.city ?? "",34        region: orders[0]?.shipping_address?.province ?? "",35        postalCode: orders[0]?.shipping_address?.postal_code ?? "",36        country: orders[0]?.shipping_address?.country_code?.toUpperCase() ?? "",37      },38    },39    currencyCode: orders[0]?.currency_code.toUpperCase() ?? "",40    type: DocumentType.SalesInvoice,41  }42})43
44const response = createTransactionStep(transactionInput)45
46const order = updateOrderWorkflow.runAsStep({47  input: {48    id: input.order_id,49    user_id: "",50    metadata: {51      avalara_transaction_code: response.code,52    },53  },54})55
56return new WorkflowResponse(order)

You prepare the transaction input using the transform function. You include in the input the line items and shipping methods from the order, along with the customer and address details.

Notice that you set the type to DocumentType.SalesInvoice to save the transaction in Avalara.

Note: Refer to Avalara's documentation for details on other accepted parameters when creating a transaction.

Then, you call the createTransactionStep to create the transaction in Avalara.

Finally, you use the updateOrderWorkflow to save the created transaction's code in the order's metadata.

A workflow must return an instance of WorkflowResponse. The WorkflowResponse constructor accepts the workflow's output as a parameter, which is the updated order in this case.

Tip: transform allows you to access the values of data during execution. Learn more in the Data Manipulation documentation.

c. Create Order Placed Subscriber#

Next, you'll create a subscriber that listens to the order.placed event and executes the createOrderTransactionWorkflow when the event is emitted.

A subscriber is an asynchronous function that is executed when its associated event is emitted.

To create the subscriber, create the file src/subscribers/order-placed.ts with the following content:

src/subscribers/order-placed.ts
1import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"2import { createOrderTransactionWorkflow } from "../workflows/create-order-transaction"3
4export default async function orderPlacedHandler({5  event: { data },6  container,7}: SubscriberArgs<{ id: string }>) {8  await createOrderTransactionWorkflow(container).run({9    input: {10      order_id: data.id,11    },12  })13}14
15export const config: SubscriberConfig = {16  event: `order.placed`,17}

A subscriber file must export:

  • An asynchronous function that is executed when its associated event is emitted.
  • An object that indicates the event that the subscriber is listening to.

The subscriber receives among its parameters the data payload of the emitted event, which includes the order ID.

In the subscriber, you call the createOrderTransactionWorkflow with the order ID to create the transaction in Avalara.

Test Order Placement with Avalara Transaction#

To test the order placement with Avalara transaction creation, make sure both the Medusa server and the Next.js Starter Storefront are running.

Then, go to the storefront at http://localhost:8000 and complete the checkout process you started in the previous step.

You can verify that the transaction was created in Avalara by going to your Avalara dashboard:

  1. From the sidebar, click on "Transactions" → "Transactions."
  2. In the filter at the top, select "This month to date."
  3. In the list, the first transaction should correspond to the order you just placed. Click on it to view its details.

You can view the tax details calculated by Avalara for the order, with the line items and shipping method included in the transaction.

The transaction's details in the Avalara dashboard showing the calculated taxes for the order


Step 5: Sync Products with Avalara#

In Avalara, you can manage the items you sell to set classifications, tax codes, exemptions, and other tax-related settings.

In this step, you'll sync Medusa's products with Avalara items. This way, you can manage tax codes and other settings for your products directly from Avalara.

To do this, you will:

  1. Add methods to the Avalara Tax Module Provider's service to manage Avalara items.
  2. Build workflows to create, update, and delete Avalara items.
  3. Create subscribers that listen to product events and trigger the workflows.

a. Add Methods to Avalara Tax Module Provider#

To manage Avalara items, you'll add methods to the Avalara Tax Module Provider's service that uses the AvaTax API to create, update, and delete items.

In src/modules/avalara/service.ts, add the following methods to the AvalaraTaxModuleProvider class:

src/modules/avalara/service.ts
1class AvalaraTaxModuleProvider implements ITaxProvider {2  // ...3  async createItems(items: {4    medusaId: string5    itemCode: string6    description: string7    [key: string]: unknown8  }[]) {9    try {10      const response = await this.avatax.createItems({11        companyId: this.options.companyId!,12        model: await Promise.all(13          items.map(async (item) => {14            return {15              ...item,16              id: 0, // Avalara will generate an ID for the item17              itemCode: item.itemCode,18              description: item.description,19              source: "medusa",20              sourceEntityId: item.medusaId,21            }22          })23        ),24      })25
26      return response27    } catch (error) {28      throw new MedusaError(29        MedusaError.Types.UNEXPECTED_STATE,30        `An error occurred while creating item classifications for Avalara: ${error}`31      )32    }33  }34
35  async getItem(id: number) {36    try {37      const response = await this.avatax.getItem({38        companyId: this.options.companyId!,39        id,40      })41
42      return response43    } catch (error) {44      throw new MedusaError(45        MedusaError.Types.UNEXPECTED_STATE,46        `An error occurred while retrieving item classification from Avalara: ${error}`47      )48    }49  }50
51  async updateItem(item: {52    id: number53    itemCode: string54    description: string55    [key: string]: unknown56  }) {57    try {58      const response = await this.avatax.updateItem({59        companyId: this.options.companyId!,60        id: item.id,61        model: {62          ...item,63          id: item.id,64          itemCode: item.itemCode,65          description: item.description,66          source: "medusa",67        },68      })69
70      return response71    } catch (error) {72      throw new MedusaError(73        MedusaError.Types.UNEXPECTED_STATE,74        `An error occurred while updating item classifications for Avalara: ${error}`75      )76    }77  }78
79  async deleteItem(id: number) {80    try {81      const response = await this.avatax.deleteItem({82        companyId: this.options.companyId!,83        id,84      })85
86      return response87    } catch (error) {88      throw new MedusaError(89        MedusaError.Types.UNEXPECTED_STATE,90        `An error occurred while deleting item classifications for Avalara: ${error}`91      )92    }93  }94}

You add the following methods:

  1. createItems: Creates multiple items in Avalara using their Create Items API.
  2. getItem: Retrieves an item from Avalara using their Get Item API.
  3. updateItem: Updates an item in Avalara using their Update Item API.
  4. deleteItem: Deletes an item in Avalara using their Delete Item API.

You'll use these methods in the next sections to build workflows that sync products with Avalara items.

b. Create Avalara Item Workflow#

The first workflow you'll build creates an item for a product in Avalara. It has the following steps:

Workflow hook

Step conditioned by when

View step details

Medusa provides the first and last step out of the box. You only need to create the createItemStep.

createItemStep

The createItemStep creates an item in Avalara.

To create the step, create the file src/workflows/steps/create-item.ts with the following content:

src/workflows/steps/create-item.ts
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import AvalaraTaxModuleProvider from "../../modules/avalara/service"3
4type StepInput = {5  item: {6    medusaId: string7    itemCode: string8    description: string9    [key: string]: unknown10  }11}12
13export const createItemStep = createStep(14  "create-item",15  async ({ item }: StepInput, { container }) => {16    const taxModuleService = container.resolve("tax")17    const avalaraProviderService = taxModuleService.getProvider(18      `tp_${AvalaraTaxModuleProvider.identifier}_avalara`19    ) as AvalaraTaxModuleProvider20
21    const response = await avalaraProviderService.createItems(22      [item]23    )24
25    return new StepResponse(response[0], response[0].id)26  },27  async (data, { container }) => {28    if (!data) {29      return30    }31    const taxModuleService = container.resolve("tax")32    const avalaraProviderService = taxModuleService.getProvider(33      `tp_${AvalaraTaxModuleProvider.identifier}_avalara`34    ) as AvalaraTaxModuleProvider35
36    avalaraProviderService.deleteItem(data)37  }38)

The step receives the details of the item to create as input.

In the step, you retrieve the Avalara Tax Module Provider's service from the Tax Module. Then, you call its createItems method to create the item in Avalara.

You return the created item, and you pass its ID to the compensation function to delete the item if an error occurs during the workflow's execution.

Note: Refer to Avalara's documentation for details on other accepted parameters when creating an item.

Create Product Item Workflow

You can now create the workflow. Create the file src/workflows/create-product-item.ts with the following content:

src/workflows/create-product-item.ts
1import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"2import { updateProductsWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows"3import { createItemStep } from "./steps/create-item"4
5type WorkflowInput = {6  product_id: string7}8
9export const createProductItemWorkflow = createWorkflow(10  "create-product-item",11  (input: WorkflowInput) => {12    const { data: products } = useQueryGraphStep({13      entity: "product",14      fields: [15        "id",16        "title",17      ],18      filters: {19        id: input.product_id,20      },21      options: {22        throwIfKeyNotFound: true,23      },24    })25
26    const response = createItemStep({27      item: {28        medusaId: products[0].id,29        itemCode: products[0].id,30        description: products[0].title,31      },32    })33
34    updateProductsWorkflow.runAsStep({35      input: {36        products: [37          {38            id: input.product_id,39            metadata: {40              avalara_item_id: response.id,41            },42          },43        ],44      },45    })46    47    return new WorkflowResponse(response)48  }49)

The workflow receives the product ID as input.

In the workflow, you:

  1. Retrieve the product's details using the useQueryGraphStep.
  2. Create the item in Avalara using the createItemStep.
  3. Save the created item's ID in the product's metadata using the updateProductsWorkflow. This ID is useful when you want to update or delete the item later.

You return the created item as the workflow's output.

c. Create Product Created Subscriber#

Next, you'll create a subscriber that listens to the product.created event and executes the createProductItemWorkflow.

Create the file src/subscribers/product-created.ts with the following content:

src/subscribers/product-created.ts
1import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"2import { createProductItemWorkflow } from "../workflows/create-product-item"3
4export default async function productCreatedHandler({5  event: { data },6  container,7}: SubscriberArgs<{ id: string }>) {8  await createProductItemWorkflow(container).run({9    input: {10      product_id: data.id,11    },12  })13}14
15export const config: SubscriberConfig = {16  event: `product.created`,17}

You create a subscriber similar to the one you created for the order.placed event. This time, it listens to the product.created event and triggers the createProductItemWorkflow with the product ID.

d. Test Product Creation with Avalara Item#

To test the product creation with Avalara item creation, make sure the Medusa server is running.

Then:

  1. Open the Medusa Admin dashboard at http://localhost:9000/admin.
  2. Go to "Products," and create a new product.
  3. After creating the product, go to your Avalara dashboard.
  4. From the sidebar, click on "Settings" → "What you sell and buy."

This will open the Items page, where you can see the product you created as items in Avalara. You can click on an item to view it, add a classification, and more.

The list of items in the Avalara dashboard showing the item created for the product

e. Update Avalara Item Workflow#

Next, you'll create a workflow that updates a product's item in Avalara. The workflow has the following steps:

Workflow hook

Step conditioned by when

View step details

Medusa provides the first step out of the box, and you have already created the createProductItemWorkflow. You only need to create the updateItemStep.

updateItemStep

The updateItemStep updates an item in Avalara.

To create the step, create the file src/workflows/steps/update-item.ts with the following content:

src/workflows/steps/update-item.ts
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import AvalaraTaxModuleProvider from "../../modules/avalara/service"3
4type StepInput = {5  item: {6    id: number7    medusaId: string8    itemCode: string9    description: string10    [key: string]: unknown11  }12}13
14export const updateItemStep = createStep(15  "update-item",16  async ({ item }: StepInput, { container }) => {17    const taxModuleService = container.resolve("tax")18    const avalaraProviderService = taxModuleService.getProvider(19      `tp_${AvalaraTaxModuleProvider.identifier}_avalara`20    ) as AvalaraTaxModuleProvider21
22    // Retrieve original item before updating23    const originalItem = await avalaraProviderService.getItem(item.id)24
25    // Update the item26    const response = await avalaraProviderService.updateItem(item)27
28    return new StepResponse(response, {29      originalItem,30    })31  },32  async (data, { container }) => {33    if (!data) {34      return35    }36
37    const taxModuleService = container.resolve("tax")38    const avalaraProviderService = taxModuleService.getProvider(39      `tp_${AvalaraTaxModuleProvider.identifier}_avalara`40    ) as AvalaraTaxModuleProvider41
42    // Revert the updates by restoring original values43    await avalaraProviderService.updateItem({44      id: data.originalItem.id,45      itemCode: data.originalItem.itemCode,46      description: data.originalItem.description,47    })48  }49)

The step receives the details of the item to update as input.

In the step, you:

  1. Retrieve the Avalara Tax Module Provider's service from the Tax Module.
  2. Retrieve the original item from Avalara before updating it.
  3. Call the updateItem method to update the item in Avalara.
  4. Return the updated item and pass the original item to the compensation function.

In the compensation function, you revert the updates by restoring the original values if an error occurs during the workflow's execution.

Update Product Item Workflow

You can now create the workflow. Create the file src/workflows/update-product-item.ts with the following content:

src/workflows/update-variant-item.ts
1import { createWorkflow, WorkflowResponse, transform, when } from "@medusajs/framework/workflows-sdk"2import { useQueryGraphStep } from "@medusajs/medusa/core-flows"3import { updateItemStep } from "./steps/update-item"4import { createProductItemWorkflow } from "./create-product-item"5
6type WorkflowInput = {7  product_id: string8}9
10export const updateProductItemWorkflow = createWorkflow(11  "update-product-item",12  (input: WorkflowInput) => {13    const { data: products } = useQueryGraphStep({14      entity: "product",15      fields: [16        "id",17        "title",18        "metadata",19      ],20      filters: {21        id: input.product_id,22      },23      options: {24        throwIfKeyNotFound: true,25      },26    })27
28    const createResponse = when({ products }, ({ products }) => 29      products.length > 0 && !products[0].metadata?.avalara_item_id30    )31      .then(() => {32        return createProductItemWorkflow.runAsStep({33          input: {34            product_id: input.product_id,35          },36        })37      })38
39    const updateResponse = when({ products }, ({ products }) => 40      products.length > 0 && !!products[0].metadata?.avalara_item_id41    )42      .then(() => {43        return updateItemStep({44          item: {45            id: products[0].metadata?.avalara_item_id as number,46            medusaId: products[0].id,47            itemCode: products[0].id,48            description: products[0].title,49          },50        })51      })52
53    const response = transform({54      createResponse,55      updateResponse,56    }, (data) => {57      return data.createResponse || data.updateResponse58    })59
60    return new WorkflowResponse(response)61  }62)

The workflow receives the product ID as input.

In the workflow, you:

  1. Retrieve the product's details using the useQueryGraphStep.
  2. Use a when condition to check whether the product has an Avalara item ID in its metadata.
    • If it doesn't, you call the createProductItemWorkflow to create the item in Avalara.
    • If it does, you prepare the input and call the updateItemStep to update the item in Avalara.
  3. Use transform to return either the created or updated item as the workflow's output.
Tip: when-then allows you to run steps based on conditions during execution. Learn more in the Conditions in Workflows documentation.

f. Create Product Updated Subscriber#

Next, you'll create a subscriber that listens to the product.updated event and executes the updateProductItemWorkflow.

Create the file src/subscribers/product-updated.ts with the following content:

src/subscribers/product-updated.ts
1import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"2import { updateProductItemWorkflow } from "../workflows/update-product-item"3
4export default async function productUpdatedHandler({5  event: { data },6  container,7}: SubscriberArgs<{ id: string }>) {8  await updateProductItemWorkflow(container).run({9    input: {10      product_id: data.id,11    },12  })13}14
15export const config: SubscriberConfig = {16  event: `product.updated`,17}

You create a subscriber similar to the one you created for the product.created event. This time, it listens to the product.updated event and triggers the updateProductItemWorkflow with the product ID.

g. Test Product Update with Avalara Item#

To test the product update with Avalara item update, make sure the Medusa server is running.

Then:

  1. Open the Medusa Admin dashboard at http://localhost:9000/admin.
  2. Go to "Products," and edit an existing product. For example, you can edit its title.
  3. After updating the product, go to your Avalara dashboard.
  4. From the sidebar, click on "Settings" → "What you sell and buy."
    • If the product already had an item in Avalara, click on it to view its details and confirm that the changes were applied.
    • If the product didn't have an item in Avalara, you should see a new item created for it.

h. Delete Avalara Item Workflow#

The last workflow you'll build deletes a product's item from Avalara. The workflow has the following steps:

Workflow hook

Step conditioned by when

View step details

Medusa provides the first step out of the box. You only need to create the deleteItemStep.

deleteItemStep

The deleteItemStep deletes an item from Avalara.

To create the step, create the file src/workflows/steps/delete-item.ts with the following content:

src/workflows/steps/delete-item.ts
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import AvalaraTaxModuleProvider from "../../modules/avalara/service"3
4type StepInput = {5  item_id: number6}7
8export const deleteItemStep = createStep(9  "delete-item",10  async ({ item_id }: StepInput, { container }) => {11    const taxModuleService = container.resolve("tax")12    const avalaraProviderService = taxModuleService.getProvider(13      `tp_${AvalaraTaxModuleProvider.identifier}_avalara`14    ) as AvalaraTaxModuleProvider15
16    try {17      // Retrieve original item before deleting18      const original = await avalaraProviderService.getItem(item_id)19      // Delete the item20      const response = await avalaraProviderService.deleteItem(original.id)21
22      return new StepResponse(response, {23        originalItem: original,24      })25    } catch (error) {26      console.error(error)27      // Item does not exist in Avalara, so we can skip deletion28      return new StepResponse(void 0)29    }30  },31  async (data, { container }) => {32    if (!data) {33      return34    }35
36    const taxModuleService = container.resolve("tax")37    const avalaraProviderService = taxModuleService.getProvider(38      `tp_${AvalaraTaxModuleProvider.identifier}_avalara`39    ) as AvalaraTaxModuleProvider40
41    await avalaraProviderService.createItems(42      [{43        medusaId: data.originalItem.sourceEntityId ?? "",44        description: data.originalItem.description,45        itemCode: data.originalItem.itemCode,46      }]47    )48  }49)

The step receives the ID of the item to delete as input.

In the step, you:

  1. Retrieve the Avalara Tax Module Provider's service from the Tax Module.
  2. Retrieve the original item before deleting it.
  3. Call the deleteItem method to delete the item in Avalara.
  4. Return the deletion response and pass the original item to the compensation function.

In the compensation function, you recreate the item using the original values if an error occurs during the workflow's execution.

Delete Product Item Workflow

You can now create the workflow. Create the file src/workflows/delete-product-item.ts with the following content:

src/workflows/delete-product-item.ts
1import { createWorkflow, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk"2import { deleteItemStep } from "./steps/delete-item"3import { useQueryGraphStep } from "@medusajs/medusa/core-flows"4
5type WorkflowInput = {6  product_id: string7}8
9export const deleteProductItemWorkflow = createWorkflow(10  "delete-product-item",11  (input: WorkflowInput) => {12    const { data: products } = useQueryGraphStep({13      entity: "product",14      fields: [15        "id",16        "metadata",17      ],18      filters: {19        id: input.product_id,20      },21      withDeleted: true,22      options: {23        throwIfKeyNotFound: true,24      },25    })26
27    when({ products }, ({ products }) =>28      products.length > 0 && !!products[0].metadata?.avalara_item_id29    )30    .then(() => {31      deleteItemStep({ item_id: products[0].metadata!.avalara_item_id as number })32    })33
34    return new WorkflowResponse(void 0)35  }36)

The workflow receives the product ID as input.

In the workflow, you:

  1. Retrieve the product's details using the useQueryGraphStep, including deleted products.
  2. Use a when condition to check whether the product has an Avalara item ID in its metadata.
    • If it does, you call the deleteItemStep to delete the item in Avalara.

i. Create Product Deleted Subscriber#

Finally, you'll create a subscriber to delete the Avalara item when a product is deleted.

Create the file src/subscribers/product-deleted.ts with the following content:

src/subscribers/product-deleted.ts
1import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"2import { deleteProductItemWorkflow } from "../workflows/delete-product-item"3
4export default async function productDeletedHandler({5  event: { data },6  container,7}: SubscriberArgs<{ id: string }>) {8  await deleteProductItemWorkflow(container).run({9    input: {10      product_id: data.id,11    },12    throwOnError: false,13  })14}15
16export const config: SubscriberConfig = {17  event: `product.deleted`,18}

You create a subscriber similar to the previous ones. This time, the subscriber listens to the product.deleted event and triggers the deleteProductItemWorkflow with the product ID.

j. Test Product Deletion with Avalara Item#

To test the product deletion with Avalara item deletion, make sure the Medusa server is running.

Then:

  1. Open the Medusa Admin dashboard at http://localhost:9000/admin.
  2. Go to "Products," and delete a product.
  3. After deleting the product, go to your Avalara dashboard.
  4. From the sidebar, click on "Settings" → "What you sell and buy." The product's item should no longer be listed.

Next Steps#

You've successfully integrated Avalara with Medusa to handle tax calculations during checkout, create transactions when orders are placed, and sync products with Avalara items.

You can expand on this integration by managing tax features in Avalara, such as exemptions. You can also handle order events like order.canceled to void transactions in Avalara when orders are canceled.

Learn More About Medusa#

If you're new to Medusa, check out the main documentation for a more in-depth understanding of the concepts used in this guide.

To learn more about the commerce features that Medusa provides, check out Medusa's Commerce Modules.

Troubleshooting#

If you encounter issues during your development, check out the troubleshooting guides.

Getting Help#

If you encounter issues not covered in the troubleshooting guides:

  1. Visit the Medusa GitHub repository to report issues or ask questions.
  2. Join the Medusa Discord community for real-time support from community members.
Was this page helpful?
Ask Anything
FAQ
What is Medusa?
How can I create a module?
How can I create a data model?
How do I create a workflow?
How can I extend a data model in the Product Module?
Recipes
How do I build a marketplace with Medusa?
How do I build digital products with Medusa?
How do I build subscription-based purchases with Medusa?
What other recipes are available in the Medusa documentation?
Chat is cleared on refresh
Line break