Reconciler Pricing and Tax
Tax rate wrong when shipping address differs from customer default
A logged-in customer with more than one saved address checks out, picks a shipping address that is not their default, and the order still gets taxed as if it shipped to the default one. Sometimes that means the wrong region's rate. Sometimes, across countries, it means 0% tax on an order that should have carried VAT. The tax rules are not broken. The address Magento resolves before it asks the rules is. Here is why it happens and a script that finds every order it hit.
Magento decides which address's tax zone to apply using the store-wide Tax Calculation Based On setting (Stores, Configuration, Sales, Tax, Calculation Settings), which can be Billing Address, Shipping Address, or Shipping Origin. For a logged-in customer with more than one saved address, quote and order totals collection can resolve the tax class against the customer's default address record instead of re-resolving it against the shipping address actually selected at checkout. This is confirmed in magento2 issue 38232, where a French delivery address was taxed at 0% because the customer's default Belgium address was used instead. The tax rule engine itself is deterministic and not at fault. The bug is an address resolution defect upstream of it. There is no safe REST write to fix tax on a placed order, so the responsible move is to independently compute the expected rate for the correct address, compare it against what the order actually charged, and report every mismatch for a human to reconcile with a credit memo and a corrected invoice. Full code, tests, and a dry run guard are below.
The problem in plain words
Tax rules in Magento are simple on paper. A customer tax class plus a product tax class plus a country, region, and postcode should point at exactly one row in tax_calculation, and that row has the rate. Given the same three inputs, the engine always returns the same answer.
The trouble is what feeds it that third input, the address. A store owner sets Tax Calculation Based On once, usually to Shipping Address, and expects every order to be taxed against whichever shipping address the customer picked at checkout. But for a logged-in customer who has saved more than one address, and especially one whose default address is in a different country or region than the one they are shipping to this time, quote and order totals collection can quietly reach for the customer's stored default address record instead of the quote's actual selected shipping address. The order still shows the correct shipping address in the address book. It is only the tax line that was computed against a different one.
Why it happens
The design intent is sound: Tax Calculation Based On is supposed to point totals collection at one specific address on the quote, either the billing address, the shipping address, or the store's shipping origin, every time. A few things make the defect easy to miss:
- It only shows up for logged-in, multi-address customers whose default address differs from the one they select for a given order, so most single-address checkouts never surface it.
- It is most damaging across countries. Confirmed in magento2 issue 38232, a French address taxed at 0% because the customer's default address was Belgium, meaning the store under-collected VAT entirely rather than just applying a slightly wrong regional rate.
- The order's address book still shows the correct shipping address, so a quick glance at the order does not raise a flag. Only the
applied_taxesand each item'stax_percentdisagree with what that shipping address should have produced. - A related but distinct misconfiguration, documented in magento2 issue 13674, is tax basing itself on Shipping Origin when the store owner expected Shipping Address, which produces a similarly wrong rate for a completely different, config level reason. Both are worth ruling in or out with the same detection pass.
This is a real, reported core defect around address resolution, not a rule configuration mistake. The tax rules and rates are deterministic once given a country, region, and postcode. See the citations at the end for the exact GitHub issues and Adobe Commerce docs.
Tax rate math on a placed order is deterministic: given the correct resolved address, the customer tax class, and the product tax class, exactly one row in tax_calculation should apply. That means we do not need to guess whether an order's tax is wrong. We can independently recompute the expected rate for the address the store's own based_on setting says should have been used, compare it to what the order actually charged, and flag only the orders where those two numbers disagree beyond rounding. No invoice or credit memo write happens automatically. Rewriting tax on a placed order is a financial transaction that needs a human.
The fix, as a flow
For each order we care about, we read the store's tax/calculation/based_on setting, the order's resolved billing and shipping addresses, its customer tax class, and its applied_taxes. We pull the tax rules and rates that apply to that customer and product tax class, independently compute the expected rate for whichever address based_on points at, and compare it to the rate the order actually applied. We separately flag any order whose shipping address customer_address_id differs from the customer's own default_shipping or default_billing id, since that is the highest risk signature of this exact leak.
Build it step by step
Get an admin token
Authenticate against the admin token endpoint with an admin username and password, or use an integration access token if you already have one. Keep the base URL, credentials, and the list of order ids to audit in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export ORDER_IDS="1001,1002,1003"
export RATE_EPSILON="0.05"
export DRY_RUN="true" # report only by default
export REPAIR_CONFIRM="false" # set true only with DRY_RUN=false to post a review comment
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export ORDER_IDS="1001,1002,1003"
export RATE_EPSILON="0.05"
export DRY_RUN="true" // report only by default
export REPAIR_CONFIRM="false" // set true only with DRY_RUN=false to post a review comment
Read the store's tax basis and the order's addresses
GET /rest/V1/store/storeConfigs for the field tax/calculation/based_on, which tells you whether the store taxes on billing, shipping, or shipping origin. Then GET /rest/V1/orders/{id} and read billing_address and extension_attributes.shipping_assignments[0].shipping.address for the region, country, and postcode of each address that was actually recorded on the order.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
def get_token(username, password):
r = requests.post(
f"{MAGENTO_URL}/rest/V1/integration/admin/token",
json={"username": username, "password": password},
timeout=30,
)
r.raise_for_status()
return r.json()
def get_store_tax_based_on(token):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/store/storeConfigs",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
configs = r.json()
# Adobe Commerce exposes this as a store config extension attribute or a
# dedicated config value depending on version; fall back to "shipping".
first = configs[0] if configs else {}
return (first.get("extension_attributes", {}) or {}).get(
"tax_calculation_based_on", "shipping"
)
def get_order(token, order_id):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/orders/{order_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
async function getToken(username, password) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function getStoreTaxBasedOn(token) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/store/storeConfigs`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const configs = await res.json();
const first = configs[0] || {};
return (first.extension_attributes || {}).tax_calculation_based_on || "shipping";
}
async function getOrder(token, orderId) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/orders/${orderId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
Fetch the applicable tax rules and rates
GET /rest/V1/taxRules/search and /rest/V1/taxRates/search, filtering by the order's customer_tax_class_id and product tax class ids where possible. Each rule references rate ids, and each rate carries tax_country_id, tax_region_id, tax_postcode, and rate. Page through with searchCriteria[pageSize] and [currentPage] since a store can have many rates.
def search_all(token, path, page_size=100):
items = []
page = 1
while True:
params = {
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": page,
}
r = requests.get(
f"{MAGENTO_URL}/rest/V1/{path}",
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
batch = body.get("items", [])
items.extend(batch)
if len(batch) < page_size:
return items
page += 1
def get_tax_rules(token):
return search_all(token, "taxRules/search")
def get_tax_rates(token):
return search_all(token, "taxRates/search")
async function searchAll(token, path, pageSize = 100) {
const items = [];
let page = 1;
while (true) {
const params = new URLSearchParams({
"searchCriteria[pageSize]": String(pageSize),
"searchCriteria[currentPage]": String(page),
});
const res = await fetch(`${MAGENTO_URL}/rest/V1/${path}?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
const batch = body.items || [];
items.push(...batch);
if (batch.length < pageSize) return items;
page += 1;
}
}
async function getTaxRules(token) {
return searchAll(token, "taxRules/search");
}
async function getTaxRates(token) {
return searchAll(token, "taxRates/search");
}
Decide, with one pure function
Keep the rate resolution in its own function that takes the resolved address, the customer and product tax class ids, and the rules and rates already fetched. It filters rules by class match, resolves each rule's rate ids, keeps rates whose country, region, and postcode match the address, sums the applicable rate, and returns the winning rule id. This mirrors what Magento's own rule engine should have computed, so we can compare it to what the order actually applied.
def _rate_matches_address(rate, address):
if str(rate.get("tax_country_id")) != str(address.get("country_id")):
return False
region_id = rate.get("tax_region_id")
if region_id not in (None, 0, "0") and str(region_id) != str(address.get("region_id")):
return False
postcode = rate.get("tax_postcode") or "*"
if postcode in ("*", ""):
return True
if "-" in postcode:
lo, hi = postcode.split("-", 1)
pc = address.get("postcode") or ""
return lo <= pc <= hi
return postcode == address.get("postcode")
def expected_tax_rate(resolved_address, customer_tax_class_id, product_tax_class_id, tax_rules, tax_rates):
rates_by_id = {r["id"]: r for r in tax_rates}
candidate_rules = [
rule for rule in tax_rules
if customer_tax_class_id in (rule.get("customer_tax_class_ids") or [])
and product_tax_class_id in (rule.get("product_tax_class_ids") or [])
]
candidate_rules.sort(key=lambda rule: rule.get("priority", 0))
for rule in candidate_rules:
matched_rate_total = 0.0
matched_any = False
for rate_id in rule.get("tax_rate_ids") or []:
rate = rates_by_id.get(rate_id)
if not rate:
continue
if _rate_matches_address(rate, resolved_address):
matched_rate_total += float(rate.get("rate", 0) or 0)
matched_any = True
if matched_any:
return {"expectedRate": matched_rate_total, "matchedRuleId": rule.get("id")}
return {"expectedRate": 0.0, "matchedRuleId": None}
function rateMatchesAddress(rate, address) {
if (String(rate.tax_country_id) !== String(address.country_id)) return false;
const regionId = rate.tax_region_id;
if (regionId !== null && regionId !== 0 && regionId !== "0" && String(regionId) !== String(address.region_id)) {
return false;
}
const postcode = rate.tax_postcode || "*";
if (postcode === "*" || postcode === "") return true;
if (postcode.includes("-")) {
const [lo, hi] = postcode.split("-");
const pc = address.postcode || "";
return lo <= pc && pc <= hi;
}
return postcode === address.postcode;
}
export function expectedTaxRate(resolvedAddress, customerTaxClassId, productTaxClassId, taxRules, taxRates) {
const ratesById = new Map(taxRates.map((r) => [r.id, r]));
const candidateRules = taxRules
.filter((rule) =>
(rule.customer_tax_class_ids || []).includes(customerTaxClassId) &&
(rule.product_tax_class_ids || []).includes(productTaxClassId)
)
.sort((a, b) => (a.priority || 0) - (b.priority || 0));
for (const rule of candidateRules) {
let matchedRateTotal = 0;
let matchedAny = false;
for (const rateId of rule.tax_rate_ids || []) {
const rate = ratesById.get(rateId);
if (!rate) continue;
if (rateMatchesAddress(rate, resolvedAddress)) {
matchedRateTotal += Number(rate.rate || 0);
matchedAny = true;
}
}
if (matchedAny) return { expectedRate: matchedRateTotal, matchedRuleId: rule.id };
}
return { expectedRate: 0, matchedRuleId: null };
}
Compare the expected rate against what the order actually applied, and flag the default-address leak
Read the order's applied_taxes rate or an item's tax_percent as the actual rate, and compare it to expected_tax_rate for the address the store's based_on setting points at. Separately, compare the order's recorded shipping address customer_address_id to the customer's own default_shipping and default_billing ids from GET /rest/V1/customers/{id}. A mismatch there is the highest risk signature of the leak from magento2 issue 38232, even before the rate comparison confirms it.
def detect_tax_mismatch(order_actual_rate, expected_result, epsilon=0.05):
delta = abs(order_actual_rate - expected_result["expectedRate"])
return {
"isMismatch": delta > epsilon,
"expectedRate": expected_result["expectedRate"],
"actualRate": order_actual_rate,
"delta": round(delta, 4),
"matchedRuleId": expected_result["matchedRuleId"],
}
def is_default_address_leak(shipping_customer_address_id, default_shipping_id, default_billing_id):
if shipping_customer_address_id is None:
return False
return (
str(shipping_customer_address_id) != str(default_shipping_id)
and str(shipping_customer_address_id) != str(default_billing_id)
)
export function detectTaxMismatch(orderActualRate, expectedResult, epsilon = 0.05) {
const delta = Math.abs(orderActualRate - expectedResult.expectedRate);
return {
isMismatch: delta > epsilon,
expectedRate: expectedResult.expectedRate,
actualRate: orderActualRate,
delta: Math.round(delta * 10000) / 10000,
matchedRuleId: expectedResult.matchedRuleId,
};
}
export function isDefaultAddressLeak(shippingCustomerAddressId, defaultShippingId, defaultBillingId) {
if (shippingCustomerAddressId === null || shippingCustomerAddressId === undefined) return false;
return (
String(shippingCustomerAddressId) !== String(defaultShippingId) &&
String(shippingCustomerAddressId) !== String(defaultBillingId)
);
}
Wire it together, report only, and never rewrite tax directly
The loop authenticates once, reads the store's tax basis, walks every configured order id, computes the expected rate, and flags a mismatch or a default-address leak. DRY_RUN defaults to true and this script never mutates tax_amount, since there is no supported REST endpoint for that on a placed order. If DRY_RUN=false and REPAIR_CONFIRM=true are both explicitly set, it may post a documented note via /rest/V1/orders/{id}/comments for finance to review. It never generates a credit memo automatically; that write happens only after a human signs off.
This script never rewrites tax on a placed order, because Magento has no endpoint for that. It reports the order id, increment id, expected rate, actual rate, and delta so a human can run the credit memo and re-invoice cycle, then exits non-zero so the finding is not silently missed. Only with explicit confirmation does it add an order comment documenting the discrepancy; it never touches money on its own.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, and only ever performs reads plus an optional documentation comment, never a rewrite of tax, an invoice, or a credit memo.
"""Detect Magento 2 or Adobe Commerce orders where the applied tax rate does
not match the address the store's own Tax Calculation Based On setting says
should have been used.
Magento resolves the applicable tax zone using the address selected by the
store-wide Tax Calculation Based On setting (Stores, Configuration, Sales,
Tax, Calculation Settings), which can be Billing Address, Shipping Address,
or Shipping Origin. For a logged-in customer with more than one saved
address, quote and order totals collection can resolve the tax class against
the customer's default address record instead of re-resolving it against the
shipping address actually selected at checkout, especially across
multi-address customers or multi-country carts. This is confirmed in
magento2 issue 38232, where a French address was taxed at 0% because the
customer's default Belgium address was used instead. The tax rule engine
itself is deterministic; the defect is an address resolution problem
upstream of rule matching, not a rule configuration error.
This script never rewrites tax_amount on a placed order, since there is no
supported REST endpoint for that. It independently computes the expected
rate for the address the store's based_on setting points at, compares it to
what the order actually applied, and separately flags any order whose
shipping address customer_address_id differs from the customer's own
default_shipping or default_billing id, the highest risk signature of this
leak. It writes a report row for every order it flags and exits non-zero so
CI or alerting notices. A human reconciles a confirmed mismatch with a
credit memo to refund the wrong tax line, followed by a corrected invoice.
Only with DRY_RUN=false and REPAIR_CONFIRM=true does it post a documentation
comment via /rest/V1/orders/{id}/comments; it never mutates tax or money on
its own. Safe to run again and again.
"""
import os
import csv
import sys
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_tax_address_mismatch")
MAGENTO_URL = os.environ.get("MAGENTO_URL", "https://example.test").rstrip("/")
ADMIN_USERNAME = os.environ.get("MAGENTO_ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.environ.get("MAGENTO_ADMIN_PASSWORD", "change-me")
ADMIN_TOKEN = os.environ.get("MAGENTO_ADMIN_TOKEN")
ORDER_IDS = [o.strip() for o in os.environ.get("ORDER_IDS", "").split(",") if o.strip()]
RATE_EPSILON = float(os.environ.get("RATE_EPSILON", "0.05"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
REPAIR_CONFIRM = os.environ.get("REPAIR_CONFIRM", "false").lower() == "true"
OUTPUT_CSV = os.environ.get("OUTPUT_CSV", "tax_address_mismatches.csv")
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "100"))
def get_token():
if ADMIN_TOKEN:
return ADMIN_TOKEN
r = requests.post(
f"{MAGENTO_URL}/rest/V1/integration/admin/token",
json={"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()
def get_store_tax_based_on(token):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/store/storeConfigs",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
configs = r.json()
first = configs[0] if configs else {}
return (first.get("extension_attributes", {}) or {}).get(
"tax_calculation_based_on", "shipping"
)
def get_order(token, order_id):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/orders/{order_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def get_customer(token, customer_id):
r = requests.get(
f"{MAGENTO_URL}/rest/V1/customers/{customer_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def search_all(token, path, page_size=PAGE_SIZE):
items = []
page = 1
while True:
params = {
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": page,
}
r = requests.get(
f"{MAGENTO_URL}/rest/V1/{path}",
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
batch = body.get("items", [])
items.extend(batch)
if len(batch) < page_size:
return items
page += 1
def get_tax_rules(token):
return search_all(token, "taxRules/search")
def get_tax_rates(token):
return search_all(token, "taxRates/search")
def post_order_comment(token, order_id, message):
r = requests.post(
f"{MAGENTO_URL}/rest/V1/orders/{order_id}/comments",
json={"statusHistory": {"comment": message, "isVisibleOnFront": 0}},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def _rate_matches_address(rate, address):
if str(rate.get("tax_country_id")) != str(address.get("country_id")):
return False
region_id = rate.get("tax_region_id")
if region_id not in (None, 0, "0") and str(region_id) != str(address.get("region_id")):
return False
postcode = rate.get("tax_postcode") or "*"
if postcode in ("*", ""):
return True
if "-" in postcode:
lo, hi = postcode.split("-", 1)
pc = address.get("postcode") or ""
return lo <= pc <= hi
return postcode == address.get("postcode")
def expected_tax_rate(resolved_address, customer_tax_class_id, product_tax_class_id, tax_rules, tax_rates):
rates_by_id = {r["id"]: r for r in tax_rates}
candidate_rules = [
rule for rule in tax_rules
if customer_tax_class_id in (rule.get("customer_tax_class_ids") or [])
and product_tax_class_id in (rule.get("product_tax_class_ids") or [])
]
candidate_rules.sort(key=lambda rule: rule.get("priority", 0))
for rule in candidate_rules:
matched_rate_total = 0.0
matched_any = False
for rate_id in rule.get("tax_rate_ids") or []:
rate = rates_by_id.get(rate_id)
if not rate:
continue
if _rate_matches_address(rate, resolved_address):
matched_rate_total += float(rate.get("rate", 0) or 0)
matched_any = True
if matched_any:
return {"expectedRate": matched_rate_total, "matchedRuleId": rule.get("id")}
return {"expectedRate": 0.0, "matchedRuleId": None}
def detect_tax_mismatch(order_actual_rate, expected_result, epsilon=RATE_EPSILON):
delta = abs(order_actual_rate - expected_result["expectedRate"])
return {
"isMismatch": delta > epsilon,
"expectedRate": expected_result["expectedRate"],
"actualRate": order_actual_rate,
"delta": round(delta, 4),
"matchedRuleId": expected_result["matchedRuleId"],
}
def is_default_address_leak(shipping_customer_address_id, default_shipping_id, default_billing_id):
if shipping_customer_address_id is None:
return False
return (
str(shipping_customer_address_id) != str(default_shipping_id)
and str(shipping_customer_address_id) != str(default_billing_id)
)
def resolved_address_for_order(order, based_on):
ext = order.get("extension_attributes", {}) or {}
assignments = ext.get("shipping_assignments") or []
shipping_address = {}
if assignments:
shipping_address = (assignments[0].get("shipping") or {}).get("address") or {}
billing_address = order.get("billing_address") or {}
if based_on == "billing":
return billing_address
return shipping_address
def order_actual_rate(order):
applied = order.get("applied_taxes") or []
if applied:
return float(applied[0].get("percent", applied[0].get("rate", 0)) or 0)
items = order.get("items") or []
for item in items:
if item.get("tax_percent") is not None:
return float(item["tax_percent"])
return 0.0
def build_report_row(order, mismatch, leak):
return {
"order_id": order.get("entity_id"),
"increment_id": order.get("increment_id"),
"expected_rate": mismatch["expectedRate"],
"actual_rate": mismatch["actualRate"],
"delta": mismatch["delta"],
"matched_rule_id": mismatch["matchedRuleId"],
"default_address_leak": leak,
}
def run():
token = get_token()
based_on = get_store_tax_based_on(token)
tax_rules = get_tax_rules(token)
tax_rates = get_tax_rates(token)
flagged = []
for order_id in ORDER_IDS:
order = get_order(token, order_id)
address = resolved_address_for_order(order, based_on)
if not address:
continue
customer_tax_class_id = order.get("customer_tax_class_id")
items = order.get("items") or []
product_tax_class_id = items[0].get("tax_class_id") if items else None
expected = expected_tax_rate(address, customer_tax_class_id, product_tax_class_id, tax_rules, tax_rates)
actual_rate = order_actual_rate(order)
mismatch = detect_tax_mismatch(actual_rate, expected)
customer_id = order.get("customer_id")
leak = False
if customer_id:
customer = get_customer(token, customer_id)
ext = order.get("extension_attributes", {}) or {}
assignments = ext.get("shipping_assignments") or []
shipping_customer_address_id = None
if assignments:
shipping_customer_address_id = (assignments[0].get("shipping") or {}).get("address", {}).get("customer_address_id")
leak = is_default_address_leak(
shipping_customer_address_id,
customer.get("default_shipping"),
customer.get("default_billing"),
)
if not mismatch["isMismatch"] and not leak:
continue
row = build_report_row(order, mismatch, leak)
flagged.append(row)
log.warning(
"Order %s tax mismatch: expected_rate=%s actual_rate=%s delta=%s default_address_leak=%s",
row["increment_id"], row["expected_rate"], row["actual_rate"], row["delta"], row["default_address_leak"],
)
if not DRY_RUN and REPAIR_CONFIRM:
post_order_comment(
token, order_id,
f"Tax review: expected rate {row['expected_rate']}%, applied rate {row['actual_rate']}%, "
f"delta {row['delta']}. Possible default-address leak: {row['default_address_leak']}. "
"Flagged for finance review; no tax or money was changed automatically.",
)
if flagged:
with open(OUTPUT_CSV, "w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=[
"order_id", "increment_id", "expected_rate", "actual_rate", "delta",
"matched_rule_id", "default_address_leak",
])
writer.writeheader()
writer.writerows(flagged)
log.info("Wrote report to %s%s", OUTPUT_CSV, "" if not DRY_RUN else " (dry run, report only)")
log.info("Done. %d order(s) flagged with a tax or address mismatch.", len(flagged))
return flagged
if __name__ == "__main__":
flagged_orders = run()
sys.exit(1 if flagged_orders else 0)
/**
* Detect Magento 2 or Adobe Commerce orders where the applied tax rate does
* not match the address the store's own Tax Calculation Based On setting
* says should have been used.
*
* Magento resolves the applicable tax zone using the address selected by the
* store-wide Tax Calculation Based On setting (Stores, Configuration, Sales,
* Tax, Calculation Settings), which can be Billing Address, Shipping
* Address, or Shipping Origin. For a logged-in customer with more than one
* saved address, quote and order totals collection can resolve the tax
* class against the customer's default address record instead of
* re-resolving it against the shipping address actually selected at
* checkout, especially across multi-address customers or multi-country
* carts. This is confirmed in magento2 issue 38232, where a French address
* was taxed at 0% because the customer's default Belgium address was used
* instead. The tax rule engine itself is deterministic; the defect is an
* address resolution problem upstream of rule matching, not a rule
* configuration error.
*
* This script never rewrites tax_amount on a placed order, since there is
* no supported REST endpoint for that. It independently computes the
* expected rate for the address the store's based_on setting points at,
* compares it to what the order actually applied, and separately flags any
* order whose shipping address customer_address_id differs from the
* customer's own default_shipping or default_billing id, the highest risk
* signature of this leak. It writes a report row for every order it flags
* and exits non-zero so CI or alerting notices. A human reconciles a
* confirmed mismatch with a credit memo to refund the wrong tax line,
* followed by a corrected invoice. Only with DRY_RUN=false and
* REPAIR_CONFIRM=true does it post a documentation comment via
* /rest/V1/orders/{id}/comments; it never mutates tax or money on its own.
* Safe to run again and again.
*
* Guide: https://www.allanninal.dev/magento/tax-rate-wrong-for-shipping-address/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/$/, "");
const ADMIN_USERNAME = process.env.MAGENTO_ADMIN_USERNAME || "admin";
const ADMIN_PASSWORD = process.env.MAGENTO_ADMIN_PASSWORD || "change-me";
const ADMIN_TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "";
const ORDER_IDS = (process.env.ORDER_IDS || "").split(",").map((o) => o.trim()).filter(Boolean);
const RATE_EPSILON = Number(process.env.RATE_EPSILON || 0.05);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REPAIR_CONFIRM = (process.env.REPAIR_CONFIRM || "false").toLowerCase() === "true";
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 100);
function rateMatchesAddress(rate, address) {
if (String(rate.tax_country_id) !== String(address.country_id)) return false;
const regionId = rate.tax_region_id;
if (regionId !== null && regionId !== undefined && regionId !== 0 && regionId !== "0" && String(regionId) !== String(address.region_id)) {
return false;
}
const postcode = rate.tax_postcode || "*";
if (postcode === "*" || postcode === "") return true;
if (postcode.includes("-")) {
const [lo, hi] = postcode.split("-");
const pc = address.postcode || "";
return lo <= pc && pc <= hi;
}
return postcode === address.postcode;
}
export function expectedTaxRate(resolvedAddress, customerTaxClassId, productTaxClassId, taxRules, taxRates) {
const ratesById = new Map(taxRates.map((r) => [r.id, r]));
const candidateRules = taxRules
.filter((rule) =>
(rule.customer_tax_class_ids || []).includes(customerTaxClassId) &&
(rule.product_tax_class_ids || []).includes(productTaxClassId)
)
.sort((a, b) => (a.priority || 0) - (b.priority || 0));
for (const rule of candidateRules) {
let matchedRateTotal = 0;
let matchedAny = false;
for (const rateId of rule.tax_rate_ids || []) {
const rate = ratesById.get(rateId);
if (!rate) continue;
if (rateMatchesAddress(rate, resolvedAddress)) {
matchedRateTotal += Number(rate.rate || 0);
matchedAny = true;
}
}
if (matchedAny) return { expectedRate: matchedRateTotal, matchedRuleId: rule.id };
}
return { expectedRate: 0, matchedRuleId: null };
}
export function detectTaxMismatch(orderActualRate, expectedResult, epsilon = RATE_EPSILON) {
const delta = Math.abs(orderActualRate - expectedResult.expectedRate);
return {
isMismatch: delta > epsilon,
expectedRate: expectedResult.expectedRate,
actualRate: orderActualRate,
delta: Math.round(delta * 10000) / 10000,
matchedRuleId: expectedResult.matchedRuleId,
};
}
export function isDefaultAddressLeak(shippingCustomerAddressId, defaultShippingId, defaultBillingId) {
if (shippingCustomerAddressId === null || shippingCustomerAddressId === undefined) return false;
return (
String(shippingCustomerAddressId) !== String(defaultShippingId) &&
String(shippingCustomerAddressId) !== String(defaultBillingId)
);
}
export function resolvedAddressForOrder(order, basedOn) {
const ext = order.extension_attributes || {};
const assignments = ext.shipping_assignments || [];
let shippingAddress = {};
if (assignments.length) {
shippingAddress = (assignments[0].shipping || {}).address || {};
}
const billingAddress = order.billing_address || {};
return basedOn === "billing" ? billingAddress : shippingAddress;
}
export function orderActualRate(order) {
const applied = order.applied_taxes || [];
if (applied.length) return Number(applied[0].percent ?? applied[0].rate ?? 0);
const items = order.items || [];
for (const item of items) {
if (item.tax_percent !== undefined && item.tax_percent !== null) return Number(item.tax_percent);
}
return 0;
}
export function buildReportRow(order, mismatch, leak) {
return {
order_id: order.entity_id,
increment_id: order.increment_id,
expected_rate: mismatch.expectedRate,
actual_rate: mismatch.actualRate,
delta: mismatch.delta,
matched_rule_id: mismatch.matchedRuleId,
default_address_leak: leak,
};
}
async function getToken() {
if (ADMIN_TOKEN) return ADMIN_TOKEN;
const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function getStoreTaxBasedOn(token) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/store/storeConfigs`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const configs = await res.json();
const first = configs[0] || {};
return (first.extension_attributes || {}).tax_calculation_based_on || "shipping";
}
async function getOrder(token, orderId) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/orders/${orderId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function getCustomer(token, customerId) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/customers/${customerId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function searchAll(token, path, pageSize = PAGE_SIZE) {
const items = [];
let page = 1;
while (true) {
const params = new URLSearchParams({
"searchCriteria[pageSize]": String(pageSize),
"searchCriteria[currentPage]": String(page),
});
const res = await fetch(`${MAGENTO_URL}/rest/V1/${path}?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
const batch = body.items || [];
items.push(...batch);
if (batch.length < pageSize) return items;
page += 1;
}
}
async function getTaxRules(token) {
return searchAll(token, "taxRules/search");
}
async function getTaxRates(token) {
return searchAll(token, "taxRates/search");
}
async function postOrderComment(token, orderId, message) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/orders/${orderId}/comments`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ statusHistory: { comment: message, isVisibleOnFront: 0 } }),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
export async function run() {
const token = await getToken();
const basedOn = await getStoreTaxBasedOn(token);
const taxRules = await getTaxRules(token);
const taxRates = await getTaxRates(token);
const flagged = [];
for (const orderId of ORDER_IDS) {
const order = await getOrder(token, orderId);
const address = resolvedAddressForOrder(order, basedOn);
if (!address || Object.keys(address).length === 0) continue;
const customerTaxClassId = order.customer_tax_class_id;
const items = order.items || [];
const productTaxClassId = items.length ? items[0].tax_class_id : null;
const expected = expectedTaxRate(address, customerTaxClassId, productTaxClassId, taxRules, taxRates);
const actualRate = orderActualRate(order);
const mismatch = detectTaxMismatch(actualRate, expected);
let leak = false;
if (order.customer_id) {
const customer = await getCustomer(token, order.customer_id);
const ext = order.extension_attributes || {};
const assignments = ext.shipping_assignments || [];
let shippingCustomerAddressId = null;
if (assignments.length) {
shippingCustomerAddressId = ((assignments[0].shipping || {}).address || {}).customer_address_id;
}
leak = isDefaultAddressLeak(shippingCustomerAddressId, customer.default_shipping, customer.default_billing);
}
if (!mismatch.isMismatch && !leak) continue;
const row = buildReportRow(order, mismatch, leak);
flagged.push(row);
console.warn(`Order ${row.increment_id} tax mismatch: expected_rate=${row.expected_rate} actual_rate=${row.actual_rate} delta=${row.delta} default_address_leak=${row.default_address_leak}`);
if (!DRY_RUN && REPAIR_CONFIRM) {
await postOrderComment(
token, orderId,
`Tax review: expected rate ${row.expected_rate}%, applied rate ${row.actual_rate}%, delta ${row.delta}. ` +
`Possible default-address leak: ${row.default_address_leak}. Flagged for finance review; no tax or money was changed automatically.`,
);
}
}
console.log(`Done. ${flagged.length} order(s) flagged with a tax or address mismatch.${DRY_RUN ? " (dry run, report only)" : ""}`);
return flagged;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run()
.then((flagged) => { if (flagged.length) process.exit(1); })
.catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The rate resolution and mismatch rules are the parts most worth testing, because they decide whether an order gets flagged. Since expected_tax_rate, detect_tax_mismatch, and is_default_address_leak are pure, no network and no Magento instance are needed. The tests build a small fixture rule and rate table mirroring the issue 38232 scenario, a French shipping address taxed as if it were the customer's default Belgium address.
from detect_tax_address_mismatch import (
expected_tax_rate,
detect_tax_mismatch,
is_default_address_leak,
)
TAX_RATES = [
{"id": 1, "tax_country_id": "BE", "tax_region_id": 0, "tax_postcode": "*", "rate": 0.0},
{"id": 2, "tax_country_id": "FR", "tax_region_id": 0, "tax_postcode": "*", "rate": 20.0},
{"id": 3, "tax_country_id": "US", "tax_region_id": 12, "tax_postcode": "90001-90099", "rate": 8.25},
]
TAX_RULES = [
{"id": 1, "priority": 0, "customer_tax_class_ids": [3], "product_tax_class_ids": [2], "tax_rate_ids": [1, 2]},
{"id": 2, "priority": 1, "customer_tax_class_ids": [3], "product_tax_class_ids": [2], "tax_rate_ids": [3]},
]
def test_french_shipping_address_expects_french_vat():
france = {"country_id": "FR", "region_id": None, "postcode": "75001"}
result = expected_tax_rate(france, 3, 2, TAX_RULES, TAX_RATES)
assert result["expectedRate"] == 20.0
assert result["matchedRuleId"] == 1
def test_belgium_default_address_expects_zero():
belgium = {"country_id": "BE", "region_id": None, "postcode": "1000"}
result = expected_tax_rate(belgium, 3, 2, TAX_RULES, TAX_RATES)
assert result["expectedRate"] == 0.0
def test_issue_38232_style_mismatch_is_detected():
# order shipped to France but was taxed as if the address were Belgium (0%)
france = {"country_id": "FR", "region_id": None, "postcode": "75001"}
expected = expected_tax_rate(france, 3, 2, TAX_RULES, TAX_RATES)
mismatch = detect_tax_mismatch(order_actual_rate=0.0, expected_result=expected)
assert mismatch["isMismatch"] is True
assert mismatch["expectedRate"] == 20.0
assert mismatch["delta"] == 20.0
def test_matching_rate_is_not_a_mismatch():
france = {"country_id": "FR", "region_id": None, "postcode": "75001"}
expected = expected_tax_rate(france, 3, 2, TAX_RULES, TAX_RATES)
mismatch = detect_tax_mismatch(order_actual_rate=20.0, expected_result=expected)
assert mismatch["isMismatch"] is False
def test_within_epsilon_is_not_a_mismatch():
france = {"country_id": "FR", "region_id": None, "postcode": "75001"}
expected = expected_tax_rate(france, 3, 2, TAX_RULES, TAX_RATES)
mismatch = detect_tax_mismatch(order_actual_rate=19.98, expected_result=expected, epsilon=0.05)
assert mismatch["isMismatch"] is False
def test_us_postcode_range_rate_matches():
address = {"country_id": "US", "region_id": 12, "postcode": "90045"}
result = expected_tax_rate(address, 3, 2, TAX_RULES, TAX_RATES)
assert result["expectedRate"] == 8.25
assert result["matchedRuleId"] == 2
def test_default_address_leak_detected_when_shipping_id_differs():
assert is_default_address_leak(shipping_customer_address_id=42, default_shipping_id=7, default_billing_id=7) is True
def test_no_leak_when_shipping_matches_default():
assert is_default_address_leak(shipping_customer_address_id=7, default_shipping_id=7, default_billing_id=9) is False
def test_no_leak_when_no_customer_address_id_present():
assert is_default_address_leak(shipping_customer_address_id=None, default_shipping_id=7, default_billing_id=9) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import {
expectedTaxRate,
detectTaxMismatch,
isDefaultAddressLeak,
} from "./detect-tax-address-mismatch.js";
const TAX_RATES = [
{ id: 1, tax_country_id: "BE", tax_region_id: 0, tax_postcode: "*", rate: 0.0 },
{ id: 2, tax_country_id: "FR", tax_region_id: 0, tax_postcode: "*", rate: 20.0 },
{ id: 3, tax_country_id: "US", tax_region_id: 12, tax_postcode: "90001-90099", rate: 8.25 },
];
const TAX_RULES = [
{ id: 1, priority: 0, customer_tax_class_ids: [3], product_tax_class_ids: [2], tax_rate_ids: [1, 2] },
{ id: 2, priority: 1, customer_tax_class_ids: [3], product_tax_class_ids: [2], tax_rate_ids: [3] },
];
test("French shipping address expects French VAT", () => {
const france = { country_id: "FR", region_id: null, postcode: "75001" };
const result = expectedTaxRate(france, 3, 2, TAX_RULES, TAX_RATES);
assert.equal(result.expectedRate, 20.0);
assert.equal(result.matchedRuleId, 1);
});
test("Belgium default address expects zero", () => {
const belgium = { country_id: "BE", region_id: null, postcode: "1000" };
const result = expectedTaxRate(belgium, 3, 2, TAX_RULES, TAX_RATES);
assert.equal(result.expectedRate, 0.0);
});
test("issue 38232 style mismatch is detected", () => {
const france = { country_id: "FR", region_id: null, postcode: "75001" };
const expected = expectedTaxRate(france, 3, 2, TAX_RULES, TAX_RATES);
const mismatch = detectTaxMismatch(0.0, expected);
assert.equal(mismatch.isMismatch, true);
assert.equal(mismatch.expectedRate, 20.0);
assert.equal(mismatch.delta, 20.0);
});
test("matching rate is not a mismatch", () => {
const france = { country_id: "FR", region_id: null, postcode: "75001" };
const expected = expectedTaxRate(france, 3, 2, TAX_RULES, TAX_RATES);
const mismatch = detectTaxMismatch(20.0, expected);
assert.equal(mismatch.isMismatch, false);
});
test("within epsilon is not a mismatch", () => {
const france = { country_id: "FR", region_id: null, postcode: "75001" };
const expected = expectedTaxRate(france, 3, 2, TAX_RULES, TAX_RATES);
const mismatch = detectTaxMismatch(19.98, expected, 0.05);
assert.equal(mismatch.isMismatch, false);
});
test("US postcode range rate matches", () => {
const address = { country_id: "US", region_id: 12, postcode: "90045" };
const result = expectedTaxRate(address, 3, 2, TAX_RULES, TAX_RATES);
assert.equal(result.expectedRate, 8.25);
assert.equal(result.matchedRuleId, 2);
});
test("default address leak detected when shipping id differs", () => {
assert.equal(isDefaultAddressLeak(42, 7, 7), true);
});
test("no leak when shipping matches default", () => {
assert.equal(isDefaultAddressLeak(7, 7, 9), false);
});
test("no leak when no customer address id present", () => {
assert.equal(isDefaultAddressLeak(null, 7, 9), false);
});
Case studies
The Belgium default address that taxed a French order at 0%
A store selling across the EU had a repeat customer whose account default address was in Belgium. On a later order they shipped to a French address instead, a gift for a relative. Everything on the order page looked right, the French address was in the shipping section, but the order carried no VAT at all.
Running the audit against the last month of cross-country orders reproduced exactly the pattern from issue 38232: the expected rate for the French address was 20%, the order had applied 0%, and the delta matched the Belgium rate precisely. Finance used the reported order id and delta to raise a credit memo for the under-collected VAT and re-invoice correctly, without touching any other order.
A wholesale buyer with three warehouses and one default
A wholesale customer had three saved shipping addresses for three different warehouses, each in a different US state, but only one was marked default. Orders shipped to the two non-default warehouses kept coming back with the default warehouse's state sales tax rate instead of the destination state's rate.
The script flagged every order whose shipping customer_address_id did not match the account's default_shipping id, then confirmed each one against the expected rate for its actual destination state. The pattern was consistent enough that the store's tax team escalated it with their own audit as supporting evidence, while the flagged orders were reconciled individually.
After running this on a schedule, a default-address tax leak stops being an invisible under-collection that only shows up in a VAT audit months later. You get a dated report of the order id, increment id, expected rate, actual rate, delta, matched rule id, and whether a default-address leak was detected, plus a non-zero exit code so CI or alerting cannot miss it. No tax figure or invoice is ever rewritten automatically. Every confirmed mismatch goes through a human, a credit memo for the wrong tax line, and a corrected invoice, which is exactly how a financial correction should happen.
FAQ
Why did Magento tax my order at the wrong rate, or at 0%, when the shipping address looked correct?
Magento resolves the applicable tax zone using the address selected by the store's Tax Calculation Based On setting, which can be Billing Address, Shipping Address, or Shipping Origin. For a logged-in, multi-address customer, quote and order totals collection can resolve against the customer's default address record instead of re-resolving against the shipping address actually chosen at checkout. A confirmed core issue, magento2 issue 38232, shows a French address taxed at 0% because the customer's default Belgium address was used instead.
Can I fix a wrong tax amount on a placed order through the REST API?
Not directly. There is no supported REST endpoint that rewrites tax_amount on a placed order. Recalculating tax on a completed order requires a credit memo to refund the wrong tax line, followed by a corrected invoice, which is a financial transaction that needs human sign-off. The safe response is to detect and report the discrepancy, then let finance run the credit memo and re-invoice cycle.
How do I detect which orders were taxed against the wrong address?
Read the store's tax/calculation/based_on setting from GET /rest/V1/store/storeConfigs, then for each order read the resolved billing and shipping addresses, applied_taxes, and tax_percent from GET /rest/V1/orders/{id}, and pull the matching tax rules and rates from /rest/V1/taxRules/search and /rest/V1/taxRates/search. Independently compute the expected rate for the address the based_on setting points at and compare it to the order's actual applied rate. Also flag any order whose shipping address customer_address_id differs from the customer's default_shipping or default_billing id from GET /rest/V1/customers/{id}, since that is the highest risk pattern for this leak.
Related field notes
Citations
On the problem:
- The vat rate is not applied correctly when the selected address is not in the same tax class as the customer's default address, magento2 issue 38232. github.com/magento/magento2/issues/38232
- Tax is based on Shipping origin (and shouldn't), magento2 issue 13674. github.com/magento/magento2/issues/13674
- Tax configuration settings, Adobe Commerce. experienceleague.adobe.com commerce-admin/stores-sales/site-store/taxes/tax-settings-general
On the solution:
- Tax rules, Adobe Commerce. experienceleague.adobe.com commerce-admin/stores-sales/site-store/taxes/tax-rules
- Tax classes, Adobe Commerce. experienceleague.adobe.com commerce-admin/stores-sales/site-store/taxes/tax-class
- Value added tax (VAT), Adobe Commerce. experienceleague.adobe.com commerce-admin/stores-sales/site-store/taxes/vat
Stuck on a tricky one?
If you have a problem in Magento orders, tax, invoices, or credit memos that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this catch a hidden tax leak?
If this saved you from an under-collection or a confusing VAT reconciliation, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi