August 13, 2025·Product
Building Medusa: Calculating Cart totals with discounts, taxes, and multiple currencies
In commerce, cart totals are the domain where every discount, tax, rounding rule, and currency quirk collides. Learn about the hidden challenges of calculating accurate cart totals.

Five years ago, we had the insane idea to build a commerce platform. We had done too many hacky workarounds on other commerce platforms and wanted something more low-level, where we could build customizations natively instead of battling rigid workflows.
When we started working on this, totals calculations seemed like the least complex part of the system. Simple arithmetic, we thought. But as you will learn in this post, totals is one of those problems, like many others in software engineering, that is surprisingly complex once you dig in.
The cart total is where all commerce domains serve as input. And where the output has direct impact on accuracy of payments, accounting operations, and importantly the trust and satisfaction of the customer.
If you are considering building custom commerce functionality, curious about which country’s transaction values must be divisible by 0.05, or just enjoy arithmetic; this post is for you!
Totals start in the cart
Ecommerce transactions start in a shopping phase. Here, the customer adds products to their cart, applies discounts, and goes through checkout. After a checkout is complete, you enter an order processing phase, where fulfillments, and potential returns or exchanges are managed.
It’s beneficial to have separate data models (Carts and Orders in Medusa) for the two phases to decouple shopping and order processing behavior.
The totals calculations start in the Cart, where everything can be dynamically derived from the cart’s contents. Later, when an order is created from the cart, you store the totals in the database to ensure you keep track of the order’s history. In this post, we will focus mostly on the Cart’s totals calculation.
Let’s consider a minimal Cart data model. One that is not much different from the one we started with when building Medusa:
123456{items: [{ title: "T-Shirt", unit_price: 100, quantity: 1 },],... // other fields not related to totals left out}
In the model above, we keep track of the items a customer adds to the cart. Each item has a unit price and a quantity, which gives us what we need to compute a simple total. Namely:
1234const total = cart.items.reduce((acc, item) => acc + item.unit_price * item.quantity,0)
In an API response, we can imagine having calculated this already and sending back a response with the computed field:
1234567{items: [{ title: "T-Shirt", unit_price: 100, quantity: 1 },],total: 100...}
Nice and easy. But of course, it's not that simple.
Incentivizing customers with discounts
Offering discounts is used to acquire new customers, increase conversion rates, incentivize newsletter signups, tracking campaign attribution, and much more. This brings a layer of complexity to our totals calculations that we need to deal with.
Discounts come in many shapes. Percentage off order, percentage off item, amount off order, amount off item, buy x get y, spend x get y, are the most common ones. But it’s not unusual to see marketing teams come up with crazy ideas (I once met with a retailer selling eyeglasses - one of their best performing discounts was “50% off your left prescription lens”).
A starting point for adding discounts could be to simply hold the discount amount in a Copy to clipboarddiscounts
field on the cart model.
12345678910// 🛑 - not great{items: [{ title: "T-Shirt", unit_price: 100, quantity: 1 },],discounts: [{ code: "10OFF", type: "percentage_off_order", value: 10 }]...}
Calculating the discount would be simple enough: get the subtotal, calculate the discount amount, and subtract the discount total from the subtotal.
But this is not a good structure when you consider the many different discount types. To see this, imagine the switch case you’d need on the type field to compute the discount amounts correctly. Also, in the case of an item-level discount consider how you would show this to the customer. Not so easy.
To support many different discount types and simplify the totals computation the best approach is to add item level adjustments.
1234567891011121314151617// ✅ - great{items: [{title: "T-Shirt",unit_price: 100,quantity: 1adjustments: [{ code: "10OFF", amount: 10 }]},],discounts: [{ code: "10OFF", type: "percentage_off_order" }]...}
With this approach, you compute the amount to adjust each item ahead of time and store it at the item level. This gives a ton of flexibility in the types of discounts you can create, and enables straightforward tracing of a discount’s allocation on each item. Let’s see how the totals will be computed with this structure. And let’s also introduce item-level totals to simplify storefront implementations.
1234567891011121314151617181920// With computed totals.{items: [{title: "T-Shirt",unit_price: 100,quantity: 1adjustments: [{ code: "10OFF", amount: 10 }]subtotal: 100, // unit_price * quantitydiscount_total: 10, // sum(adjustments.amount)total: 90 // subtotal - discount_total},],discounts: [{ code: "10OFF", type: "percentage_off_order" }]subtotal: 100, // sum(items.subtotal)discount_total: 10, // sum(items.discount_total)
In Medusa, the built-in promotion module is responsible for calculating the adjustment amounts, which the Cart will store. This module comes with all the common promotion types. A benefit of the data model above, however, is that the logic to calculate adjustment amounts is completely decoupled from the cart implementation. You can therefore plug in your own function to generate adjustments, opening up for all the crazy promotion needs out there; even the left prescription lens example mentioned earlier.
Back to reality and taxes
Thus far we have operated in a utopian world without taxes. Already now there’s a fair amount of complexity, but let’s step into the real world and make it even more tricky.
When we started working on Medusa, we were building for companies in Denmark. The Danish VAT scheme is very simple. A flat 25% on everything. If all countries worked like that, we could simply add a cart-level Copy to clipboardtax_rate
field and use that to compute the tax amount of an order. But of course it’s not that easy.
Just within the EU there are many different VAT schemes. France has four different rates depending on the product being sold, Germany has three, other countries have two, and so on so forth. Looking beyond the EU, you find that the United States have instances of both state, city, and “special district” level sales taxes. And in Türkiye, when you sell passenger cars you charge taxes on taxes.
Most of the time tax rates are based on the product being sold. Doing item-level tax rates similar to the item-level adjustments is therefore the best approach. Let’s look at our data model with this change:
123456789101112131415{items: [{title: "T-Shirt",unit_price: 100,quantity: 1,adjustments: [],tax_lines: [{ code: "DK25", rate: 25 }]},],discounts: []...}
In the API response we can now calculate the item and order level totals.
1234567891011121314151617181920{items: [{title: "T-Shirt",unit_price: 100,quantity: 1,adjustments: [],tax_lines: [{ code: "DK25", rate: 25 }],subtotal: 100,discount_total: 0,tax_total: 25, // subtotal * sum(tax_lines.rate) / 100total: 125 // subtotal + tax_total - discount_total},],discounts: []subtotal: 100,discount_total: 0,tax_total: 25 // sum(items.tax_total)
Medusa has a built-in Tax module that configures and calculates the right tax rates for items in a Cart. These are determined when items are added to the Cart or when shipping details are updated. But just like how the Promotion module doesn’t have to be the source for item adjustments, the Tax module doesn’t have to be the source for tax lines. This means that it is simple to integrate with providers like Avalara or Stripe Tax.
A note on precision
When you start dealing with percentages (e.g., tax rates and percentage discounts) you can bump into some annoying edge cases wrt. totals. In the examples used so far, we have dealt with integer totals. But let’s consider a scenario where a customer has three items in their cart each priced 1.99.
123456789{items: [{ title: "Notebook", unit_price: 1.99, quantity: 1 },{ title: "Pen", unit_price: 1.99, quantity: 1 },{ title: "Stickers", unit_price: 1.99, quantity: 1 },],discounts: [],...}
When you add a 20% discount to this cart with our item level adjustments, you get the following:
You could be tempted to round the adjustment to the lowest currency unit (e.g., cents). But doing so would result in an inaccurate calculation. As you would end up with a 1.20 discount instead of the correct 1.19. Not the biggest deal you might say. However, eventually customers will notice, and trust will be weakened. And over enough orders these discrepancies become noticeable.
In an earlier version of Medusa we stored all values as integers in the lowest currency unit (e.g., cents). This is common practice in systems that deal with money, however, it makes dealing with precision issues more complicated. We have therefore switched to storing full precision for all intermediate calculations (like line item adjustments), and only round to the lowest currency unit when totals leave the system.
Note: to avoid weird JavaScript arithmetic problems (try 0.1 + 0.2), we use the excellent BigNumber.js library.
Currencies, minor units, quirky rules, and global carts
A magical property of the internet is that it’s global out of the box. Go live and reach the world. For that to be effective in digital commerce, you want to surface prices in local currencies. This has smaller implications for our totals calculations when it comes to rounding results.
Currencies differ in minor units, and since you can’t charge customers half a cent (or half whatever minor unit) you need to round totals before charging the customer, exporting to ERP systems or generating invoices.
Most currencies have two decimal precision to represent cents like USD and EUR. But JPY and KRW has zero decimals, and some currencies like Kuwait’s KWD has three.
As mentioned previously Medusa keeps track of the full precision in calculating intermediate values and only when the total leaves Medusa is the rounding completed.
To keep track of the rounding rules, Medusa stores all currencies’ precisions in a table. Furthermore, the cart needs to keep track of the currency values are denominated in resulting in a data model like this:
1234567891011121314{currency_code: "usd",items: [{title: "T-Shirt",unit_price: 100,quantity: 1,adjustments: [],tax_lines: []},],discounts: []...}
If the precision rules themselves, wasn’t enough of a quirk for you, there is still the edge case of Swiss CHF, where totals should be rounded so they are divisible by 0.05.
Tax inclusivity
When you sell in different currencies across multiple markets your transactions become subject to different VAT rates. For example, a t-shirt should be taxed at a VAT rate of 19%, 20%, or 23% depending on whether it’s shipped to Germany, France, or Portugal. Most of the time, however, you will want the ticket price of that t-shirt to be the same; say €50.
In Medusa, you can manage prices for products at different levels. For example, you can have a currency price or a country price. You can also choose to set the price as tax inclusive or tax exclusive. This makes it straightforward to solve the problem of having the same price even when VAT rates differ. Simply set the EUR price for the t-shirt to be €50, and Medusa will calculate the VAT amount.
As with everything else, however, this adds some complexity to our totals calculation. Let’s see an example. Imagine you have the following cart:
12345678910111213{currency_code: "eur",items: [{title: "T-Shirt",unit_price: 50,is_tax_inclusive: true,quantity: 1,tax_lines: [{ code: "FR", rate: 20 }],adjustments: []}]}
Notice we expanded the item data model to include an Copy to clipboardis_tax_inclusive
flag. This flag is set based on whether the derived unit price is a value to be treated with or without taxes. With this information we can calculate the totals for the item with the following logic.
123456789// When is_tax_inclusive: true - start with the total and work backwardstotal = unit_price * quantitytax_total = total * (1 - 1 / (1 + sum(tax_lines.rate) / 100))subtotal = total - tax_total// When is_tax_inclusive: false - start with the subtotal and work backwardssubtotal = unit_price * quantitytax_total = subtotal * sum(tax_lines.rate) / 100total = subtotal + tax_total
The logic above is simplified and doesn’t include discount considerations. However, similar calculations are needed wrt. discount amounts being tax inclusive or not. E.g., should you give €10 off the order on the amount with or without taxes - if your customers are consumers you almost always want the amount off to account for taxes, the opposite is generally true if your customers are businesses.
Let the backend handle (all) the totals
This post covers the “complexity highlights” wrt. to totals calculations, but there are a lot more totals that you might need when creating great customer experiences. With other platforms, you may find yourself doing client-side calculations to display the right numbers in a cart. That works, but it’s error prone (e.g., forgetting/removing a field in a GraphQL request can mess up your calculation). For this reason, Medusa calculates all the totals you might need in a customer experience. This post cannot cover all the quirks and edge cases that brought them to life, but here’s the list if you are curious.
Item Totals
12345678910compare_at_unit_price // used for strike-through pricingunit_price // the price to chargeoriginal_total // the total without discountsoriginal_subtotal // the subtotal without accounting for discountsoriginal_tax_total // the tax total without accounting for discountstotal // the total amount charged for this itemsubtotal // the item total before discounts and taxestax_total // the tax amount included in the item's totaldiscount_total // the discount amount subtracted from the subtotaldiscount_tax_total // the tax that would have been in absence of the discount
A lot of the totals above carry no accounting significance, but are purely there to help make it easy to build a cart page that customers will understand. For example, discounts should generally be applied without taxes, however, when you display the discount amount to a consumer you want to tell them that they saved €10 on a €50 order and not €8.33. The Copy to clipboarddiscount_tax_total
is therefore available to easily show the saved amount after taxes.
Cart totals
1234567891011121314151617181920original_item_total // sum of item prices including tax, before any discountsoriginal_item_subtotal // item prices excluding tax, before any discountsoriginal_item_tax_total // tax that would have been charged on items before discountsitem_total // amount charged for all items after discounts, tax includeditem_subtotal // item prices excluding tax, after discountsitem_tax_total // tax portion already included in item_totalshipping_total // shipping cost charged after discounts, tax includedshipping_subtotal // shipping cost excluding tax, after discountsshipping_tax_total // tax included in shipping_totaloriginal_shipping_total // shipping cost with tax, before any discountsoriginal_shipping_subtotal // shipping cost excluding tax, before discountsoriginal_shipping_tax_total // tax that would have been charged on shipping before discountsdiscount_total // amount taken off subtotal by discounts (pre-tax)discount_tax_total // tax that would have applied to discount_total if no discount existedgift_card_total // value covered by gift cards (reduces what the customer pays)
Adding it all up
Totals may seem trivial, but they are where everything comes together in a commerce platform: every discount, tax rule, currency quirk, and rounding edge-case fires through them before money and products move. Getting from Copy to clipboardunit_price * quantity
to production-grade calculations involve every domain and even a bit of psychology (customers need to understand the calculation and see the pennies add up).
If you are building a custom commerce experience, I hope this post was helpful. And if you don’t want to build all the core commerce logic from scratch you should check out Medusa. We give you the primitives you need for digital commerce (like calculating totals, keeping track of inventory, processing orders, etc.), and a backend framework for creating custom logic. This allows you to build everything from DTC e-commerce sites, B2B self-serve portals, marketplaces, SaaS billing systems, and much, much more.