Skip to content

Diagnostic Credit Memos and Refunds

Partial refund tax computed from the full order instead of the refunded items

A customer returns two of the five items they ordered, and the credit memo should refund tax on those two items only. Instead the credit memo's tax total matches the order's entire tax, as if all five had been returned. Nobody notices until accounting reconciles refunds against sales tax filings and the numbers do not add up. This is a real, repeatedly reported defect in Magento's credit memo tax collector, not a one off store bug, and here is why it happens and a script that finds every credit memo it hit.

Python and Node.js Adobe Commerce REST API Safe by default (report only)
Holding a card and a phone
Photo by Nathana Reboucas on Unsplash
The short answer

Magento's credit memo tax totals collector, Magento\Sales\Model\Order\Creditmemo\Total\Tax, is supposed to prorate tax per line item using the quantity being refunded versus the quantity invoiced. Several long standing bugs, tracked across magento2 GitHub issues #8797, #9929, #10982, #14713, #23938, #32222, and #34586, instead cause it to copy the order's full tax_amount and base_tax_amount onto the credit memo, most often when the credit memo is created from the admin order view rather than the invoice view, when multiple partial credit memos are issued against the same invoice, or when the store's display currency differs from the base currency. Because CreditmemoItemInterface.tax_amount is a calculated snapshot stored at creation time, not something re-derived later, the wrong number sits there permanently. There is no supported REST write to fix an existing credit memo, so the safe move is to audit every order and its credit memos, compute the expected proportional tax yourself, and flag any credit memo whose base_tax_amount does not match, especially the tell tale case where a partial refund carries a full order's worth of tax. Full code, tests, and a dry run guard are below.

The problem in plain words

When a customer returns part of an order, say two units out of five, the credit memo is supposed to refund a proportional slice of everything: the item price for two units, the shipping share if any, and the tax that applied to those two units. Magento has a totals collector built specifically for this job, and on the happy path it does exactly that.

But the collector has a habit of falling back to the order's total tax instead of prorating it. The credit memo item still correctly shows a quantity of two out of five ordered, so at a glance nothing looks broken. It is only when you compare the credit memo's base_tax_amount to what two fifths of the order's tax should be that the mismatch shows up, and by then the credit memo has already been issued, the refund has already gone out, and the document is treated as a closed financial record.

Refund 2 of 5 partial credit memo Creditmemo Total Tax should prorate 2/5 of tax collector bug, copies full tax base_tax_amount equals order's full tax Books do not match tax filings The credit memo item still shows qty 2 of 5. Only the tax total is wrong, and it is invisible at a glance.
The credit memo quantities look correct. The tax total collector is what silently reverts to the order's full tax instead of a proportional share.

Why it happens

The design intent is sound: prorate OrderItemInterface.tax_amount by qty_refunded / qty_ordered for each line, then sum. A rounding or collector regression instead makes the credit memo copy the order's tax as a whole. This has been reported repeatedly across multiple 2.x versions and patch levels, and a few patterns show up over and over:

None of this throws a visible error. The credit memo saves, the refund posts, the customer gets their money back, and the only trace of the problem is a tax number that is bigger than it should be for that quantity. See the citations at the end for the exact GitHub issues that document this across versions.

The key insight

A credit memo is an immutable accounting document once it exists. There is no supported way to PATCH its stored tax_amount, and trying to would undermine the whole point of a financial record. So the only responsible move is to detect the mismatch independently, by recomputing what the tax should have been from the order's own item data, and report it with enough detail that a human can decide the next step, which may be a corrective adjustment on a new refund call, never an edit to the old one.

The fix, as a flow

For each order we care about, we read the order items' tax_amount and qty_ordered, then pull every credit memo issued against that order. For each credit memo we recompute the expected tax per refunded line as order_item.tax_amount * (creditmemo_item.qty / order_item.qty_ordered), sum it, and compare that expected total to the credit memo's actual base_tax_amount. Anything outside a small rounding tolerance gets written to a report, never mutated.

