Skip to content

Diagnostic Credit Memos and Refunds

Credit memo total wrong on multi invoice orders

A customer ordered three items, one shipped a week before the other two, so the order carries two invoices. A partial refund goes out against the second invoice, and the credit memo total, or just its tax amount, does not add up to what was actually invoiced and refunded. Finance flags it, and now you are staring at a credit memo that is a real financial record, cannot be edited, and still looks wrong. Here is why Magento's total collectors get this wrong on split orders and a small script that finds exactly which credit memos do not add up.

Python and Node.js Orders, Invoices, Creditmemo REST API Safe by default (report only)
A calculator on a yellow background
Photo by Behnam Norouzi on Unsplash
The short answer

Magento's credit memo total collectors, Magento\Sales\Model\Order\Creditmemo\Total\Tax and the related shipping and discount collectors, compute refundable tax and totals mainly from the parent order's aggregate tax_amount rather than proportionally from the one invoice actually being refunded. When an order was split into two or more invoices, each invoice and credit memo pair needs to prorate tax and shipping by the items actually invoiced and refunded, and because the collectors do not consistently subtract tax already refunded by earlier credit memos on earlier invoices of the same order (allowedTax and allowedBaseTax are not scoped per invoice), a credit memo on the second or later invoice can double count or omit tax and shipping. This is a long-standing class of bug tracked across several core issues, not one line of broken code. There is no REST endpoint to fix a credit memo's totals after it is created, so the safe move is to recompute an expected total per credit memo from its parent invoice's per-item tax, prorated by refunded qty, and report every credit memo whose actual total or tax drifts past a cent of tolerance, plus any order where refunds in total exceed what was invoiced. Full code, tests, and a dry run guard are below.

The problem in plain words

A credit memo is supposed to answer one question: of what was invoiced, how much of it, and its tax and shipping, is being handed back. On a simple order with a single invoice, that is straightforward, since the whole order was invoiced at once and the credit memo's total collectors have exactly one tax base to prorate from.

Split the order into two or more invoices, and that single tax base stops being single. Each invoice really only represents part of the order, and its own base_tax_amount should be scoped to the items it actually invoiced. But Magento's collectors for credit memo totals, tax in particular, lean on the parent order's aggregate tax_amount rather than recalculating strictly from the specific invoice being refunded. On top of that, the running total of tax already refunded by prior credit memos, tracked as allowedTax and allowedBaseTax, is not consistently scoped per invoice. So a credit memo issued against the second invoice can end up counting tax that a credit memo against the first invoice already refunded, or missing tax that belongs only to its own invoice.

The result shows up as a credit memo whose base_grand_total or base_tax_amount does not match what you get by hand tracing the actual invoiced items, their tax rates, and the quantity being refunded. It is not obviously wrong to a person clicking through the admin, since the numbers look plausible, they are just not the right numbers for that specific invoice.

Order tax_amount (aggregate) Invoice 1 already refunded once Invoice 2 new credit memo here pulls from order total, not invoice 2 alone allowedTax not scoped per invoice Credit memo total does not match what invoice 2 actually owed back
The tax base the collector reaches for is the whole order, not the one invoice being refunded, so a second credit memo can double count or omit tax.

Why it happens

Finance usually finds this the same way: they reconcile a refund against the invoice it was supposed to match, the numbers are close but not equal, and re-checking the math by hand from the invoice line items shows the credit memo is off by the tax on items from a different invoice entirely. See the citations at the end for the exact issue threads.

The key insight

A credit memo, once created, is an immutable financial record tied to a payment or refund transaction. There is no supported REST endpoint to mutate its totals, and recalculating correctly requires re-running the core total collectors, which are not exposed over the API. So the only safe thing a script can do is independently recompute what the credit memo's tax and grand total should have been, using the invoice it was actually issued against, and report the discrepancy. Whether to compensate with a new offsetting credit memo is a decision for a human with money authority, never an automatic edit.

The fix, as a flow

We do not touch any existing credit memo. We add a job that, for every order with more than one invoice, pulls each credit memo alongside its parent invoice, recomputes an expected tax and grand total by prorating the invoice's own per-item tax by the refunded quantity, and reports every credit memo whose actual total drifts past a cent of tolerance, or whose refunds in total exceed what its invoice actually carried.

Scheduled job orders with 2+ invoices Pull creditmemo + invoice GET /invoices, /creditmemo Prorate expected total tax per unit × refunded qty Delta over tolerance? yes no, report ok FLAG_DISCREPANT report row for finance
The script only ever reports a discrepant credit memo. It never edits or writes a credit memo, since that record is meant to be immutable.

Build it step by step

1

Get an admin bearer token

Authenticate the same way as any other Magento REST client. Either call POST /rest/V1/integration/admin/token with an admin username and password, or create an integration and use its token directly. Keep the store URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export TOLERANCE_CENTS="0.01"
export DRY_RUN="true"   # report-only either way, this only affects log verbosity
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export TOLERANCE_CENTS="0.01"
export DRY_RUN="true"   // report-only either way, this only affects log verbosity
2

