← back to blog

Shopify Tax is a Three-Way Handshake

A client messaged me: “I changed all product prices to be without VAT, can that be the reason taxes are missing in SAP?”

I opened the order payload. Oh.

The integration had worked flawlessly for two years. The day the client flipped one Market to tax-exclusive, half the B2C orders started syncing to SAP without VAT. The bug wasn’t new — it had been dormant since day one. The price change just revealed it.

Here’s what I learned rewriting the tax logic properly.

Tax in Shopify isn’t a field. It’s a handshake.

When you fetch an order via Admin API, tax information shows up in three independent places:

  1. Order.taxesIncluded — a boolean. Whether prices already include VAT.
  2. taxLines[] — present on the order, every line item, and every shipping line. Each entry has rate and amount.
  3. subtotalPriceSet / totalPriceSet / totalTaxSet — rolled-up totals.

Three fields. They don’t always agree, because they each tell a different part of the story. You have to read all three together.

The four modes you’ll see in production

Real data from one CZ-based EU store, same merchant, same API version:

ScenariotaxesIncludedtaxLines.amountoriginalUnitPrice contains
B2C domestic (CZ)truepopulatedVAT baked in
B2C cross-border EU (SK / PL)falsepopulatednet price, tax added on top
B2B reverse charge (valid VAT ID, SK/PL)false0 with rate preservednet price, no VAT charged
B2B domestic (CZ→CZ)truepopulatedVAT baked in

Two things worth pausing on.

First: taxesIncluded is NOT a shop-wide constant. It’s configured per Market, and B2B customer context can override it. One store, same hour, can emit true on one order and false on the next. If your code caches it anywhere, go delete that cache.

Second: zero tax ≠ no tax rate. Reverse-charge B2B EU orders have taxLines[].amount === 0 but rate === 0.23. No VAT is collected (that’s reverse charge). But the rate stays on the record, because the legal invoice must still mention it. If you skip taxLines entries where amount === 0 thinking they’re empty, you’ll under-bill your B2B customers.

The subtotal flip

This is the part that bit the integration.

When taxesIncluded === true, subtotal is gross — it already includes VAT. total = subtotal + shipping.

When taxesIncluded === false, subtotal is net — tax is separate. total = subtotal + shipping + tax.

Same field name, same API shape, two different meanings depending on a boolean elsewhere in the payload.

Golden invariant:

totalPriceSet === subtotalPriceSet 
              + totalShippingPriceSet 
              + (taxesIncluded ? 0 : totalTaxSet)

If this doesn’t hold for an order, something unusual is going on — refunds, adjustments, manual edits. Flag it before you sync.

Why you should never compute tax from rate × net

You’d expect rate × net === reported tax. It doesn’t always match.

Real order from the bug:

  • Net line: 289.56 PLN
  • Rate: 0.23
  • Expected tax: 289.56 × 0.23 = 66.60 PLN
  • Shopify reported tax: 66.69 PLN

A 0.09 PLN drift. Shopify does per-market rounding somewhere internal and I haven’t fully reverse-engineered where. But it’s real, and it’s reproducible.

Rule: if you need to reconcile with Shopify’s invoice exactly, sum taxLines[].amount. Only fall back to rate × net when the amount is zero (reverse charge — there’s no reported amount, so you have no choice).

One function, three branches

Most ERPs (SAP, Helios, Pohoda, SAGE) expect gross unit prices on line items — they derive tax backwards. Shopify doesn’t always give you gross. You construct it:

function grossUpLineTotal(netTotal, taxLines, isTaxExclusive) {
  if (!isTaxExclusive) return netTotal;              // already gross

  const taxAmount = sum(taxLines.map(t => t.amount));
  if (taxAmount > 0) return netTotal + taxAmount;    // B2C tax-exclusive

  const rate = taxLines[0]?.rate ?? 0;
  return netTotal * (1 + rate);                      // reverse charge
}

One function, three branches, covers every case. Notice what’s not in that decision tree: isReverseCharge, country codes, taxExempt, customer type. If you find yourself special-casing countries, you’re one level too shallow. taxesIncluded captures everything you need.

Don’t forget the shipping line

Easy to miss, because shipping costs are usually small. But shipping has its own taxLines[] with the same structure, and for tax-exclusive orders the shipping price is also net. If you pass it through to the ERP as-is, you’ll under-bill shipping VAT.

Apply the same gross-up function to the shipping line. Same three branches.

This happens to be the second bug I found in the same codebase. Git blame showed a commit titled "remove taxes from shipping" from years earlier — someone had papered over a related symptom by dropping shipping VAT entirely. It looked correct for Mode 1 orders. It silently broke Modes 2 and 3.

The sanity check that catches 90% of weirdness

Before syncing any order to an ERP, verify the invariant:

const total = parseFloat(order.totalPriceSet.presentmentMoney.amount);
const subtotal = parseFloat(order.subtotalPriceSet.presentmentMoney.amount);
const shipping = parseFloat(order.totalShippingPriceSet.presentmentMoney.amount);
const tax = parseFloat(order.totalTaxSet.presentmentMoney.amount);

const expectedTotal = subtotal + shipping + (order.taxesIncluded ? 0 : tax);
const drift = Math.abs(total - expectedTotal);

if (drift > 0.5) {
  flagForReview(order, { drift });
}

Refunds, manual edits, currency conversion artifacts — they all show up as drift here before they become a wrong invoice in your ERP.

The mental model that made it click

A Shopify order is a receipt in progress, in three layers:

  • originalUnitPrice — sticker price the customer saw.
  • taxLines — tax narration: what rate applied, what money was actually collected.
  • totalPriceSet — what the card was charged. This one is always true.

taxesIncluded tells you whether the sticker price had VAT baked in. Everything else follows from that boolean.

What to go check today

If you maintain a Shopify→ERP integration, three things worth checking before the next time a client messages you about missing tax:

  1. Search the code for country codes in tax logic. If you find them, it’s a leaky abstraction. Replace with taxesIncluded.
  2. Check how you handle taxLines with amount === 0. If you filter them out, you’re silently breaking reverse charge.
  3. Run the invariant on historic orders. If it doesn’t hold somewhere, you have a reconciliation story worth knowing before your client does.

Tax isn’t a sexy topic. Nobody at Shopify Unite does a talk called “How the tax payload actually works.” But it’s the kind of backend plumbing that decides whether your integration survives the first time a merchant expands to a new market — or becomes the reason you spend a Saturday reconciling invoices by hand.