Medusa is an open source headless ecommerce platform that we have used in the past on client projects as an alternative to Shopify for creating an e-commerce backend. Taxes and tax reporting plays an important role in the backend of any e-commerce store since it is crucial for stores to collect the correct amount of taxes on each sale, and track all of that tax data for when it is time to file tax reports.
The Problem
Since Medusa was primarily designed for countries that use a VAT model, the built in tax calculation will not work for users that sell to the United States or other countries that have varying tax rates within the same region. It would not be feasilbe to maintain all of the state and local tax rates within Medusa, since that could result in having thousands of regions and requiring the user to maintain the accuracy of those tax rates and it could make for a very strange and unrelaible user experience.
The solution is to use a third-party tax provider that can caluclate a tax rate based on the address of the store selling and item, and the address of the customer that purchased the item. In this post, I will be using Taxjar, but similar logic that is applied here could also be applied using other tax services.
It is also a good idea to discuss any tax questions or concerns with a professional tax accountant before implementing this or any tax solution in your application.
Prerequisites
Before you start, it is assumed that the following is completed already:
-
Setup a running instance of Medusa
-
Seed your Database with the following test data:
-
a region
-
a user
-
A product with a variant that has a price
-
a shipping option
-
Created a Taxjar account (the free trial is fine for this)
-
Familiar with how services and subscribers work in Medusa
-
Optional but highly recommended: Have a running version of the Medusa Admin
Create the Taxjar Service
Create the directory (if it doesn’t already exist) src/types
and create a file named index.ts
. These types do not have to placed in this file, they could be placed in another types file, but for this implementation we will put the types there. In that file, add the following contents:
export type TaxjarToFromData = { from_country: string,
from_zip: string,
from_state: string,
from_city: string,
from_street: string,
to_country: string;
to_zip: string;
to_state: string;
to_city: string;
to_street: string;
}
export type TaxjarLineItem = {
id: string,
quantity: number,
product_tax_code?: string,
unit_price: number,
discount?: number
}
export interface TaxjarCreateOrderData extends TaxjarToFromData {
transaction_id: string,
transaction_date: string,
amount: number,
shipping: number,
sales_tax: number,
line_items: TaxjarLineItem[]
}
Next, in /src/services
, create a new file called taxjar.ts
. To start, this file should contain the following:
import { AbstractTaxService,
Address,
ItemTaxCalculationLine,
Order,
ShippingTaxCalculationLine
} from "@medusajs/medusa";
import { ProviderTaxLine } from "@medusajs/medusa/dist/types/tax-service";
import Taxjar from "taxjar";
import { TaxForOrderRes, CreateOrderRes } from "taxjar/dist/types/returnTypes";
import {
TaxCalculationContext,
TaxjarCreateOrderData,
TaxjarLineItem,
TaxjarToFromData
} from "../types";
import { EntityManager } from "typeorm";
import { MedusaError } from 'medusa-core-utils';
type ConstructorParams = {
manager: EntityManager;
}
// This is an example source address, replace these values with your own store.
const STORE_ADDRESS = {
from_country: 'US',
from_zip: '85007',
from_state: 'AZ',
from_city: 'Phoenix',
from_street: '1700 W Washington St.',
}
export default class TaxjarService extends AbstractTaxService {
protected static identifier: string = 'taxjar';
readonly manager: EntityManager;
private readonly client: Taxjar;
constructor (container: ConstructorParams) {
super();
this.manager_ = container.manager;
this.client = new Taxjar({
apiKey: process.env.TAXJAR_API_KEY,
apiUrl: process.env.TAXJAR_URL
});
}
}
Notice the identifier
here is set to taxjar
, this tells Medusa the name of our new tax service and this is the value that the region’s tax_provider_id
will be set to once we set a region to use our new tax provider.
In the constructor, first initialize a new Taxjar object with the necessary environment variables. Your .env
file should have the following variables set:
-
TAXJAR_API_KEY
: The API key associated to your Taxjar account (IMPORTANT: I recommend starting with the sandbox key while testing.)
-
TAXJAR_URL
: This is the base URL for Taxjar. The taxjar sandbox will be https://api.sandbox.taxjar.com
and the live API is https://api.sandbox.taxjar.com
. Make sure you have paired the right API key with the right URL (sandbox API key can only be used with the sandbox URL and vice-versa.)
Now add the following methods:
async getTaxLines( itemLines: ItemTaxCalculationLine[],
shippingLines: ShippingTaxCalculationLine[],
calculationContext: TaxCalculationContext,
): Promise<ProviderTaxLine[]> {
let taxRate = 0;
if (calculationContext?.shipping_address?.postal_code && itemLines.length > 0 ) {
const address = this.buildTaxjarData(calculationContext.shipping_address);
taxRate = await this.fetchTaxRate(
itemLines,
shippingLines,
address
);
}
return this.buildTaxLines(itemLines, shippingLines, taxRate)
}
private buildTaxLines(
itemLines: ItemTaxCalculationLine[],
shippingLines: ShippingTaxCalculationLine[],
itemTaxRate?: number) {
return [
...itemLines.map(line => ({
rate: itemTaxRate,
name: "Sales tax",
code: "",
item_id: line.item.id
})),
// Shipping taxes are combined in taxjar since they are combined, always set to
// 0% here.
...shippingLines.map(line => ({
rate: 0,
name: "default",
code: "default",
shipping_method_id: line.shipping_method.id
}))
];
}
private buildTaxjarData(address: Address): TaxjarToFromData {
return {
...STORE_ADDRESS,
to_country: address.country_code,
to_zip: address.postal_code,
to_state: address.province,
to_city: address.city,
to_street: address.address_1,
}
}
async fetchTaxRate(
itemLines: ItemTaxCalculationLine[],
shippingLines: ShippingTaxCalculationLine[],
addrInfo
): Promise<number> {
const taxjarLineItems: TaxjarLineItem[] = itemLines.map((line) => ({
id: line.item.id,
quantity: line.item.quantity,
unit_price: line.item.unit_price,
}))
// combined cost of all shipping methods (typically will only have 1 here)
addrInfo.shipping = shippingLines.reduce((acc, line) => {
return acc += line.shipping_method.price;
}, 0)
addrInfo.line_items = taxjarLineItems;
return await this.client.taxForOrder(addrInfo)
.then((res) => {
return res.tax.rate * 100
})
.catch((err) => {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Taxjar error: ${err}`
)
});
}
The only required method that needs to be on any class that extends AbstractTaxService
is getTaxLines
. We want to only calucate if the calculationContext
contains a shipping address, otherwise return the ProviderTaxLine
array with the tax rate as 0. This will prevent errors in Taxjar from requests being sent without a zip code.
The buildTaxLines
method is constructing the ProviderTaxLine
data for each item. This is the data that is returned to the core and added to the item
array in the cart or the order.
The buildTaxjarData
method is going to take the address data for the customer and create an object that fulfills the required keys for Taxjar. Taxes are calculated based on the ship to address, which is why it is important that it is is the customer’s shipping address.
fetchTaxRate
is where the tax rate for an item is calcuated via an API call to taxjar, using the this.client
. The taxjarLineItems
is an array of lineItems from the cart or order that contain the line item id, quantity, and the price of a single item. The total cost of shipping for the cart or order is then calculated and added to the addr_info
object. This is done here because Taxjar is able to calculate shipping tax with item tax together and give the total amount of taxes to collect in a single call, this makes it much easier to calculate these together as opposed to separately, like Medusa does by default.
At the end of the getTaxLines
method, an array of objects is returned that contains the tax rate data from Taxjar for every item and shipping method for that cart or order. An important piece to notice here is that the shipping rate is hard coded to always be 0. This is because taxjar calculates the shipping tax with the item and does not return a breakdown of a shipping tax rate. In some cases, the shipping is actually not part of the taxable amount, which is why it is best to have these calculated together instead of separately since Taxjar will know the correct way to calculate it.
Medusa will save this as a line_item_tax_line
record in the database.
Next, run medusa develop
to start the backend server and if using the admin UI, run npm run start
in the root directory of the admin to start the server. Login and go to settings -> tax settings and select any region. The tax calculation settings should now have an option that says “Taxjar”. Set a region to “Taxjar” and also uncheck the “Calculate taxes automatically” box.
NOTE: Disabling calculating taxes automatically is recommended to limit the amount of requests sent to the Taxjar API since Taxjar limits the requests sent per month.
Verify Tax Calculation
To test this, we will need a cart to be created and the cart should contain a variant. There also should be a customer associated to the cart and an address associated to that customer. For help on how to structure these requests and for how this can be done, visit the Medusa API docs for store and admin.
Tip: It’s recommended you use a shipping address that is in the same state as the store. This will ensure that Sales taxes are calculated since each state has slightly different laws around how sales taxes online are calculated.
To manually calculate taxes, send a POST
request to /store/carts/:cart_id/taxes
. In the items
array, notice there is an array named tax_lines
and this will contain an object resembling something like this:
"tax_lines": [ {
"id": "litl_01GK00HASPHYX4CRTFD7DWW4YD",
"created_at": "2022-11-28T20:58:23.668Z",
"updated_at": "2022-11-28T20:58:23.668Z",
"rate": 8.7,
"name": "Sales tax",
"code": "",
"metadata": null,
"item_id": "item_01GK0042CQT7J4WGXKXF0MW89H"
}
],
A result like this will indicate that Taxjar calculation has worked.
Tracking Orders with Taxjar
Now that taxes are being calculated for a cart, we want to be able to capture the amount paid in taxes (with other applicable data) in Taxjar so that the store owner can use that for reporting purposes. To do this, we will be creating an event subscriber that listens for order.placed
and creates an order in Taxjar with the data from Medusa.
First, we will need to add a method to create the orders in Taxjar to the TaxjarService.
In src/services/taxjar-service.ts
, add the createTaxOrder
method. This method will organize the line items into an array of items for Taxjar, then append those line items and other applicable order data to the taxjarData
object. Notice that here we are setting the transaction_id
in Taxjar to match the Order ID from Medusa.This will make identifying tax records for orders much easier for the store owner when they go to cross-compare the information from Medusa with Taxjar.
async createTaxOrder(order: Order): Promise<CreateOrderRes | void> { const shippingAddress = this.buildTaxjarData(order.shipping_address);
const taxjarLineItems: TaxjarLineItem[] = order.items.map((line) => ({
id: line.id,
quantity: line.quantity,
unit_price: line.unit_price,
product_identifier: line.variant.id
}));
const taxjarData: TaxjarCreateOrderData = {
...shippingAddress,
line_items: taxjarLineItems,
transaction_id: order.id,
transaction_date: order.created_at.toString(),
amount: order.subtotal + order.shipping_total,
shipping: order.shipping_total,
sales_tax: order.tax_total
};
const res = await this.client.createOrder(taxjarData)
.catch((err) => {
return
});
return res;
}
Before creating the subscriber, we need to appease Typescript by overriding the ITaxService
method since we will need to call our new createTaxOrder
method from outside of the TaxjarService
class.
Create a directory called interfaces
inside of the src
directory. Then add a file named index.ts
with the content below.
// src/interfaces/index.ts import {
EventBusService,
NoteService,
OrderService,
TaxProviderService
} from "@medusajs/medusa";
import { EntityManager } from "typeorm";
import { ITaxService } from "../interfaces";
type InjectedDependencies = {
eventBusService: EventBusService;
orderService: OrderService;
manager: EntityManager;
taxProviderService: TaxProviderService;
noteService: NoteService;
}
class TaxProviderSubscriber {
private readonly manager: EntityManager
private readonly eventBusService: EventBusService;
private readonly orderService: OrderService;
private readonly taxProviderService: TaxProviderService;
private readonly noteService: NoteService;
constructor({
eventBusService,
manager,
orderService,
taxProviderService,
noteService,
}: InjectedDependencies) {
this.eventBusService = eventBusService;
this.orderService = orderService;
this.manager = manager;
this.taxProviderService = taxProviderService;
this.noteService = noteService;
eventBusService.subscribe(
OrderService.Events.PLACED, async (order: { id: string}) => {
await this.createProviderOrder(order)
}
);
}
async createProviderOrder({ id }: { id: string }): Promise<void> {
// get the order record
const order = await this.orderService.retrieve(id, {
select: ['id', 'subtotal', 'shipping_total', 'tax_total', 'created_at'],
relations: ['region', 'items', 'shipping_address']
})
// return if not using custom tax provider
if (!order.region.tax_provider_id) {
return
}
const taxProvider = this.taxProviderService.retrieveProvider(
order.region
) as ITaxService;
const taxData = await taxProvider.createTaxOrder(order);
// this is to show the response in the log, remove in production
console.log(taxData)
// if there was a problem writing the order to taxjar, create a note for the admin.
if (!taxData) {
await this.noteService.create({
resource_id: order.id,
resource_type: 'order',
value: "There was an issue creating the taxjar order to track sales tax, create in taxjar."
})
}
}
}
export default TaxProviderSubscriber;
In the constructor
method above, we have set a listener to listen for when the order.placed
occurs. This will pass the necessary params that are part of the Redis event to createProviderOrder
.
The createProviderOrder
method will get the new order from the database, and determine if the region has a tax provider set (null
indicates that it is using the system tax provider.). If it does, it will call the createTaxOrder
method from the TaxjarService
and that will create the order in Taxjar. Notice that I have placed a console.log(taxData)
here so it will log out the request result. This is so we can see the Taxjar response in the terminal to verify the order was created since there is no way to verify this in the Taxjar web UI when using the sandbox. This can be removed after testing.
Lastly, if for some reason creating the order in Taxjar fails, a note will be added for that order indicating that there was an issue creating the Taxjar order. This will appear in the Admin UI so the admin knows to go create an order in Taxjar manually so their tax tracking in Taxjar can stay accurate.
Test it out
First, build your project and start the Medusa server with these 2 commands:
yarn build medusa develop
Testing this will require a little bit of setup work. Below I have included a bash script that will streamline the cart setup process. This will send a series of curl requests to the Medusa backend to simulate the process a customer would normally go through when creating a cart and going through the checkout process.
To use the script below create a cart_setup.sh
file and paste this content in:
# cart_setup.sh region=$1
variant=$2
shipping_option=$3
cart_id=$(curl --location --request POST 'http://localhost:9000/store/carts' \
--header 'Content-Type: application/json' \
--data-raw "{
\"region_id\": \"$region\"
}" | jq -r ".cart.id")
# add variant to cart
curl -s --location -g --request POST "http://localhost:9000/store/carts/$cart_id/line-items" \
--header 'Content-Type: application/json' \
--data "{
\"variant_id\": \"$variant\",
\"quantity\": 1
}"
# add a shipping address
curl -s --location --request POST "http://localhost:9000/store/carts/$cart_id" \
--header 'Content-Type: application/json' \
--data-raw "{
\"shipping_address\": {
\"address_1\": \"123 Main St\",
\"city\": \"Phoenix\",
\"province\": \"AZ\",
\"country_code\": \"us\",
\"postal_code\": \"85007\"
}
}"
# initialize the payment sessions
curl --location -g --request POST "http://localhost:9000/store/carts/$cart_id/payment-sessions" \
# add a shipping option (use the one you created in the pre-requisites section)
curl --location -g --request POST "http://localhost:9000/store/carts/$cart_id/shipping-methods" \
--header 'Content-Type: application/json' \
--data-raw "{
\"option_id\": \"$shipping_option\"
}"
# select a payment provider
curl --location -g --request POST "http://localhost:9000/store/carts/$cart_id/payment-session" \
--header 'Content-Type: application/json' \
--data-raw '{
"provider_id": "manual"
}'
To use the script, run it with this command:
sh cart_setup.sh {region_id} {variant_id} {shipping_option_id}
region_id
is the ID of the region that is using taxjar for calculation.
variant_id
is any variant that you wish to add to the cart.
shipping_option_id
is the ID of the shipping option to use in that cart.
Now that we have setup the cart properly, we can calculate tax on the cart. Copy the cart ID found at the top of the ouput from the script above. Then run this request:
curl --location -g --request POST "http://localhost:9000/store/carts/$cart_id/taxes"
In the items
array inside of the returned cart object, there will be a tax_lines
key with an array as its value. This should now contain a tax rate for that item, looking something like this:
"tax_lines": [ {
"rate": 8.7,
"name": "Sales Tax",
"code": "",
"item_id": "item_id"
}
],
Next, we want to verify the TaxProviderSubscriber
is working and that it sends a request to Taxjar to create an order. To do this, “complete” the cart and generate an order by sending the following request:
curl --location -g --request POST "http://localhost:9000/store/carts/$cart_id/complete"
This will trigger the order.placed
event which we have created our subscriber to listen for. If you check your terminal there will be a log showing the order data from the created Taxjar order.
Conclusion
Taxjar now should be working and calculating taxes correctly for your store. As a testable example, I have created a basic store here on my Github. This will provide all of the necessary code in a single spot to help you get started. Also if you have any questions or notice any problems with what is written there when you try to use it, feel free to open an issue!