Talk to the Magento REST API

Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps the request and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]

def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

List invoices per order, then credit memos per invoice

For each candidate order, call GET /rest/V1/invoices filtered by order_id to get every invoice, each with entity_id, base_tax_amount, and items[].base_tax_amount and items[].qty_invoiced. Only orders with more than one invoice are the reproducing condition worth checking. Then pull the credit memos tied to each invoice, filtering the creditmemo search on invoice_id, since that is the specific invoice each credit memo claims to refund against.

step3.py
def invoices_for_order(order_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[filterGroups][0][filters][0][value]": order_id,
    }
    return magento_get("/invoices", params)["items"]


def creditmemos_for_invoice(invoice_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "invoice_id",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[filterGroups][0][filters][0][value]": invoice_id,
    }
    return magento_get("/creditmemo", params)["items"]
step3.js
async function invoicesForOrder(orderId) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[filterGroups][0][filters][0][value]": orderId,
  };
  const data = await magentoGet("/invoices", params);
  return data.items;
}

async function creditmemosForInvoice(invoiceId) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "invoice_id",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[filterGroups][0][filters][0][value]": invoiceId,
  };
  const data = await magentoGet("/creditmemo", params);
  return data.items;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the credit memo, its parent invoice, and any prior credit memos already issued against that same invoice, and returns whether it is discrepant plus the expected totals. A pure function like this is easy to read and easy to test, which we do later. It prorates each invoice item's tax by refunded quantity to get an expected tax amount, builds an expected grand total from the refunded items, shipping, and adjustments, and separately checks that refunds against one invoice never exceed that invoice's total, which is an over refund regardless of what the tax math says.

decide.py
def decide_credit_memo_discrepancy(credit_memo, parent_invoice, prior_credit_memos_for_invoice,
                                    tolerance_cents=0.01):
    invoice_items_by_id = {item["itemId"]: item for item in parent_invoice["items"]}

    expected_tax_amount = 0.0
    expected_items_total = 0.0
    for item in credit_memo["items"]:
        invoice_item = invoice_items_by_id.get(item.get("itemId"))
        if invoice_item and invoice_item["qtyInvoiced"]:
            per_unit_tax = invoice_item["baseTaxAmount"] / invoice_item["qtyInvoiced"]
            per_unit_row = invoice_item["baseRowTotal"] / invoice_item["qtyInvoiced"]
        else:
            per_unit_tax = 0.0
            per_unit_row = item["baseRowTotal"] / item["qtyRefunded"] if item["qtyRefunded"] else 0.0
        expected_tax_amount += per_unit_tax * item["qtyRefunded"]
        expected_items_total += per_unit_row * item["qtyRefunded"]

    expected_grand_total = (
        expected_items_total
        + credit_memo["baseShippingAmount"]
        + expected_tax_amount
        - credit_memo["adjustmentNegative"]
        + credit_memo["adjustmentPositive"]
    )

    delta_grand_total = round(credit_memo["baseGrandTotal"] - expected_grand_total, 2)
    delta_tax_amount = round(credit_memo["baseTaxAmount"] - expected_tax_amount, 2)

    prior_total = sum(cm["baseGrandTotal"] for cm in prior_credit_memos_for_invoice)
    over_refund = (prior_total + credit_memo["baseGrandTotal"]) > (parent_invoice["baseGrandTotal"] + tolerance_cents)

    if over_refund:
        reason = "over_refund"
    elif abs(delta_tax_amount) > tolerance_cents:
        reason = "tax_mismatch"
    elif abs(delta_grand_total) > tolerance_cents:
        reason = "grand_total_mismatch"
    else:
        reason = "ok"

    return {
        "isDiscrepant": reason != "ok",
        "expectedGrandTotal": round(expected_grand_total, 2),
        "expectedTaxAmount": round(expected_tax_amount, 2),
        "deltaGrandTotal": delta_grand_total,
        "deltaTaxAmount": delta_tax_amount,
        "reason": reason,
    }