Fetch order items[] tax_amount, qty Fetch credit memos for this order_id Compute expected tax * qty/qty_ordered Delta over epsilon? yes Flag creditmemo write to report row no Matched left alone
The script only ever reports. The original credit memo is never touched. A corrective adjustment, if chosen, goes through a brand new refund call.

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 list of order ids to audit 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 ORDER_IDS="1001,1002,1003"
export TAX_EPSILON="0.01"
export DRY_RUN="true"   # report only, this script never edits a creditmemo
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 ORDER_IDS="1001,1002,1003"
export TAX_EPSILON="0.01"
export DRY_RUN="true"   // report only, this script never edits a creditmemo
2

Read the order's items and tax

POST to /rest/V1/integration/admin/token for a bearer token, then GET /rest/V1/orders/{orderId}. Keep the order's base_tax_amount and each item's item_id, tax_amount, and qty_ordered, since those are the independent baseline every credit memo gets checked against.

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_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()
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 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();
}
3

List the credit memos issued against that order

GET /rest/V1/creditmemo with a searchCriteria filter on order_id. Each result carries items[] with order_item_id and qty, plus the credit memo's own base_tax_amount, which is exactly the number we need to check.

step3.py
def get_creditmemos_for_order(token, order_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
        "searchCriteria[filterGroups][0][filters][0][value]": order_id,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[pageSize]": 100,
    }
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/creditmemo",
        params=params,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json().get("items", [])
