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.
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.
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:
- The credit memo is created from the admin order view page rather than from an invoice's own Credit Memo button, a path that skips some of the same proration logic the invoice flow relies on.
- Multiple partial credit memos are issued against the same invoice, and the collector's running math for what tax remains gets confused about how much has already been refunded.
- The store's display currency differs from the base currency, and the tax proration is computed in the wrong currency context before being converted, or not converted at all.
CreditmemoItemInterface.tax_amountandbase_tax_amountare stored as calculated snapshots at creation time. They are never re-derived from the order later, so whatever the collector computed at that moment, right or wrong, is what stays on the record permanently.
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.
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.
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 TAX_EPSILON="0.01"
export DRY_RUN="true" # report only, this script never edits a creditmemo
// 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
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.
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()
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();
}
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.
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", [])
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 || [];
}
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.
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,
}
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 };
}
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.
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()
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();
}
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.
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.
"""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 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.
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
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
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.
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.
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:
- Wrong (full-) tax calculation on partial creditmemo (PDF totals), magento2 issue 9929. github.com/magento/magento2/issues/9929
- Issue in Partial Credit Memo including total tax, magento2 issue 14713. github.com/magento/magento2/issues/14713
- Tax for complete order applied when refunding partial order, magento2 issue 32222. github.com/magento/magento2/issues/32222
On the solution:
- Step 10, Issue a partial refund, Adobe Commerce REST API tutorial. developer.adobe.com commerce/webapi/rest/tutorials/orders/order-issue-refund
- Refunds, Sales module REST API, Adobe Commerce webapi. developer.adobe.com commerce/webapi/rest/modules/sales
- 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.
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