decide.js
export function decideCreditMemoDiscrepancy(creditMemo, parentInvoice, priorCreditMemosForInvoice,
                                             toleranceCents = 0.01) {
  const invoiceItemsById = new Map(parentInvoice.items.map((item) => [item.itemId, item]));

  let expectedTaxAmount = 0;
  let expectedItemsTotal = 0;
  for (const item of creditMemo.items) {
    const invoiceItem = invoiceItemsById.get(item.itemId);
    let perUnitTax = 0;
    let perUnitRow = 0;
    if (invoiceItem && invoiceItem.qtyInvoiced) {
      perUnitTax = invoiceItem.baseTaxAmount / invoiceItem.qtyInvoiced;
      perUnitRow = invoiceItem.baseRowTotal / invoiceItem.qtyInvoiced;
    } else if (item.qtyRefunded) {
      perUnitRow = item.baseRowTotal / item.qtyRefunded;
    }
    expectedTaxAmount += perUnitTax * item.qtyRefunded;
    expectedItemsTotal += perUnitRow * item.qtyRefunded;
  }

  const expectedGrandTotal =
    expectedItemsTotal + creditMemo.baseShippingAmount + expectedTaxAmount
    - creditMemo.adjustmentNegative + creditMemo.adjustmentPositive;

  const deltaGrandTotal = round2(creditMemo.baseGrandTotal - expectedGrandTotal);
  const deltaTaxAmount = round2(creditMemo.baseTaxAmount - expectedTaxAmount);

  const priorTotal = priorCreditMemosForInvoice.reduce((sum, cm) => sum + cm.baseGrandTotal, 0);
  const overRefund = (priorTotal + creditMemo.baseGrandTotal) > (parentInvoice.baseGrandTotal + toleranceCents);

  let reason;
  if (overRefund) reason = "over_refund";
  else if (Math.abs(deltaTaxAmount) > toleranceCents) reason = "tax_mismatch";
  else if (Math.abs(deltaGrandTotal) > toleranceCents) reason = "grand_total_mismatch";
  else reason = "ok";

  return {
    isDiscrepant: reason !== "ok",
    expectedGrandTotal: round2(expectedGrandTotal),
    expectedTaxAmount: round2(expectedTaxAmount),
    deltaGrandTotal,
    deltaTaxAmount,
    reason,
  };
}

function round2(n) {
  return Math.round(n * 100) / 100;
}
5

Report by default, never fake a repair

The output is a structured report row per discrepant credit memo: order_increment_id, creditmemo_increment_id, invoice_id, expected_grand_total, actual_grand_total, expected_tax_amount, actual_tax_amount, delta, and reason. There is no code path in this script that edits an existing credit memo, because creditmemo entities are immutable financial records and there is no supported endpoint for it.

6

An explicit, opt-in compensating refund, off by default

Only if DRY_RUN=false and an explicit allowlist of order increment ids is supplied does the script attempt the one guarded write: creating a new offsetting credit memo via POST /rest/V1/order/{orderId}/refund with an explicit arguments.adjustment_positive or arguments.adjustment_negative to true up the customer-visible total. It never edits the original discrepant record. This path touches real money, so it is logged, gated behind an explicit flag, and meant to run only after manual approval.

Run it safe