step3.js
async function getCreditmemosForOrder(token, orderId) {
  const params = new URLSearchParams({
    "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
    "searchCriteria[filterGroups][0][filters][0][value]": orderId,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[pageSize]": "100",
  });
  const res = await fetch(`${MAGENTO_URL}/rest/V1/creditmemo?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  const body = await res.json();
  return body.items || [];
}
4

Decide, with one pure function

Keep the tax math in its own function that takes only plain numbers already pulled from the order and credit memo JSON: the order item's tax amount, its ordered quantity, the refunded quantity on this credit memo line, and the credit memo's reported tax total. It has no I/O, so it is trivial to test with fixed inputs, and it is the same function whether you call it once per line or sum across every refunded line on a credit memo.

mismatch.py
def is_creditmemo_tax_mismatched(order_item_tax_amount, order_item_qty_ordered,
                                  creditmemo_item_qty, creditmemo_base_tax_amount, epsilon=0.01):
    if not order_item_qty_ordered:
        expected_tax = 0.0
    else:
        expected_tax = order_item_tax_amount * (creditmemo_item_qty / order_item_qty_ordered)
    delta = creditmemo_base_tax_amount - expected_tax
    return {
        "expectedTax": expected_tax,
        "delta": delta,
        "mismatched": abs(delta) > epsilon,
    }
mismatch.js
export function isCreditMemoTaxMismatched(orderItemTaxAmount, orderItemQtyOrdered, creditMemoItemQty, creditMemoBaseTaxAmount, epsilon = 0.01) {
  const expectedTax = orderItemQtyOrdered
    ? orderItemTaxAmount * (creditMemoItemQty / orderItemQtyOrdered)
    : 0;
  const delta = creditMemoBaseTaxAmount - expectedTax;
  return { expectedTax, delta, mismatched: Math.abs(delta) > epsilon };
}
5

There is no REST write for this, print the adjustment payload instead

Creditmemo totals are immutable once created, and there is no PATCH endpoint for them. When a mismatch is confirmed, the safe corrective path is a brand new refund call carrying arguments.adjustment_positive (to refund the missing tax) or arguments.adjustment_negative (to claw back over refunded tax) against /rest/V1/order/{orderId}/refund or /rest/V1/invoice/{invoiceId}/refund. The script only prints this payload in dry run, and only calls it when DRY_RUN=false is explicit.

adjustment.py
def build_adjustment_payload(delta):
    if delta > 0:
        return {"arguments": {"adjustment_negative": round(delta, 2)}}
    return {"arguments": {"adjustment_positive": round(abs(delta), 2)}}

def apply_adjustment(token, order_id, delta):
    payload = build_adjustment_payload(delta)
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/order/{order_id}/refund",
        json=payload,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
adjustment.js
function buildAdjustmentPayload(delta) {
  if (delta > 0) return { arguments: { adjustment_negative: Math.round(delta * 100) / 100 } };
  return { arguments: { adjustment_positive: Math.round(Math.abs(delta) * 100) / 100 } };
}

async function applyAdjustment(token, orderId, delta) {
  const payload = buildAdjustmentPayload(delta);
  const res = await fetch(`${MAGENTO_URL}/rest/V1/order/${orderId}/refund`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
6

Wire it together with a dry run guard

The loop authenticates once, walks every configured order id, pulls its items and credit memos, runs the pure mismatch function per refunded line, sums the expected tax per credit memo, and writes a report row for anything over epsilon. DRY_RUN defaults to true. In dry run the script only prints the proposed adjustment payload; it calls the refund endpoint only when a caller explicitly sets DRY_RUN=false.

Run it safe

This script never edits an existing credit memo, because Magento has no endpoint for that. It reports the order increment id, the credit memo increment id, the expected tax, the actual tax, and the delta so a human can review it, then, only under an explicit DRY_RUN=false, it may issue a new corrective refund call. The original document is never touched.

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 plus, optionally, a new corrective refund call, never an edit to the original credit memo.

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.
flag_creditmemo_tax_mismatch.py
"""Flag Magento 2 or Adobe Commerce credit memos whose tax was computed from the
full order instead of the refunded items.

Magento's credit memo tax totals collector is supposed to prorate tax per line
item using qty being refunded versus qty invoiced. Long standing bugs, seen
across magento2 GitHub issues 8797, 9929, 10982, 14713, 23938, 32222, and
34586, instead cause it to copy the order's full tax_amount and
base_tax_amount onto the credit memo, notably when the credit memo is created
from the admin order view, when multiple partial credit memos are issued
against the same invoice, or when the display currency differs from the base
currency. CreditmemoItemInterface.tax_amount is a snapshot stored at creation
time, never re-derived later, so a wrong number stays wrong forever.

This script never edits an existing credit memo, since Magento has no
supported REST write for that. It recomputes the expected proportional tax
from the order's own item data, compares it to each credit memo's reported
base_tax_amount, and writes a reconciliation report. Only under an explicit
DRY_RUN=false does it optionally POST a new corrective refund call carrying
an adjustment_positive or adjustment_negative argument. In dry run it only
prints the proposed payload. 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("flag_creditmemo_tax_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()]
TAX_EPSILON = float(os.environ.get("TAX_EPSILON", "0.01"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OUTPUT_CSV = os.environ.get("OUTPUT_CSV", "creditmemo_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_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_creditmemos_for_order(token, order_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
        "searchCriteria[filterGroups][0][filters][0][value]": order_id,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[pageSize]": 100,
    }
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/creditmemo",
        params=params,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json().get("items", [])


def is_creditmemo_tax_mismatched(order_item_tax_amount, order_item_qty_ordered,
                                  creditmemo_item_qty, creditmemo_base_tax_amount, epsilon=TAX_EPSILON):
    if not order_item_qty_ordered:
        expected_tax = 0.0
    else:
        expected_tax = order_item_tax_amount * (creditmemo_item_qty / order_item_qty_ordered)
    delta = creditmemo_base_tax_amount - expected_tax
    return {
        "expectedTax": expected_tax,
        "delta": delta,
        "mismatched": abs(delta) > epsilon,
    }


def expected_tax_for_creditmemo(order_items_by_id, creditmemo):
    expected_total = 0.0
    for cm_item in creditmemo.get("items", []):
        order_item = order_items_by_id.get(cm_item.get("order_item_id"))
        if not order_item:
            continue
        expected_total += is_creditmemo_tax_mismatched(
            order_item.get("tax_amount", 0) or 0,
            order_item.get("qty_ordered", 0) or 0,
            cm_item.get("qty", 0) or 0,
            0,  # placeholder, only expectedTax is used per line here
        )["expectedTax"]
    return expected_total


def build_adjustment_payload(delta):
    if delta > 0:
        return {"arguments": {"adjustment_negative": round(delta, 2)}}
    return {"arguments": {"adjustment_positive": round(abs(delta), 2)}}


def apply_adjustment(token, order_id, delta):
    payload = build_adjustment_payload(delta)
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/order/{order_id}/refund",
        json=payload,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    token = get_token()
    flagged = []
    for order_id in ORDER_IDS:
        order = get_order(token, order_id)
        order_items_by_id = {item.get("item_id"): item for item in order.get("items", [])}
        creditmemos = get_creditmemos_for_order(token, order_id)

        for cm in creditmemos:
            expected_tax = expected_tax_for_creditmemo(order_items_by_id, cm)
            actual_tax = cm.get("base_tax_amount", 0) or 0
            delta = actual_tax - expected_tax
            mismatched = abs(delta) > TAX_EPSILON
            if not mismatched:
                continue

            row = {
                "order_increment_id": order.get("increment_id"),
                "creditmemo_increment_id": cm.get("increment_id"),
                "expected_tax": round(expected_tax, 4),
                "actual_tax": round(actual_tax, 4),
                "delta": round(delta, 4),
            }
            flagged.append(row)
            log.warning(
                "Order %s creditmemo %s: expected_tax=%s actual_tax=%s delta=%s",
                row["order_increment_id"], row["creditmemo_increment_id"],
                row["expected_tax"], row["actual_tax"], row["delta"],
            )

            payload = build_adjustment_payload(delta)
            log.info("Proposed adjustment payload: %s", payload)
            if not DRY_RUN:
                apply_adjustment(token, order_id, delta)

    if flagged:
        with open(OUTPUT_CSV, "w", newline="") as fh:
            writer = csv.DictWriter(fh, fieldnames=[
                "order_increment_id", "creditmemo_increment_id",
                "expected_tax", "actual_tax", "delta",
            ])
            writer.writeheader()
            writer.writerows(flagged)

    log.info("Done. %d creditmemo(s) flagged, %s.", len(flagged),
              "dry run, nothing written" if DRY_RUN else "corrective refund attempted where flagged")


if __name__ == "__main__":
    run()
flag-creditmemo-tax-mismatch.js
/**
 * Flag Magento 2 or Adobe Commerce credit memos whose tax was computed from
 * the full order instead of the refunded items.
 *
 * Magento's credit memo tax totals collector is supposed to prorate tax per
 * line item using qty being refunded versus qty invoiced. Long standing bugs,
 * seen across magento2 GitHub issues 8797, 9929, 10982, 14713, 23938, 32222,
 * and 34586, instead cause it to copy the order's full tax_amount and
 * base_tax_amount onto the credit memo, notably when the credit memo is
 * created from the admin order view, when multiple partial credit memos are
 * issued against the same invoice, or when the display currency differs from
 * the base currency. CreditmemoItemInterface.tax_amount is a snapshot stored
 * at creation time, never re-derived later, so a wrong number stays wrong
 * forever.
 *
 * This script never edits an existing credit memo, since Magento has no
 * supported REST write for that. It recomputes the expected proportional tax
 * from the order's own item data, compares it to each credit memo's reported
 * base_tax_amount, and writes a reconciliation report. Only under an
 * explicit DRY_RUN=false does it optionally POST a new corrective refund
 * call carrying an adjustment_positive or adjustment_negative argument. In
 * dry run it only prints the proposed payload. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/partial-refund-tax-miscalculated/
 */
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 TAX_EPSILON = Number(process.env.TAX_EPSILON || 0.01);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function isCreditMemoTaxMismatched(orderItemTaxAmount, orderItemQtyOrdered, creditMemoItemQty, creditMemoBaseTaxAmount, epsilon = TAX_EPSILON) {
  const expectedTax = orderItemQtyOrdered
    ? orderItemTaxAmount * (creditMemoItemQty / orderItemQtyOrdered)
    : 0;
  const delta = creditMemoBaseTaxAmount - expectedTax;
  return { expectedTax, delta, mismatched: Math.abs(delta) > epsilon };
}

export function expectedTaxForCreditMemo(orderItemsById, creditMemo) {
  let expectedTotal = 0;
  for (const cmItem of creditMemo.items || []) {
    const orderItem = orderItemsById[cmItem.order_item_id];
    if (!orderItem) continue;
    expectedTotal += isCreditMemoTaxMismatched(
      orderItem.tax_amount || 0,
      orderItem.qty_ordered || 0,
      cmItem.qty || 0,
      0,
    ).expectedTax;
  }
  return expectedTotal;
}

export function buildAdjustmentPayload(delta) {
  if (delta > 0) return { arguments: { adjustment_negative: Math.round(delta * 100) / 100 } };
  return { arguments: { adjustment_positive: Math.round(Math.abs(delta) * 100) / 100 } };
}

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 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 getCreditmemosForOrder(token, orderId) {
  const params = new URLSearchParams({
    "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
    "searchCriteria[filterGroups][0][filters][0][value]": orderId,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[pageSize]": "100",
  });
  const res = await fetch(`${MAGENTO_URL}/rest/V1/creditmemo?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  const body = await res.json();
  return body.items || [];
}

async function applyAdjustment(token, orderId, delta) {
  const payload = buildAdjustmentPayload(delta);
  const res = await fetch(`${MAGENTO_URL}/rest/V1/order/${orderId}/refund`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

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

  for (const orderId of ORDER_IDS) {
    const order = await getOrder(token, orderId);
    const orderItemsById = {};
    for (const item of order.items || []) orderItemsById[item.item_id] = item;
    const creditmemos = await getCreditmemosForOrder(token, orderId);

    for (const cm of creditmemos) {
      const expectedTax = expectedTaxForCreditMemo(orderItemsById, cm);
      const actualTax = cm.base_tax_amount || 0;
      const delta = actualTax - expectedTax;
      const mismatched = Math.abs(delta) > TAX_EPSILON;
      if (!mismatched) continue;

      const row = {
        order_increment_id: order.increment_id,
        creditmemo_increment_id: cm.increment_id,
        expected_tax: Math.round(expectedTax * 10000) / 10000,
        actual_tax: Math.round(actualTax * 10000) / 10000,
        delta: Math.round(delta * 10000) / 10000,
      };
      flagged.push(row);
      console.warn(`Order ${row.order_increment_id} creditmemo ${row.creditmemo_increment_id}: expected_tax=${row.expected_tax} actual_tax=${row.actual_tax} delta=${row.delta}`);

      const payload = buildAdjustmentPayload(delta);
      console.log("Proposed adjustment payload:", JSON.stringify(payload));
      if (!DRY_RUN) await applyAdjustment(token, orderId, delta);
    }
  }

  console.log(`Done. ${flagged.length} creditmemo(s) flagged, ${DRY_RUN ? "dry run, nothing written" : "corrective refund attempted where flagged"}.`);
  return flagged;
}

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

Add a test

The mismatch rule is the part most worth testing, because it decides whether a credit memo gets flagged. Since is_creditmemo_tax_mismatched and isCreditMemoTaxMismatched are pure, no network and no Magento instance are needed. The tests feed in plain numbers and check the verdict, covering an exact proportional match, the epsilon edge, the tell tale case of a partial refund carrying full order tax, and a zero qty_ordered guard.

test_partial_refund_mismatch.py
from flag_creditmemo_tax_mismatch import is_creditmemo_tax_mismatched


def test_proportional_tax_matches_is_not_mismatched():
    # order item taxed 10.00 across 5 units, refund 2 units, so expect 4.00
    result = is_creditmemo_tax_mismatched(
        order_item_tax_amount=10.00, order_item_qty_ordered=5,
        creditmemo_item_qty=2, creditmemo_base_tax_amount=4.00,
    )
    assert result["expectedTax"] == 4.00
    assert result["mismatched"] is False


def test_within_epsilon_is_not_mismatched():
    result = is_creditmemo_tax_mismatched(
        order_item_tax_amount=10.00, order_item_qty_ordered=5,
        creditmemo_item_qty=2, creditmemo_base_tax_amount=4.005,
    )
    assert result["mismatched"] is False


def test_full_order_tax_on_partial_refund_is_mismatched():
    # tell tale bug: partial refund of 2 of 5 carries the full 10.00 order tax
    result = is_creditmemo_tax_mismatched(
        order_item_tax_amount=10.00, order_item_qty_ordered=5,
        creditmemo_item_qty=2, creditmemo_base_tax_amount=10.00,
    )
    assert result["expectedTax"] == 4.00
    assert result["delta"] == 6.00
    assert result["mismatched"] is True


def test_over_refunded_tax_is_mismatched_with_negative_delta():
    result = is_creditmemo_tax_mismatched(
        order_item_tax_amount=10.00, order_item_qty_ordered=5,
        creditmemo_item_qty=2, creditmemo_base_tax_amount=1.00,
    )
    assert result["delta"] == -3.00
    assert result["mismatched"] is True


def test_zero_qty_ordered_guarded_to_zero_expected_tax():
    result = is_creditmemo_tax_mismatched(
        order_item_tax_amount=10.00, order_item_qty_ordered=0,
        creditmemo_item_qty=0, creditmemo_base_tax_amount=0,
    )
    assert result["expectedTax"] == 0.0
    assert result["mismatched"] is False


def test_custom_epsilon_is_respected():
    result = is_creditmemo_tax_mismatched(
        order_item_tax_amount=10.00, order_item_qty_ordered=5,
        creditmemo_item_qty=2, creditmemo_base_tax_amount=4.08,
        epsilon=0.1,
    )
    assert result["mismatched"] is False
mismatch.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isCreditMemoTaxMismatched } from "./flag-creditmemo-tax-mismatch.js";

test("proportional tax match is not mismatched", () => {
  const result = isCreditMemoTaxMismatched(10.00, 5, 2, 4.00);
  assert.equal(result.expectedTax, 4.00);
  assert.equal(result.mismatched, false);
});

test("within epsilon is not mismatched", () => {
  const result = isCreditMemoTaxMismatched(10.00, 5, 2, 4.005);
  assert.equal(result.mismatched, false);
});

test("full order tax on partial refund is mismatched", () => {
  const result = isCreditMemoTaxMismatched(10.00, 5, 2, 10.00);
  assert.equal(result.expectedTax, 4.00);
  assert.equal(result.delta, 6.00);
  assert.equal(result.mismatched, true);
});

test("over refunded tax is mismatched with negative delta", () => {
  const result = isCreditMemoTaxMismatched(10.00, 5, 2, 1.00);
  assert.equal(result.delta, -3.00);
  assert.equal(result.mismatched, true);
});

test("zero qty ordered guarded to zero expected tax", () => {
  const result = isCreditMemoTaxMismatched(10.00, 0, 0, 0);
  assert.equal(result.expectedTax, 0);
  assert.equal(result.mismatched, false);
});

test("custom epsilon is respected", () => {
  const result = isCreditMemoTaxMismatched(10.00, 5, 2, 4.08, 0.1);
  assert.equal(result.mismatched, false);
});

Case studies

Admin order view

A support agent's habit that quietly overstated refunded tax

A homeware store's support team preferred issuing credit memos from the order page instead of drilling into the specific invoice, because it was one click closer. Every one of those credit memos, when the return was partial, carried the order's entire tax amount instead of a proportional share. Months later, finance found refunded tax exceeded collected tax on several tax jurisdictions and could not explain why.

Running the audit script against a quarter of orders turned up dozens of credit memos with the exact tell tale sign, a partial quantity paired with a full order tax total. The report gave finance the order and credit memo increment ids they needed to file corrected returns, without anyone touching the original documents.

Multiple partial credit memos

Two partial refunds against one invoice, one of them doubled the tax

A customer returned an item in two separate shipments, so support issued two partial credit memos against the same invoice a week apart. The second credit memo's tax collector lost track of what the first had already refunded and copied the order's remaining full tax onto a credit memo that should have covered a small remaining slice.

The script flagged the second credit memo specifically, since its base_tax_amount did not match the proportional expectation for its own qty. Because the script never edits history, the team used the reported delta to issue a new adjustment refund that corrected the tax books going forward, leaving both original credit memos intact as the accounting record they are.

What good looks like

After running this on a schedule, a mis-taxed partial refund stops being a mystery finance finds during reconciliation. You get a dated report of the order increment id, the credit memo increment id, the expected tax, the actual tax, and the delta for every credit memo that disagrees with its own order data, plus a ready to review adjustment payload. The original credit memo stays exactly as issued, since that is what an accounting record is supposed to do, and any correction happens through a new, explicit refund call.

FAQ

Why does a partial refund credit memo show the full order tax instead of a proportional share?

Magento's credit memo tax totals collector is supposed to prorate tax per line item based on the quantity being refunded versus the quantity invoiced. Long standing bugs in that collector, especially when a credit memo is created from the admin order view instead of the invoice view, when multiple partial credit memos are issued against the same invoice, or when the display currency differs from the base currency, cause it to copy the order's full tax_amount onto the credit memo instead of prorating it.

Can I fix a wrong credit memo tax amount through the REST API?

No. A credit memo is treated as an immutable financial record once it is created, and there is no supported REST endpoint that edits or patches an existing creditmemo's totals. The safe response is to detect and report the mismatch, then, only if you explicitly choose to, issue a new refund call carrying an adjustment_positive or adjustment_negative argument to correct the tax going forward rather than mutating the original document.

How do I detect which credit memos have the wrong tax amount?

For each order, read the order items' tax_amount and qty_ordered, then for every credit memo against that order compute the expected tax as tax_amount times the refunded quantity divided by qty_ordered, summed across the refunded lines. Compare that expected total to the credit memo's base_tax_amount with a small epsilon for rounding. The tell tale sign is a credit memo whose base_tax_amount equals the order's full base_tax_amount even though its quantity is less than qty_ordered.

Related field notes

Citations

On the problem:

  1. Wrong (full-) tax calculation on partial creditmemo (PDF totals), magento2 issue 9929. github.com/magento/magento2/issues/9929
  2. Issue in Partial Credit Memo including total tax, magento2 issue 14713. github.com/magento/magento2/issues/14713
  3. Tax for complete order applied when refunding partial order, magento2 issue 32222. github.com/magento/magento2/issues/32222

On the solution:

  1. Step 10, Issue a partial refund, Adobe Commerce REST API tutorial. developer.adobe.com commerce/webapi/rest/tutorials/orders/order-issue-refund
  2. Refunds, Sales module REST API, Adobe Commerce webapi. developer.adobe.com commerce/webapi/rest/modules/sales
  3. REST API reference, Adobe Commerce Web API. developer.adobe.com commerce/webapi/rest/reference

Stuck on a tricky one?

If you have a problem in Magento orders, credit memos, refunds, or tax 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 refund 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