Skip to content

Diagnostic Pricing and Tax

Tax recalculated incorrectly after coupon applied

A shopper applies a coupon, the discount looks right on the order, and the tax line still looks plausible on its own. But add it up: subtotal minus discount plus tax does not equal the grand total. Nobody notices at checkout, because every individual number looks reasonable. It only shows up when finance reconciles orders against what they collected in tax, and by then dozens of orders already carry the wrong number. Here is why Magento's total collectors disagree with each other and a script that finds every order where they do.

Python and Node.js Adobe Commerce REST API Safe by default (report only)
A sale sign on a building
Photo by Aleksi Partanen on Unsplash
The short answer

Magento builds an order's totals through a chain of collector models: Subtotal, then Discount, then Tax, then Grand Total. Whether that chain produces a consistent number depends on two settings under Stores, Configuration, Sales, Tax, Calculation Settings: Apply Customer Tax, which can be Before Discount or After Discount, and Apply Discount on Prices, which can be Excluding Tax or Including Tax. When those settings disagree with how catalog prices are entered, or a cart price rule coupon is combined with tax inclusive catalog prices, the discount collector subtracts the coupon amount from the row total using one base, while the tax collector recomputes tax_amount from the pre discount unit price. The row's discount_tax_compensation_amount ends up calculated against the wrong base, or left at zero, and base_row_total minus base_discount_amount plus base_tax_amount no longer equals base_grand_total. This is a recurring defect class, tracked across magento2 GitHub issues 8964, 19494, 29506, and 26597, and Adobe Commerce even shipped Quality Patch ACSD-61200 acknowledging it. There is no REST write that safely rewrites a placed order's totals, so the responsible move is to recompute the expected tax from the order's own item data and report every order where it disagrees. Full code, tests, and a dry run guard are below.

The problem in plain words

Every Magento order total is built by a chain of collector models running in sequence: first Subtotal adds up the item rows, then Discount applies any cart price rule coupon, then Tax works out what is owed, then Grand Total adds everything together. Each collector reads the output of the one before it, so the chain only produces a correct order if every collector agrees on what base amount tax should be computed against.

The coupon discount and the tax calculation are supposed to be coordinated by two admin settings: whether tax applies before or after the discount, and whether the discount itself is calculated on tax excluding or tax including prices. When those settings do not match how your catalog prices were entered, or when a cart price rule coupon is combined with tax inclusive catalog prices, the Discount collector reduces the row total using one assumption while the Tax collector recomputes tax_amount from the original, pre discount unit price. The gap between what tax should have been charged on the discounted amount and what the compensation field actually holds is the defect. Nothing on the order looks obviously wrong. The subtotal is right, the discount amount is right, the tax percent is right. It is only the arithmetic across all four fields that fails.

Coupon applied cart price rule Discount collector reduces base_row_total tax collector disagrees Tax collector uses pre discount price Totals do not reconcile base_row_total minus base_discount_amount plus base_tax_amount no longer equals base_grand_total, and every field on its own still looks plausible.
Each collector's output looks fine in isolation. It is the chain of Subtotal, Discount, Tax, and Grand Total together that breaks.

Why it happens

The chain of total collectors, Subtotal, Discount, Tax, then Grand Total, only produces a consistent order when the tax settings and the discount math agree on the same base amount. A few patterns cause them not to:

This is a recurring defect class in Magento 2, not a one off store misconfiguration, tracked across magento2 GitHub issues 8964, 19494, 29506, and 26597 spanning multiple 2.x versions, and Adobe Commerce shipped Quality Patch ACSD-61200 specifically to fix discount tax compensation in sales total calculations. Nothing throws an error. The order saves, the customer pays, and the only trace is that the four totals fields no longer add up. See the citations at the end for the exact issues and the patch.

The key insight

An order's totals are computed once, by the total collectors, at the moment the order is placed. There is no REST endpoint that lets you rewrite base_tax_amount or base_grand_total on an existing order, and there should not be, because an invoice or credit memo may already exist against those exact figures. So the only responsible move is to recompute what the tax should have been from the order's own stored item data, independently, and flag any order where the reconciliation fails, leaving the fix to a human doing finance review or a credit memo plus corrected re-invoice.

The fix, as a flow

For each order, we pull the totals and every item's base_row_total, base_discount_amount, base_discount_tax_compensation_amount, and tax_percent. For each item we compute the taxable base as row total minus discount plus tax compensation, then the expected tax as that base times the tax percent. We sum expected tax per item into an expected order tax, and build an expected grand total from subtotal, discount, expected tax, and shipping. Anything outside a small rounding tolerance gets written to a report, never mutated.

Fetch orders with coupon_code set Read items[] per order row total, discount, tax_percent Compute expected tax and grand total Delta over epsilon? yes Flag order write to report row no Reconciles left alone
The script only ever reports. No order total is rewritten. A confirmed mismatch goes to finance review or a credit memo plus corrected re-invoice.