This script never edits an existing credit memo and never assumes a discrepancy in Magento's favor or the customer's. DRY_RUN defaults to true and only report mode ever runs unattended. The compensating refund path requires both DRY_RUN=false and an explicit order allowlist, and even then it only creates a new offsetting credit memo, it never mutates the one that was flagged.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks every order with more than one invoice, pulls each credit memo alongside its parent invoice and any prior credit memos on that invoice, recomputes the expected totals with the pure function, and prints a structured report. It never edits an existing credit memo, so it is safe to run again and again.

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_discrepancy.py
"""Flag Magento 2 credit memos whose total or tax is wrong on a multi invoice order.

Magento's credit memo total collectors (Magento\\Sales\\Model\\Order\\Creditmemo\\Total\\Tax
and the related shipping and discount collectors) compute refundable tax and totals
mainly from the parent order's aggregate tax_amount rather than proportionally from
the specific invoice being refunded. When an order was split into two or more
invoices, each invoice and credit memo pair needs to prorate tax and shipping by the
items actually invoiced and refunded, and the collectors do not consistently subtract
tax already refunded by prior credit memos tied to earlier invoices on the same order.
A credit memo has no supported REST endpoint to mutate its totals after creation, so
this only reports the discrepancy. Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests

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

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
TOLERANCE_CENTS = float(os.environ.get("TOLERANCE_CENTS", "0.01"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
REFUND_ALLOWLIST = {
    s.strip() for s in os.environ.get("REFUND_ALLOWLIST", "").split(",") if s.strip()
}


def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def magento_post(path, payload):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1{path}",
        json=payload,
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def orders_complete_or_closed(page_size=200, current_page=1):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "status",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
        "searchCriteria[filterGroups][0][filters][0][value]": "complete,closed",
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    return magento_get("/orders", params)["items"]


def invoices_for_order(order_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[filterGroups][0][filters][0][value]": order_id,
    }
    return magento_get("/invoices", params)["items"]


def creditmemos_for_invoice(invoice_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "invoice_id",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[filterGroups][0][filters][0][value]": invoice_id,
    }
    return magento_get("/creditmemo", params)["items"]


def normalize_invoice(raw):
    return {
        "entityId": raw.get("entity_id"),
        "baseGrandTotal": raw.get("base_grand_total") or 0.0,
        "baseTaxAmount": raw.get("base_tax_amount") or 0.0,
        "items": [
            {
                "itemId": item.get("item_id") or item.get("order_item_id"),
                "qtyInvoiced": item.get("qty") or 0.0,
                "baseTaxAmount": item.get("base_tax_amount") or 0.0,
                "baseRowTotal": item.get("base_row_total") or 0.0,
            }
            for item in raw.get("items", [])
        ],
    }


def normalize_creditmemo(raw):
    return {
        "entityId": raw.get("entity_id"),
        "incrementId": raw.get("increment_id"),
        "invoiceId": raw.get("invoice_id"),
        "baseGrandTotal": raw.get("base_grand_total") or 0.0,
        "baseTaxAmount": raw.get("base_tax_amount") or 0.0,
        "baseShippingAmount": raw.get("base_shipping_amount") or 0.0,
        "adjustmentPositive": raw.get("adjustment_positive") or 0.0,
        "adjustmentNegative": raw.get("adjustment_negative") or 0.0,
        "items": [
            {
                "itemId": item.get("order_item_id"),
                "qtyRefunded": item.get("qty") or 0.0,
                "baseRowTotal": item.get("base_row_total") or 0.0,
                "baseTaxAmount": item.get("base_tax_amount") or 0.0,
            }
            for item in raw.get("items", [])
        ],
    }


def decide_credit_memo_discrepancy(credit_memo, parent_invoice, prior_credit_memos_for_invoice,
                                    tolerance_cents=0.01):
    invoice_items_by_id = {item["itemId"]: item for item in parent_invoice["items"]}

    expected_tax_amount = 0.0
    expected_items_total = 0.0
    for item in credit_memo["items"]:
        invoice_item = invoice_items_by_id.get(item.get("itemId"))
        if invoice_item and invoice_item["qtyInvoiced"]:
            per_unit_tax = invoice_item["baseTaxAmount"] / invoice_item["qtyInvoiced"]
            per_unit_row = invoice_item["baseRowTotal"] / invoice_item["qtyInvoiced"]
        else:
            per_unit_tax = 0.0
            per_unit_row = item["baseRowTotal"] / item["qtyRefunded"] if item["qtyRefunded"] else 0.0
        expected_tax_amount += per_unit_tax * item["qtyRefunded"]
        expected_items_total += per_unit_row * item["qtyRefunded"]

    expected_grand_total = (
        expected_items_total
        + credit_memo["baseShippingAmount"]
        + expected_tax_amount
        - credit_memo["adjustmentNegative"]
        + credit_memo["adjustmentPositive"]
    )

    delta_grand_total = round(credit_memo["baseGrandTotal"] - expected_grand_total, 2)
    delta_tax_amount = round(credit_memo["baseTaxAmount"] - expected_tax_amount, 2)

    prior_total = sum(cm["baseGrandTotal"] for cm in prior_credit_memos_for_invoice)
    over_refund = (prior_total + credit_memo["baseGrandTotal"]) > (parent_invoice["baseGrandTotal"] + tolerance_cents)

    if over_refund:
        reason = "over_refund"
    elif abs(delta_tax_amount) > tolerance_cents:
        reason = "tax_mismatch"
    elif abs(delta_grand_total) > tolerance_cents:
        reason = "grand_total_mismatch"
    else:
        reason = "ok"

    return {
        "isDiscrepant": reason != "ok",
        "expectedGrandTotal": round(expected_grand_total, 2),
        "expectedTaxAmount": round(expected_tax_amount, 2),
        "deltaGrandTotal": delta_grand_total,
        "deltaTaxAmount": delta_tax_amount,
        "reason": reason,
    }


def compensating_refund(order_id, positive_adjustment):
    """Create a NEW offsetting credit memo. Never edits the flagged record."""
    payload = {
        "arguments": {
            "adjustment_positive": positive_adjustment,
            "adjustment_negative": 0,
        }
    }
    return magento_post(f"/order/{order_id}/refund", payload)


def run():
    flagged = []
    for raw_order in orders_complete_or_closed():
        order_id = raw_order["entity_id"]
        raw_invoices = invoices_for_order(order_id)
        if len(raw_invoices) < 2:
            continue

        for raw_invoice in raw_invoices:
            invoice = normalize_invoice(raw_invoice)
            raw_credit_memos = creditmemos_for_invoice(invoice["entityId"])
            credit_memos = [normalize_creditmemo(cm) for cm in raw_credit_memos]

            for i, credit_memo in enumerate(credit_memos):
                prior = credit_memos[:i]
                result = decide_credit_memo_discrepancy(credit_memo, invoice, prior, TOLERANCE_CENTS)
                if result["isDiscrepant"]:
                    flagged.append({
                        "orderIncrementId": raw_order.get("increment_id"),
                        "creditmemoIncrementId": credit_memo["incrementId"],
                        "invoiceId": invoice["entityId"],
                        "expectedGrandTotal": result["expectedGrandTotal"],
                        "actualGrandTotal": credit_memo["baseGrandTotal"],
                        "expectedTaxAmount": result["expectedTaxAmount"],
                        "actualTaxAmount": credit_memo["baseTaxAmount"],
                        "delta": result["deltaGrandTotal"],
                        "reason": result["reason"],
                    })

    for row in flagged:
        log.warning(
            "Order %s creditmemo %s (invoice %s) is %s. expected grand total %.2f, actual %.2f (delta %.2f).",
            row["orderIncrementId"], row["creditmemoIncrementId"], row["invoiceId"], row["reason"],
            row["expectedGrandTotal"], row["actualGrandTotal"], row["delta"],
        )

    if flagged:
        log.error("%d credit memo(s) discrepant. This script never edits them directly.", len(flagged))
    else:
        log.info("Done. No credit memo discrepancies found.")

    if not DRY_RUN and REFUND_ALLOWLIST:
        for row in flagged:
            if row["orderIncrementId"] in REFUND_ALLOWLIST and row["delta"] < 0:
                log.warning(
                    "Creating compensating refund for order %s (short by %.2f).",
                    row["orderIncrementId"], -row["delta"],
                )
                compensating_refund(row["orderIncrementId"], -row["delta"])


if __name__ == "__main__":
    run()
flag-creditmemo-discrepancy.js
/**
 * Flag Magento 2 credit memos whose total or tax is wrong on a multi invoice order.
 *
 * Magento's credit memo total collectors (Magento\Sales\Model\Order\Creditmemo\Total\Tax
 * and the related shipping and discount collectors) compute refundable tax and totals
 * mainly from the parent order's aggregate tax_amount rather than proportionally from
 * the specific invoice being refunded. When an order was split into two or more
 * invoices, each invoice and credit memo pair needs to prorate tax and shipping by the
 * items actually invoiced and refunded, and the collectors do not consistently subtract
 * tax already refunded by prior credit memos tied to earlier invoices on the same order.
 * A credit memo has no supported REST endpoint to mutate its totals after creation, so
 * this only reports the discrepancy. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/credit-memo-total-wrong-multi-invoice/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const TOLERANCE_CENTS = Number(process.env.TOLERANCE_CENTS || 0.01);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REFUND_ALLOWLIST = new Set(
  (process.env.REFUND_ALLOWLIST || "").split(",").map((s) => s.trim()).filter(Boolean)
);

function round2(n) {
  return Math.round(n * 100) / 100;
}

export function decideCreditMemoDiscrepancy(creditMemo, parentInvoice, priorCreditMemosForInvoice,
                                             toleranceCents = 0.01) {
  const invoiceItemsById = new Map(parentInvoice.items.map((item) => [item.itemId, item]));

  let expectedTaxAmount = 0;
  let expectedItemsTotal = 0;
  for (const item of creditMemo.items) {
    const invoiceItem = invoiceItemsById.get(item.itemId);
    let perUnitTax = 0;
    let perUnitRow = 0;
    if (invoiceItem && invoiceItem.qtyInvoiced) {
      perUnitTax = invoiceItem.baseTaxAmount / invoiceItem.qtyInvoiced;
      perUnitRow = invoiceItem.baseRowTotal / invoiceItem.qtyInvoiced;
    } else if (item.qtyRefunded) {
      perUnitRow = item.baseRowTotal / item.qtyRefunded;
    }
    expectedTaxAmount += perUnitTax * item.qtyRefunded;
    expectedItemsTotal += perUnitRow * item.qtyRefunded;
  }

  const expectedGrandTotal =
    expectedItemsTotal + creditMemo.baseShippingAmount + expectedTaxAmount
    - creditMemo.adjustmentNegative + creditMemo.adjustmentPositive;

  const deltaGrandTotal = round2(creditMemo.baseGrandTotal - expectedGrandTotal);
  const deltaTaxAmount = round2(creditMemo.baseTaxAmount - expectedTaxAmount);

  const priorTotal = priorCreditMemosForInvoice.reduce((sum, cm) => sum + cm.baseGrandTotal, 0);
  const overRefund = (priorTotal + creditMemo.baseGrandTotal) > (parentInvoice.baseGrandTotal + toleranceCents);

  let reason;
  if (overRefund) reason = "over_refund";
  else if (Math.abs(deltaTaxAmount) > toleranceCents) reason = "tax_mismatch";
  else if (Math.abs(deltaGrandTotal) > toleranceCents) reason = "grand_total_mismatch";
  else reason = "ok";

  return {
    isDiscrepant: reason !== "ok",
    expectedGrandTotal: round2(expectedGrandTotal),
    expectedTaxAmount: round2(expectedTaxAmount),
    deltaGrandTotal,
    deltaTaxAmount,
    reason,
  };
}

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function magentoPost(path, payload) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    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();
}

async function ordersCompleteOrClosed(pageSize = 200, currentPage = 1) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "status",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "in",
    "searchCriteria[filterGroups][0][filters][0][value]": "complete,closed",
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": currentPage,
  };
  const data = await magentoGet("/orders", params);
  return data.items;
}

async function invoicesForOrder(orderId) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[filterGroups][0][filters][0][value]": orderId,
  };
  const data = await magentoGet("/invoices", params);
  return data.items;
}

async function creditmemosForInvoice(invoiceId) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "invoice_id",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[filterGroups][0][filters][0][value]": invoiceId,
  };
  const data = await magentoGet("/creditmemo", params);
  return data.items;
}

function normalizeInvoice(raw) {
  return {
    entityId: raw.entity_id,
    baseGrandTotal: raw.base_grand_total || 0,
    baseTaxAmount: raw.base_tax_amount || 0,
    items: (raw.items || []).map((item) => ({
      itemId: item.item_id || item.order_item_id,
      qtyInvoiced: item.qty || 0,
      baseTaxAmount: item.base_tax_amount || 0,
      baseRowTotal: item.base_row_total || 0,
    })),
  };
}

function normalizeCreditmemo(raw) {
  return {
    entityId: raw.entity_id,
    incrementId: raw.increment_id,
    invoiceId: raw.invoice_id,
    baseGrandTotal: raw.base_grand_total || 0,
    baseTaxAmount: raw.base_tax_amount || 0,
    baseShippingAmount: raw.base_shipping_amount || 0,
    adjustmentPositive: raw.adjustment_positive || 0,
    adjustmentNegative: raw.adjustment_negative || 0,
    items: (raw.items || []).map((item) => ({
      itemId: item.order_item_id,
      qtyRefunded: item.qty || 0,
      baseRowTotal: item.base_row_total || 0,
      baseTaxAmount: item.base_tax_amount || 0,
    })),
  };
}

async function compensatingRefund(orderId, positiveAdjustment) {
  const payload = {
    arguments: {
      adjustment_positive: positiveAdjustment,
      adjustment_negative: 0,
    },
  };
  return magentoPost(`/order/${orderId}/refund`, payload);
}

export async function run() {
  const flagged = [];
  const rawOrders = await ordersCompleteOrClosed();

  for (const rawOrder of rawOrders) {
    const orderId = rawOrder.entity_id;
    const rawInvoices = await invoicesForOrder(orderId);
    if (rawInvoices.length < 2) continue;

    for (const rawInvoice of rawInvoices) {
      const invoice = normalizeInvoice(rawInvoice);
      const rawCreditMemos = await creditmemosForInvoice(invoice.entityId);
      const creditMemos = rawCreditMemos.map(normalizeCreditmemo);

      creditMemos.forEach((creditMemo, i) => {
        const prior = creditMemos.slice(0, i);
        const result = decideCreditMemoDiscrepancy(creditMemo, invoice, prior, TOLERANCE_CENTS);
        if (result.isDiscrepant) {
          flagged.push({
            orderIncrementId: rawOrder.increment_id,
            creditmemoIncrementId: creditMemo.incrementId,
            invoiceId: invoice.entityId,
            expectedGrandTotal: result.expectedGrandTotal,
            actualGrandTotal: creditMemo.baseGrandTotal,
            expectedTaxAmount: result.expectedTaxAmount,
            actualTaxAmount: creditMemo.baseTaxAmount,
            delta: result.deltaGrandTotal,
            reason: result.reason,
          });
        }
      });
    }
  }

  for (const row of flagged) {
    console.warn(
      `Order ${row.orderIncrementId} creditmemo ${row.creditmemoIncrementId} (invoice ${row.invoiceId}) is ` +
      `${row.reason}. expected grand total ${row.expectedGrandTotal.toFixed(2)}, actual ${row.actualGrandTotal.toFixed(2)} ` +
      `(delta ${row.delta.toFixed(2)}).`
    );
  }

  if (flagged.length) {
    console.error(`${flagged.length} credit memo(s) discrepant. This script never edits them directly.`);
  } else {
    console.log("Done. No credit memo discrepancies found.");
  }

  if (!DRY_RUN && REFUND_ALLOWLIST.size) {
    for (const row of flagged) {
      if (REFUND_ALLOWLIST.has(row.orderIncrementId) && row.delta < 0) {
        console.warn(`Creating compensating refund for order ${row.orderIncrementId} (short by ${(-row.delta).toFixed(2)}).`);
        await compensatingRefund(row.orderIncrementId, -row.delta);
      }
    }
  }
}

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

Add a test

The decision rule is the part most worth testing, because it decides whether a real credit memo gets reported as wrong. Because we kept decide_credit_memo_discrepancy pure, the test needs no network and no Magento store. It just feeds in plain fixture rows for each branch and checks the answer.

test_creditmemo_discrepancy.py
from flag_creditmemo_discrepancy import decide_credit_memo_discrepancy


def invoice(**over):
    base = {
        "entityId": 900,
        "baseGrandTotal": 220.00,
        "baseTaxAmount": 20.00,
        "items": [
            {"itemId": 1, "qtyInvoiced": 2.0, "baseTaxAmount": 20.00, "baseRowTotal": 200.00},
        ],
    }
    base.update(over)
    return base


def credit_memo(**over):
    base = {
        "entityId": 700,
        "incrementId": "100000700",
        "invoiceId": 900,
        "baseGrandTotal": 110.00,
        "baseTaxAmount": 10.00,
        "baseShippingAmount": 0.0,
        "adjustmentPositive": 0.0,
        "adjustmentNegative": 0.0,
        "items": [
            {"itemId": 1, "qtyRefunded": 1.0, "baseRowTotal": 100.00, "baseTaxAmount": 10.00},
        ],
    }
    base.update(over)
    return base


def test_matched_credit_memo_is_ok():
    result = decide_credit_memo_discrepancy(credit_memo(), invoice(), [])
    assert result["reason"] == "ok"
    assert result["isDiscrepant"] is False


def test_tax_mismatch_is_flagged():
    cm = credit_memo(baseTaxAmount=20.00, baseGrandTotal=120.00)
    result = decide_credit_memo_discrepancy(cm, invoice(), [])
    assert result["reason"] == "tax_mismatch"
    assert result["isDiscrepant"] is True
    assert result["expectedTaxAmount"] == 10.00


def test_grand_total_mismatch_is_flagged():
    cm = credit_memo(baseShippingAmount=15.00, baseGrandTotal=110.00)
    result = decide_credit_memo_discrepancy(cm, invoice(), [])
    assert result["reason"] == "grand_total_mismatch"
    assert result["isDiscrepant"] is True


def test_over_refund_beats_other_reasons():
    prior = [{"baseGrandTotal": 150.00}]
    cm = credit_memo(baseGrandTotal=110.00)
    result = decide_credit_memo_discrepancy(cm, invoice(), prior)
    assert result["reason"] == "over_refund"
    assert result["isDiscrepant"] is True


def test_within_tolerance_is_ok():
    cm = credit_memo(baseGrandTotal=110.004)
    result = decide_credit_memo_discrepancy(cm, invoice(), [], tolerance_cents=0.01)
    assert result["reason"] == "ok"


def test_expected_totals_prorate_by_refunded_qty():
    inv = invoice(items=[
        {"itemId": 1, "qtyInvoiced": 4.0, "baseTaxAmount": 40.00, "baseRowTotal": 400.00},
    ])
    cm = credit_memo(items=[
        {"itemId": 1, "qtyRefunded": 1.0, "baseRowTotal": 100.00, "baseTaxAmount": 10.00},
    ], baseGrandTotal=110.00, baseTaxAmount=10.00)
    result = decide_credit_memo_discrepancy(cm, inv, [])
    assert result["expectedTaxAmount"] == 10.00
    assert result["expectedGrandTotal"] == 110.00
    assert result["reason"] == "ok"
creditmemo-discrepancy.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideCreditMemoDiscrepancy } from "./flag-creditmemo-discrepancy.js";

const invoice = (over = {}) => ({
  entityId: 900,
  baseGrandTotal: 220.00,
  baseTaxAmount: 20.00,
  items: [
    { itemId: 1, qtyInvoiced: 2.0, baseTaxAmount: 20.00, baseRowTotal: 200.00 },
  ],
  ...over,
});

const creditMemo = (over = {}) => ({
  entityId: 700,
  incrementId: "100000700",
  invoiceId: 900,
  baseGrandTotal: 110.00,
  baseTaxAmount: 10.00,
  baseShippingAmount: 0,
  adjustmentPositive: 0,
  adjustmentNegative: 0,
  items: [
    { itemId: 1, qtyRefunded: 1.0, baseRowTotal: 100.00, baseTaxAmount: 10.00 },
  ],
  ...over,
});

test("matched credit memo is ok", () => {
  const result = decideCreditMemoDiscrepancy(creditMemo(), invoice(), []);
  assert.equal(result.reason, "ok");
  assert.equal(result.isDiscrepant, false);
});

test("tax mismatch is flagged", () => {
  const cm = creditMemo({ baseTaxAmount: 20.00, baseGrandTotal: 120.00 });
  const result = decideCreditMemoDiscrepancy(cm, invoice(), []);
  assert.equal(result.reason, "tax_mismatch");
  assert.equal(result.isDiscrepant, true);
  assert.equal(result.expectedTaxAmount, 10.00);
});

test("grand total mismatch is flagged", () => {
  const cm = creditMemo({ baseShippingAmount: 15.00, baseGrandTotal: 110.00 });
  const result = decideCreditMemoDiscrepancy(cm, invoice(), []);
  assert.equal(result.reason, "grand_total_mismatch");
  assert.equal(result.isDiscrepant, true);
});

test("over refund beats other reasons", () => {
  const prior = [{ baseGrandTotal: 150.00 }];
  const cm = creditMemo({ baseGrandTotal: 110.00 });
  const result = decideCreditMemoDiscrepancy(cm, invoice(), prior);
  assert.equal(result.reason, "over_refund");
  assert.equal(result.isDiscrepant, true);
});

test("within tolerance is ok", () => {
  const cm = creditMemo({ baseGrandTotal: 110.004 });
  const result = decideCreditMemoDiscrepancy(cm, invoice(), [], 0.01);
  assert.equal(result.reason, "ok");
});

test("expected totals prorate by refunded qty", () => {
  const inv = invoice({
    items: [{ itemId: 1, qtyInvoiced: 4.0, baseTaxAmount: 40.00, baseRowTotal: 400.00 }],
  });
  const cm = creditMemo({
    items: [{ itemId: 1, qtyRefunded: 1.0, baseRowTotal: 100.00, baseTaxAmount: 10.00 }],
    baseGrandTotal: 110.00,
    baseTaxAmount: 10.00,
  });
  const result = decideCreditMemoDiscrepancy(cm, inv, []);
  assert.equal(result.expectedTaxAmount, 10.00);
  assert.equal(result.expectedGrandTotal, 110.00);
  assert.equal(result.reason, "ok");
});

Case studies

Tax mismatch

The split shipment that overcharged tax on the refund

A furniture store shipped a large order in two waves because one item was backordered, generating two invoices. When the customer returned the backordered item, the credit memo against the second invoice carried a tax amount that matched the entire order's aggregate tax rather than just that item's share, overrefunding tax by a small but consistent amount on every split order.

The reconciliation job, run against every order with more than one invoice, flagged the credit memo as tax_mismatch, showing the expected tax prorated strictly from the second invoice's own line item. Finance used the report to true up the difference with a deliberate compensating credit memo, and reviewed the store's split-shipment tax configuration to stop it from recurring.

Over refund

The one that would have paid the customer twice

An order had two invoices, and a support agent issued a partial credit memo against each invoice for the same returned item after a miscommunication about which invoice it belonged to. Individually, each credit memo looked plausible against its own invoice, so nothing in the admin UI made the double refund obvious.

The script's per-invoice check summed prior credit memos issued against that invoice before evaluating the new one, and caught the second credit memo as over_refund the moment its total plus the first credit memo exceeded the invoice's own grand total. The team caught it before the second refund transaction settled.

What good looks like

After this runs on a schedule, a wrong credit memo total on a split order is caught within one polling cycle instead of surviving as a quiet reconciliation gap. The report carries the exact order_increment_id, creditmemo_increment_id, and invoice_id affected, the expected versus actual totals, and the reason, so finance can decide on a deliberate compensating refund rather than guessing at which number was right.

FAQ

Why is my Magento credit memo total wrong when the order has more than one invoice?

Magento's credit memo total collectors, such as Magento\Sales\Model\Order\Creditmemo\Total\Tax and the related shipping and discount collectors, compute refundable tax and totals mainly from the parent order's aggregate tax_amount rather than proportionally from the one invoice actually being refunded. When an order was split into two or more invoices, each invoice and credit memo pair needs to prorate tax and shipping by the items actually invoiced and refunded, and the collectors do not consistently subtract tax already refunded by earlier credit memos tied to earlier invoices on the same order, so a credit memo on the second or later invoice can double count or omit tax and shipping.

Can I fix a wrong credit memo total through the Magento REST API?

No, not safely. A credit memo is an immutable financial record tied to a payment or refund transaction, and there is no supported REST endpoint to mutate its totals after it is created. Recomputing it correctly requires re-running the core total collectors, which are not exposed over the API. The safe pattern is to detect and report the discrepancy, then, only with explicit authorization, issue a new offsetting credit memo or adjustment rather than editing the original record.

How do I detect that a credit memo's total is wrong on a multi invoice order?

Pull the parent invoice for the credit memo over GET /rest/V1/invoices filtered by order_id, prorate each invoice item's tax by qty_invoiced to get an expected per-unit tax, multiply by the refunded qty on the credit memo, and add shipping and adjustments to get an expected grand total and expected tax amount. Compare those against the credit memo's actual base_grand_total and base_tax_amount with a small tolerance, and separately check that the sum of credit memos issued against that invoice never exceeds the invoice total, which flags an over refund.

Related field notes

Citations

On the problem:

  1. GitHub Issue: wrong credit memo total sum when the order is split into multiple invoices. github.com/magento/magento2/issues/26777
  2. GitHub Issue: tax for the complete order applied when refunding a partial order. github.com/magento/magento2/issues/32222
  3. GitHub Issue: issue in partial credit memo including total tax. github.com/magento/magento2/issues/14713

On the solution:

  1. Adobe Commerce and Magento User Guide: credit memos. experienceleague.adobe.com credit memos
  2. Adobe Commerce: Order Management REST API reference for refunding an order. developer.adobe.com order management REST API reference
  3. Adobe Commerce: CreditmemoRepositoryInterface in the Sales module. developer.adobe.com sales module reference

Stuck on a tricky one?

If you have a problem in Magento 2 or Adobe Commerce orders, cron, catalog data, or inventory 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 catch a refund that did not add up?

If this saved you a confusing finance reconciliation or a customer refunded the wrong amount, 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