Build it step by step

1

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 reconciliation epsilon in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export TAX_EPSILON="0.01"
export PAGE_SIZE="100"
export DRY_RUN="true"   # report only, this script never edits an order
setup (shell)
// 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 TAX_EPSILON="0.01"
export PAGE_SIZE="100"
export DRY_RUN="true"   // report only, this script never edits an order
2

List orders that used a coupon

POST to /rest/V1/integration/admin/token for a bearer token, then GET /rest/V1/orders with a searchCriteria filter where the field is coupon_code and the condition type is notnull, paging with currentPage. That narrows the audit to the orders where this defect class can actually happen.

step2.py
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_orders_with_coupon(token, page_size=100, current_page=1):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "coupon_code",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "notnull",
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/orders",
        params=params,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
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 getOrdersWithCoupon(token, pageSize = 100, currentPage = 1) {
  const params = new URLSearchParams({
    "searchCriteria[filterGroups][0][filters][0][field]": "coupon_code",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "notnull",
    "searchCriteria[pageSize]": String(pageSize),
    "searchCriteria[currentPage]": String(currentPage),
  });
  const res = await fetch(`${MAGENTO_URL}/rest/V1/orders?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

Read the totals each order actually reported

Each order in the response carries base_subtotal, base_discount_amount, base_tax_amount, base_shipping_amount, base_shipping_tax_amount, base_shipping_discount_amount, and base_grand_total, plus items[] where each entry carries base_row_total, base_discount_amount, base_discount_tax_compensation_amount, and tax_percent. This is everything the reconciliation needs, already on the order, no extra endpoint required.

step3.py
def to_reconcile_input(order):
    items = []
    for it in order.get("items", []):
        items.append({
            "baseRowTotal": it.get("base_row_total", 0) or 0,
            "baseDiscountAmount": it.get("base_discount_amount", 0) or 0,
            "baseDiscountTaxCompensationAmount": it.get("base_discount_tax_compensation_amount", 0) or 0,
            "taxPercent": it.get("tax_percent", 0) or 0,
        })
    return {
        "baseSubtotal": order.get("base_subtotal", 0) or 0,
        "baseDiscountAmount": order.get("base_discount_amount", 0) or 0,
        "baseTaxAmount": order.get("base_tax_amount", 0) or 0,
        "baseShippingAmount": order.get("base_shipping_amount", 0) or 0,
        "baseShippingTaxAmount": order.get("base_shipping_tax_amount", 0) or 0,
        "baseShippingDiscountAmount": order.get("base_shipping_discount_amount", 0) or 0,
        "baseGrandTotal": order.get("base_grand_total", 0) or 0,
        "items": items,
    }
step3.js
function toReconcileInput(order) {
  const items = (order.items || []).map((it) => ({
    baseRowTotal: it.base_row_total || 0,
    baseDiscountAmount: it.base_discount_amount || 0,
    baseDiscountTaxCompensationAmount: it.base_discount_tax_compensation_amount || 0,
    taxPercent: it.tax_percent || 0,
  }));
  return {
    baseSubtotal: order.base_subtotal || 0,
    baseDiscountAmount: order.base_discount_amount || 0,
    baseTaxAmount: order.base_tax_amount || 0,
    baseShippingAmount: order.base_shipping_amount || 0,
    baseShippingTaxAmount: order.base_shipping_tax_amount || 0,
    baseShippingDiscountAmount: order.base_shipping_discount_amount || 0,
    baseGrandTotal: order.base_grand_total || 0,
    items,
  };
}
4

Decide, with one pure function

Keep the reconciliation math in its own function that takes only plain numbers already pulled from the order JSON, no network and no Magento instance. For each item it computes the taxable base as row total minus discount plus tax compensation, then the expected item tax as that base times the tax percent, rounded to two decimals. It sums expected item tax into an expected order tax, computes an expected grand total, and compares both against what the order actually reported, plus every item's own delta.

reconcile.py
def round2(value):
    return round(value + 1e-9, 2)

def reconcile_order_tax(order, epsilon=0.01):
    per_item_deltas = []
    expected_tax = 0.0
    for item in order.get("items", []):
        taxable_base = (
            item.get("baseRowTotal", 0)
            - item.get("baseDiscountAmount", 0)
            + item.get("baseDiscountTaxCompensationAmount", 0)
        )
        expected_item_tax = round2(taxable_base * item.get("taxPercent", 0) / 100)
        delta = round2(item.get("baseTaxAmount", 0) - expected_item_tax) if "baseTaxAmount" in item else None
        per_item_deltas.append({
            "taxableBase": round2(taxable_base),
            "expectedItemTax": expected_item_tax,
            "delta": delta,
        })
        expected_tax += expected_item_tax
    expected_tax = round2(expected_tax)

    expected_grand_total = round2(
        order.get("baseSubtotal", 0)
        - order.get("baseDiscountAmount", 0)
        + expected_tax
        + order.get("baseShippingAmount", 0)
        + order.get("baseShippingTaxAmount", 0)
        - order.get("baseShippingDiscountAmount", 0)
    )

    tax_delta = round2(order.get("baseTaxAmount", 0) - expected_tax)
    grand_total_delta = round2(order.get("baseGrandTotal", 0) - expected_grand_total)

    ok = abs(tax_delta) <= epsilon and abs(grand_total_delta) <= epsilon
    for d in per_item_deltas:
        if d["delta"] is not None and abs(d["delta"]) > epsilon:
            ok = False

    return {
        "ok": ok,
        "expectedTax": expected_tax,
        "expectedGrandTotal": expected_grand_total,
        "taxDelta": tax_delta,
        "grandTotalDelta": grand_total_delta,
        "perItemDeltas": per_item_deltas,
    }
reconcile.js
export function round2(value) {
  return Math.round((value + Number.EPSILON) * 100) / 100;
}

export function reconcileOrderTax(order, epsilon = 0.01) {
  const perItemDeltas = [];
  let expectedTax = 0;

  for (const item of order.items || []) {
    const taxableBase = item.baseRowTotal - item.baseDiscountAmount + item.baseDiscountTaxCompensationAmount;
    const expectedItemTax = round2((taxableBase * item.taxPercent) / 100);
    const delta = "baseTaxAmount" in item ? round2(item.baseTaxAmount - expectedItemTax) : null;
    perItemDeltas.push({ taxableBase: round2(taxableBase), expectedItemTax, delta });
    expectedTax += expectedItemTax;
  }
  expectedTax = round2(expectedTax);

  const expectedGrandTotal = round2(
    order.baseSubtotal - order.baseDiscountAmount + expectedTax +
    order.baseShippingAmount + order.baseShippingTaxAmount - order.baseShippingDiscountAmount
  );

  const taxDelta = round2(order.baseTaxAmount - expectedTax);
  const grandTotalDelta = round2(order.baseGrandTotal - expectedGrandTotal);

  let ok = Math.abs(taxDelta) <= epsilon && Math.abs(grandTotalDelta) <= epsilon;
  for (const d of perItemDeltas) {
    if (d.delta !== null && Math.abs(d.delta) > epsilon) ok = false;
  }

  return { ok, expectedTax, expectedGrandTotal, taxDelta, grandTotalDelta, perItemDeltas };
}
5

There is no REST write for this, write a report instead

Order totals are computed once, by the total collector chain, at placement time, and are not writable through PUT on /rest/V1/orders/{id}. When a mismatch is confirmed, the script records the order id, increment id, coupon code, expected versus actual tax and grand total, and the delta, for manual finance review or admin reprocessing through a credit memo plus corrected re-invoice. It never attempts to write the order.

report.py
def build_report_row(order_raw, result):
    return {
        "order_id": order_raw.get("entity_id"),
        "increment_id": order_raw.get("increment_id"),
        "coupon_code": order_raw.get("coupon_code"),
        "expected_tax": result["expectedTax"],
        "actual_tax": order_raw.get("base_tax_amount", 0) or 0,
        "tax_delta": result["taxDelta"],
        "expected_grand_total": result["expectedGrandTotal"],
        "actual_grand_total": order_raw.get("base_grand_total", 0) or 0,
        "grand_total_delta": result["grandTotalDelta"],
    }
report.js
function buildReportRow(orderRaw, result) {
  return {
    order_id: orderRaw.entity_id,
    increment_id: orderRaw.increment_id,
    coupon_code: orderRaw.coupon_code,
    expected_tax: result.expectedTax,
    actual_tax: orderRaw.base_tax_amount || 0,
    tax_delta: result.taxDelta,
    expected_grand_total: result.expectedGrandTotal,
    actual_grand_total: orderRaw.base_grand_total || 0,
    grand_total_delta: result.grandTotalDelta,
  };
}
6

Wire it together with a dry run guard

The loop authenticates once, pages through every order that carries a coupon code, runs the pure reconciliation function per order, and writes a report row for anything over epsilon. DRY_RUN defaults to true and, in this script, dry run is the only mode: there is no write path to a placed order's totals, so the flag exists to make that explicit and to gate whether a CSV is written to disk versus only logged.

Run it safe

This script never edits an existing order, because Magento has no endpoint for that. It reports the order id, increment id, coupon code, expected versus actual tax and grand total, and the delta, so finance can review it and decide whether to reprocess through a credit memo plus corrected re-invoice. If the tax settings are the ongoing root cause, the durable fix is changing Apply Customer Tax and Apply Discount on Prices under Stores, Configuration, Sales, Tax, Calculation Settings to a consistent combination, an admin panel or CLI change outside the REST order write surface.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever performs reads, never a write to an order's totals.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
reconcile_coupon_tax.py
"""Flag Magento 2 or Adobe Commerce orders whose tax was recalculated
incorrectly after a coupon was applied.

Magento builds order totals through a chain of total collector models:
Subtotal, then Discount, then Tax, then Grand Total. Whether that chain
reconciles depends on Sales, Tax, Calculation Settings for Apply Customer Tax
(Before Discount or After Discount) and Apply Discount on Prices (Excluding
Tax or Including Tax). When those settings disagree with how catalog prices
are entered, or a cart price rule coupon meets tax inclusive catalog prices,
the discount collector reduces the row total using one base while the tax
collector recomputes tax_amount from the pre discount unit price, so
discount_tax_compensation_amount ends up wrong or zero and base_row_total
minus base_discount_amount plus base_tax_amount no longer equals
base_grand_total. This is a recurring defect class, seen across magento2
GitHub issues 8964, 19494, 29506, and 26597, and Adobe Commerce shipped
Quality Patch ACSD-61200 for discount tax compensation specifically.

This script never edits an order, since Magento has no supported REST write
for a placed order's totals. It recomputes the expected tax and grand total
from the order's own item data and writes a reconciliation report. Safe to
run again and again.
"""
import os
import csv
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_coupon_tax")

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")
TAX_EPSILON = float(os.environ.get("TAX_EPSILON", "0.01"))
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "100"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OUTPUT_CSV = os.environ.get("OUTPUT_CSV", "coupon_tax_mismatches.csv")


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_orders_with_coupon(token, page_size=PAGE_SIZE, current_page=1):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "coupon_code",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "notnull",
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/orders",
        params=params,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def to_reconcile_input(order):
    items = []
    for it in order.get("items", []):
        items.append({
            "baseRowTotal": it.get("base_row_total", 0) or 0,
            "baseDiscountAmount": it.get("base_discount_amount", 0) or 0,
            "baseDiscountTaxCompensationAmount": it.get("base_discount_tax_compensation_amount", 0) or 0,
            "taxPercent": it.get("tax_percent", 0) or 0,
            "baseTaxAmount": it.get("base_tax_amount", 0) or 0,
        })
    return {
        "baseSubtotal": order.get("base_subtotal", 0) or 0,
        "baseDiscountAmount": order.get("base_discount_amount", 0) or 0,
        "baseTaxAmount": order.get("base_tax_amount", 0) or 0,
        "baseShippingAmount": order.get("base_shipping_amount", 0) or 0,
        "baseShippingTaxAmount": order.get("base_shipping_tax_amount", 0) or 0,
        "baseShippingDiscountAmount": order.get("base_shipping_discount_amount", 0) or 0,
        "baseGrandTotal": order.get("base_grand_total", 0) or 0,
        "items": items,
    }


def round2(value):
    return round(value + 1e-9, 2)


def reconcile_order_tax(order, epsilon=TAX_EPSILON):
    per_item_deltas = []
    expected_tax = 0.0
    for item in order.get("items", []):
        taxable_base = (
            item.get("baseRowTotal", 0)
            - item.get("baseDiscountAmount", 0)
            + item.get("baseDiscountTaxCompensationAmount", 0)
        )
        expected_item_tax = round2(taxable_base * item.get("taxPercent", 0) / 100)
        delta = round2(item.get("baseTaxAmount", 0) - expected_item_tax) if "baseTaxAmount" in item else None
        per_item_deltas.append({
            "taxableBase": round2(taxable_base),
            "expectedItemTax": expected_item_tax,
            "delta": delta,
        })
        expected_tax += expected_item_tax
    expected_tax = round2(expected_tax)

    expected_grand_total = round2(
        order.get("baseSubtotal", 0)
        - order.get("baseDiscountAmount", 0)
        + expected_tax
        + order.get("baseShippingAmount", 0)
        + order.get("baseShippingTaxAmount", 0)
        - order.get("baseShippingDiscountAmount", 0)
    )

    tax_delta = round2(order.get("baseTaxAmount", 0) - expected_tax)
    grand_total_delta = round2(order.get("baseGrandTotal", 0) - expected_grand_total)

    ok = abs(tax_delta) <= epsilon and abs(grand_total_delta) <= epsilon
    for d in per_item_deltas:
        if d["delta"] is not None and abs(d["delta"]) > epsilon:
            ok = False

    return {
        "ok": ok,
        "expectedTax": expected_tax,
        "expectedGrandTotal": expected_grand_total,
        "taxDelta": tax_delta,
        "grandTotalDelta": grand_total_delta,
        "perItemDeltas": per_item_deltas,
    }


def build_report_row(order_raw, result):
    return {
        "order_id": order_raw.get("entity_id"),
        "increment_id": order_raw.get("increment_id"),
        "coupon_code": order_raw.get("coupon_code"),
        "expected_tax": result["expectedTax"],
        "actual_tax": order_raw.get("base_tax_amount", 0) or 0,
        "tax_delta": result["taxDelta"],
        "expected_grand_total": result["expectedGrandTotal"],
        "actual_grand_total": order_raw.get("base_grand_total", 0) or 0,
        "grand_total_delta": result["grandTotalDelta"],
    }


def all_orders_with_coupon(token):
    current_page = 1
    while True:
        data = get_orders_with_coupon(token, PAGE_SIZE, current_page)
        items = data.get("items", [])
        for order in items:
            yield order
        total = data.get("total_count", 0)
        if current_page * PAGE_SIZE >= total or not items:
            return
        current_page += 1


def run():
    token = get_token()
    flagged = []

    for order_raw in all_orders_with_coupon(token):
        reconcile_input = to_reconcile_input(order_raw)
        result = reconcile_order_tax(reconcile_input, TAX_EPSILON)
        if result["ok"]:
            continue

        row = build_report_row(order_raw, result)
        flagged.append(row)
        log.warning(
            "Order %s coupon %s: expected_tax=%s actual_tax=%s tax_delta=%s grand_total_delta=%s",
            row["increment_id"], row["coupon_code"], row["expected_tax"],
            row["actual_tax"], row["tax_delta"], row["grand_total_delta"],
        )

    if flagged and not DRY_RUN:
        with open(OUTPUT_CSV, "w", newline="") as fh:
            writer = csv.DictWriter(fh, fieldnames=[
                "order_id", "increment_id", "coupon_code",
                "expected_tax", "actual_tax", "tax_delta",
                "expected_grand_total", "actual_grand_total", "grand_total_delta",
            ])
            writer.writeheader()
            writer.writerows(flagged)

    log.info("Done. %d order(s) flagged for manual finance review, %s.", len(flagged),
              "dry run, nothing written" if DRY_RUN else f"report written to {OUTPUT_CSV}")
    return flagged


if __name__ == "__main__":
    run()
reconcile-coupon-tax.js
/**
 * Flag Magento 2 or Adobe Commerce orders whose tax was recalculated
 * incorrectly after a coupon was applied.
 *
 * Magento builds order totals through a chain of total collector models:
 * Subtotal, then Discount, then Tax, then Grand Total. Whether that chain
 * reconciles depends on Sales, Tax, Calculation Settings for Apply Customer
 * Tax (Before Discount or After Discount) and Apply Discount on Prices
 * (Excluding Tax or Including Tax). When those settings disagree with how
 * catalog prices are entered, or a cart price rule coupon meets tax
 * inclusive catalog prices, the discount collector reduces the row total
 * using one base while the tax collector recomputes tax_amount from the
 * pre discount unit price, so discount_tax_compensation_amount ends up
 * wrong or zero and base_row_total minus base_discount_amount plus
 * base_tax_amount no longer equals base_grand_total. This is a recurring
 * defect class, seen across magento2 GitHub issues 8964, 19494, 29506, and
 * 26597, and Adobe Commerce shipped Quality Patch ACSD-61200 for discount
 * tax compensation specifically.
 *
 * This script never edits an order, since Magento has no supported REST
 * write for a placed order's totals. It recomputes the expected tax and
 * grand total from the order's own item data and writes a reconciliation
 * report. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/tax-wrong-after-coupon-applied/
 */
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 TAX_EPSILON = Number(process.env.TAX_EPSILON || 0.01);
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 100);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function round2(value) {
  return Math.round((value + Number.EPSILON) * 100) / 100;
}

export function reconcileOrderTax(order, epsilon = TAX_EPSILON) {
  const perItemDeltas = [];
  let expectedTax = 0;

  for (const item of order.items || []) {
    const taxableBase = item.baseRowTotal - item.baseDiscountAmount + item.baseDiscountTaxCompensationAmount;
    const expectedItemTax = round2((taxableBase * item.taxPercent) / 100);
    const delta = "baseTaxAmount" in item ? round2(item.baseTaxAmount - expectedItemTax) : null;
    perItemDeltas.push({ taxableBase: round2(taxableBase), expectedItemTax, delta });
    expectedTax += expectedItemTax;
  }
  expectedTax = round2(expectedTax);

  const expectedGrandTotal = round2(
    order.baseSubtotal - order.baseDiscountAmount + expectedTax +
    order.baseShippingAmount + order.baseShippingTaxAmount - order.baseShippingDiscountAmount
  );

  const taxDelta = round2(order.baseTaxAmount - expectedTax);
  const grandTotalDelta = round2(order.baseGrandTotal - expectedGrandTotal);

  let ok = Math.abs(taxDelta) <= epsilon && Math.abs(grandTotalDelta) <= epsilon;
  for (const d of perItemDeltas) {
    if (d.delta !== null && Math.abs(d.delta) > epsilon) ok = false;
  }

  return { ok, expectedTax, expectedGrandTotal, taxDelta, grandTotalDelta, perItemDeltas };
}

function toReconcileInput(order) {
  const items = (order.items || []).map((it) => ({
    baseRowTotal: it.base_row_total || 0,
    baseDiscountAmount: it.base_discount_amount || 0,
    baseDiscountTaxCompensationAmount: it.base_discount_tax_compensation_amount || 0,
    taxPercent: it.tax_percent || 0,
    baseTaxAmount: it.base_tax_amount || 0,
  }));
  return {
    baseSubtotal: order.base_subtotal || 0,
    baseDiscountAmount: order.base_discount_amount || 0,
    baseTaxAmount: order.base_tax_amount || 0,
    baseShippingAmount: order.base_shipping_amount || 0,
    baseShippingTaxAmount: order.base_shipping_tax_amount || 0,
    baseShippingDiscountAmount: order.base_shipping_discount_amount || 0,
    baseGrandTotal: order.base_grand_total || 0,
    items,
  };
}

function buildReportRow(orderRaw, result) {
  return {
    order_id: orderRaw.entity_id,
    increment_id: orderRaw.increment_id,
    coupon_code: orderRaw.coupon_code,
    expected_tax: result.expectedTax,
    actual_tax: orderRaw.base_tax_amount || 0,
    tax_delta: result.taxDelta,
    expected_grand_total: result.expectedGrandTotal,
    actual_grand_total: orderRaw.base_grand_total || 0,
    grand_total_delta: result.grandTotalDelta,
  };
}

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 getOrdersWithCoupon(token, pageSize = PAGE_SIZE, currentPage = 1) {
  const params = new URLSearchParams({
    "searchCriteria[filterGroups][0][filters][0][field]": "coupon_code",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "notnull",
    "searchCriteria[pageSize]": String(pageSize),
    "searchCriteria[currentPage]": String(currentPage),
  });
  const res = await fetch(`${MAGENTO_URL}/rest/V1/orders?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function* allOrdersWithCoupon(token) {
  let currentPage = 1;
  while (true) {
    const data = await getOrdersWithCoupon(token, PAGE_SIZE, currentPage);
    const items = data.items || [];
    for (const order of items) yield order;
    const total = data.total_count || 0;
    if (currentPage * PAGE_SIZE >= total || !items.length) return;
    currentPage++;
  }
}

export async function run() {
  const token = await getToken();
  const flagged = [];

  for await (const orderRaw of allOrdersWithCoupon(token)) {
    const reconcileInput = toReconcileInput(orderRaw);
    const result = reconcileOrderTax(reconcileInput, TAX_EPSILON);
    if (result.ok) continue;

    const row = buildReportRow(orderRaw, result);
    flagged.push(row);
    console.warn(`Order ${row.increment_id} coupon ${row.coupon_code}: expected_tax=${row.expected_tax} actual_tax=${row.actual_tax} tax_delta=${row.tax_delta} grand_total_delta=${row.grand_total_delta}`);
  }

  console.log(`Done. ${flagged.length} order(s) flagged for manual finance review, ${DRY_RUN ? "dry run, nothing written" : "report ready"}.`);
  return flagged;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The reconciliation rule is the part most worth testing, because it decides whether an order gets flagged for finance review. Since reconcile_order_tax and reconcileOrderTax are pure, no network and no Magento instance are needed. The tests feed in plain numbers covering an order with no coupon, a percentage coupon, a fixed amount coupon, and a tax inclusive price order where the compensation field is missing entirely.

test_coupon_tax_reconcile.py
from reconcile_coupon_tax import reconcile_order_tax


def base_order(**over):
    order = {
        "baseSubtotal": 100.0,
        "baseDiscountAmount": 0.0,
        "baseTaxAmount": 10.0,
        "baseShippingAmount": 0.0,
        "baseShippingTaxAmount": 0.0,
        "baseShippingDiscountAmount": 0.0,
        "baseGrandTotal": 110.0,
        "items": [{
            "baseRowTotal": 100.0,
            "baseDiscountAmount": 0.0,
            "baseDiscountTaxCompensationAmount": 0.0,
            "taxPercent": 10.0,
            "baseTaxAmount": 10.0,
        }],
    }
    order.update(over)
    return order


def test_no_coupon_order_reconciles():
    result = reconcile_order_tax(base_order())
    assert result["ok"] is True
    assert result["expectedTax"] == 10.0
    assert result["taxDelta"] == 0.0


def test_percentage_coupon_with_correct_compensation_reconciles():
    # 20% off a 100 row, tax compensation correctly reduces the taxable base
    order = base_order(
        baseDiscountAmount=20.0,
        baseTaxAmount=8.0,
        baseGrandTotal=88.0,
        items=[{
            "baseRowTotal": 100.0,
            "baseDiscountAmount": 20.0,
            "baseDiscountTaxCompensationAmount": 0.0,
            "taxPercent": 10.0,
            "baseTaxAmount": 8.0,
        }],
    )
    result = reconcile_order_tax(order)
    assert result["ok"] is True
    assert result["expectedTax"] == 8.0


def test_fixed_amount_coupon_bug_leaves_tax_on_pre_discount_base():
    # discount collector reduced the row, but tax collector still taxed the
    # full pre discount 100 instead of the discounted 90
    order = base_order(
        baseDiscountAmount=10.0,
        baseTaxAmount=10.0,
        baseGrandTotal=100.0,
        items=[{
            "baseRowTotal": 100.0,
            "baseDiscountAmount": 10.0,
            "baseDiscountTaxCompensationAmount": 0.0,
            "taxPercent": 10.0,
            "baseTaxAmount": 10.0,
        }],
    )
    result = reconcile_order_tax(order)
    assert result["ok"] is False
    assert result["expectedTax"] == 9.0
    assert result["taxDelta"] == 1.0


def test_tax_inclusive_price_order_with_missing_compensation_is_flagged():
    order = base_order(
        baseDiscountAmount=15.0,
        baseTaxAmount=10.0,
        baseGrandTotal=95.0,
        items=[{
            "baseRowTotal": 100.0,
            "baseDiscountAmount": 15.0,
            "baseDiscountTaxCompensationAmount": 0.0,
            "taxPercent": 10.0,
            "baseTaxAmount": 10.0,
        }],
    )
    result = reconcile_order_tax(order)
    assert result["ok"] is False
    assert result["expectedTax"] == 8.5


def test_within_epsilon_is_ok():
    order = base_order(baseTaxAmount=10.004, baseGrandTotal=110.004)
    result = reconcile_order_tax(order, epsilon=0.01)
    assert result["ok"] is True


def test_per_item_deltas_reported():
    order = base_order(
        items=[{
            "baseRowTotal": 100.0,
            "baseDiscountAmount": 10.0,
            "baseDiscountTaxCompensationAmount": 0.0,
            "taxPercent": 10.0,
            "baseTaxAmount": 10.0,
        }],
        baseDiscountAmount=10.0,
    )
    result = reconcile_order_tax(order)
    assert result["perItemDeltas"][0]["expectedItemTax"] == 9.0
    assert result["perItemDeltas"][0]["delta"] == 1.0
reconcile.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { reconcileOrderTax } from "./reconcile-coupon-tax.js";

function baseOrder(over = {}) {
  const order = {
    baseSubtotal: 100.0,
    baseDiscountAmount: 0.0,
    baseTaxAmount: 10.0,
    baseShippingAmount: 0.0,
    baseShippingTaxAmount: 0.0,
    baseShippingDiscountAmount: 0.0,
    baseGrandTotal: 110.0,
    items: [{
      baseRowTotal: 100.0,
      baseDiscountAmount: 0.0,
      baseDiscountTaxCompensationAmount: 0.0,
      taxPercent: 10.0,
      baseTaxAmount: 10.0,
    }],
    ...over,
  };
  return order;
}

test("no coupon order reconciles", () => {
  const result = reconcileOrderTax(baseOrder());
  assert.equal(result.ok, true);
  assert.equal(result.expectedTax, 10.0);
  assert.equal(result.taxDelta, 0.0);
});

test("percentage coupon with correct compensation reconciles", () => {
  const order = baseOrder({
    baseDiscountAmount: 20.0,
    baseTaxAmount: 8.0,
    baseGrandTotal: 88.0,
    items: [{
      baseRowTotal: 100.0,
      baseDiscountAmount: 20.0,
      baseDiscountTaxCompensationAmount: 0.0,
      taxPercent: 10.0,
      baseTaxAmount: 8.0,
    }],
  });
  const result = reconcileOrderTax(order);
  assert.equal(result.ok, true);
  assert.equal(result.expectedTax, 8.0);
});

test("fixed amount coupon bug leaves tax on pre discount base", () => {
  const order = baseOrder({
    baseDiscountAmount: 10.0,
    baseTaxAmount: 10.0,
    baseGrandTotal: 100.0,
    items: [{
      baseRowTotal: 100.0,
      baseDiscountAmount: 10.0,
      baseDiscountTaxCompensationAmount: 0.0,
      taxPercent: 10.0,
      baseTaxAmount: 10.0,
    }],
  });
  const result = reconcileOrderTax(order);
  assert.equal(result.ok, false);
  assert.equal(result.expectedTax, 9.0);
  assert.equal(result.taxDelta, 1.0);
});

test("tax inclusive price order with missing compensation is flagged", () => {
  const order = baseOrder({
    baseDiscountAmount: 15.0,
    baseTaxAmount: 10.0,
    baseGrandTotal: 95.0,
    items: [{
      baseRowTotal: 100.0,
      baseDiscountAmount: 15.0,
      baseDiscountTaxCompensationAmount: 0.0,
      taxPercent: 10.0,
      baseTaxAmount: 10.0,
    }],
  });
  const result = reconcileOrderTax(order);
  assert.equal(result.ok, false);
  assert.equal(result.expectedTax, 8.5);
});

test("within epsilon is ok", () => {
  const order = baseOrder({ baseTaxAmount: 10.004, baseGrandTotal: 110.004 });
  const result = reconcileOrderTax(order, 0.01);
  assert.equal(result.ok, true);
});

test("per item deltas reported", () => {
  const order = baseOrder({
    items: [{
      baseRowTotal: 100.0,
      baseDiscountAmount: 10.0,
      baseDiscountTaxCompensationAmount: 0.0,
      taxPercent: 10.0,
      baseTaxAmount: 10.0,
    }],
    baseDiscountAmount: 10.0,
  });
  const result = reconcileOrderTax(order);
  assert.equal(result.perItemDeltas[0].expectedItemTax, 9.0);
  assert.equal(result.perItemDeltas[0].delta, 1.0);
});

Case studies

Tax inclusive catalog

A European store where the grand total quietly drifted for months

A store selling tax inclusive prices across the EU ran a sitewide percentage coupon for a seasonal sale. Apply Discount on Prices was set to Excluding Tax, which did not match how the catalog prices were actually entered. Every order that used the coupon kept a plausible looking tax line, but the four totals fields never quite added up.

Nobody noticed until the finance team reconciled a quarter's worth of VAT filings against Magento's reported tax collected and came up short by a few percent. Running the audit script against every coupon order isolated exactly which ones disagreed and by how much, which let finance correct the filings without touching a single order record.

Fixed amount coupon

A fixed 10 off coupon that undercharged tax on thousands of orders

A US retailer ran a flat 10 dollar off coupon for a year. Apply Customer Tax was set to Before Discount, so the tax collector kept computing tax on the pre discount unit price instead of the post discount amount the customer actually paid tax on. Each individual order's shortfall was small, a dollar or so, so nobody flagged it during checkout QA.

At the scale of thousands of coupon orders per quarter, the shortfall in reported tax collected became a real number a sales tax auditor would ask about. The reconciliation report gave the team the exact order ids and deltas needed to work with their accountant on corrected filings, while the underlying tax settings were changed going forward to a consistent combination.

What good looks like

After running this on a schedule, a coupon that throws off tax stops being something finance discovers during a filing deadline. You get a dated report of the order id, increment id, coupon code, expected versus actual tax and grand total, and the delta for every order that disagrees with its own item data. No order is ever rewritten, since Magento has no supported way to do that safely after the fact, and any correction happens through a credit memo plus corrected re-invoice, or through fixing the tax settings so new orders stop drifting.

FAQ

Why does applying a coupon throw off the tax on a Magento order?

Magento computes order totals through a chain of collectors: Subtotal, then Discount, then Tax, then Grand Total. The tax collector's result depends on the Sales, Tax, Calculation Settings for Apply Customer Tax and Apply Discount on Prices. When those settings do not match how catalog prices are entered, or a cart price rule coupon meets tax inclusive prices, the discount collector reduces the row total but the tax collector still uses the pre discount unit price, so discount_tax_compensation_amount ends up wrong or zero and the order's totals stop reconciling.

Can I fix a wrong order tax total through the REST API?

No, not after the order is placed. Magento's total collectors run once at order placement and their output fields on the order and its items are not writable through PUT on /rest/V1/orders/{id}. Recomputing and overwriting them after the fact also risks desyncing any invoice or credit memo already issued against the original amounts, so the safe response is to detect and report the mismatch for manual finance review or a credit memo plus corrected re-invoice, not to patch the order.

How do I detect which orders have the wrong tax after a coupon?

For each item, compute the taxable base as base_row_total minus base_discount_amount plus base_discount_tax_compensation_amount, then the expected item tax as that base times tax_percent over 100. Sum the expected item tax into an expected order tax, and compute an expected grand total from base_subtotal, base_discount_amount, expected tax, and shipping. Flag any order where the reported base_tax_amount or base_grand_total differs from the expected value by more than a small epsilon, such as 0.01.

Related field notes

Citations

On the problem:

  1. Wrong tax calculation after applying discount coupon in Magento Open Source v2.3.4, magento2 issue 29506. github.com/magento/magento2/issues/29506
  2. Tax Calculation is going wrong after applying coupon code in magento 2, magento2 issue 19494. github.com/magento/magento2/issues/19494
  3. Tax calculation wrong after applying coupon code (Major Bug), magento2 issue 8964. github.com/magento/magento2/issues/8964

On the solution:

  1. ACSD-61200: Fixes discount tax compensation in sales total calculations, Adobe Commerce Quality Patches Tool. experienceleague.adobe.com quality-patches-tool acsd-61200
  2. Tax configuration settings, Adobe Commerce. experienceleague.adobe.com commerce-admin tax-settings-general
  3. REST API reference, Adobe Commerce Developer. developer.adobe.com commerce/webapi/rest/reference

Stuck on a tricky one?

If you have a problem in Magento orders, pricing, tax, or reconciliation that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this untangle your coupon tax?

If this saved you a confusing reconciliation or a wrong tax filing, 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

Back to all Magento field notes