Skip to content

Diagnostic Credit Memos and Refunds

Credit memo grand total not refreshed after adjustment edit

A refund needs a little extra for return shipping, or a small deduction for a restocking fee, so someone opens the credit memo screen and types a number into Refund Shipping or Adjustment Fee. The grand total on screen does not move. They save anyway, and now there is a credit memo whose total does not match what its own fields add up to. Here is why only quantity changes ever trigger a recalculation, and a small script that finds every credit memo where the math does not check out.

Python and Node.js Creditmemo REST API Safe by default (report only)
A calculator next to a laptop
Photo by Mehdi Mirzaie on Unsplash
The short answer

In Magento 2's admin credit memo creation form, the grand total displayed and saved is only recalculated by the Update Qty's JavaScript handler, which fires on item quantity changes. It is never wired to the Refund Shipping, Adjustment Refund (adjustment_positive), or Adjustment Fee (adjustment_negative) input fields. Editing those fields alone leaves grand_total stale in both the UI and the persisted record unless a qty update or the actual refund submission forces Magento's server side total collectors to run. The same class of drift is reachable through POST /V1/creditmemo, since the API does not independently re-validate that grand_total equals subtotal - discount_amount + shipping_amount + tax_amount + adjustment_positive - adjustment_negative. A script can list credit memos over the REST API, recompute the expected total from their own fields, and flag anything that disagrees by more than a cent. Full code, tests, and a dry run guard are below.

The problem in plain words

The admin credit memo creation screen is a form with several inputs that all feed into one number at the bottom: the grand total. Change how many units of an item are being refunded, and a JavaScript handler called Update Qty's runs, recalculates every dependent total, and repaints the grand total on screen. That part works exactly as expected.

But Refund Shipping, Adjustment Refund, and Adjustment Fee are not wired to that same handler. Type a new shipping refund amount or a restocking fee into one of those boxes, and nothing recalculates. The number at the bottom of the screen keeps showing whatever it showed before you touched the field. If the person creating the refund trusts what is on screen and saves, the credit memo that gets created can carry a grand_total that never accounted for the adjustment they just typed in, because only a qty change, or the server side total collectors that run during the actual refund submission, ever force a real recalculation.

User edits Refund Shipping / Adjustment Fee No handler wired these fields never fire it Update Qty's only fires on qty change Grand total stale on screen and on save grand_total disagrees with fields Same gap reachable via POST /V1/creditmemo API does not re-validate the total either
Only a quantity change, or the server side total collectors run at actual submission, recalculate the grand total. Refund Shipping and the two adjustment fields are never wired to that recalculation.

Why it happens

This is one of the oldest, most-referenced credit memo complaints in Magento's core issue tracker and its community forum, precisely because it is easy to reproduce by hand: open a credit memo, type into Adjustment Fee, watch nothing change. See the citations at the end for the exact threads.

The key insight

A credit memo, once created, is treated as an immutable financial record. There is no supported PUT or PATCH endpoint that lets you overwrite a posted creditmemo's grand_total, and a correct recomputation has to run through Magento's own total collectors, not a raw field write, or the ledger can end up disagreeing with what was actually sent to the payment gateway. So the only safe thing a script can do is independently recompute what grand_total should be from the creditmemo's own subtotal, discount_amount, shipping_amount, tax_amount, and adjustment fields, and report any credit memo where that expected number and the stored number disagree.

The fix, as a flow

We do not touch any existing credit memo. We add a job that lists recent credit memos over the REST API, recomputes the expected grand total from each one's own subtotal, discount, shipping, tax, and adjustment fields, and reports every record where the stored total drifts from that recomputation by more than a cent.

Scheduled job runs on a timer List recent credit memos GET /creditmemos, by created_at Recompute expected total subtotal, shipping, tax, adjustments Delta over epsilon? yes no, report ok Flag drifted record report row for review
The script only ever reports a drifted credit memo. It never writes to an existing record, since that would bypass Magento's own total collectors.

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 LOOKBACK_DAYS="7"
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 LOOKBACK_DAYS="7"
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 recent credit memos

Call GET /rest/V1/creditmemos with a searchCriteria filter on created_at using conditionType=gteq for your lookback window, plus pageSize and currentPage for paging. Read back entity_id, increment_id, order_id, subtotal, discount_amount, shipping_amount, tax_amount, adjustment_positive, adjustment_negative, and grand_total, since the decision function needs all of them.

step3.py
def recent_creditmemos(since_iso, page_size=100, current_page=1):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
        "searchCriteria[filterGroups][0][filters][0][value]": since_iso,
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    return magento_get("/creditmemos", params)["items"]
step3.js
async function recentCreditmemos(sinceIso, pageSize = 100, currentPage = 1) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
    "searchCriteria[filterGroups][0][filters][0][value]": sinceIso,
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": currentPage,
  };
  const data = await magentoGet("/creditmemos", params);
  return data.items;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the credit memo's own totals and returns the expected grand total, the delta, and whether it is drifted. A pure function like this is easy to read and easy to test, which we do later. It recomputes expected_grand_total as subtotal - discount_amount + shipping_amount + tax_amount + adjustment_positive - adjustment_negative, rounds to two decimal places, and flags drift when the absolute delta against the stored grand_total exceeds a small epsilon, by default one cent.

decide.py
def evaluate_creditmemo_total_drift(creditmemo, epsilon=0.01):
    expected_grand_total = round(
        creditmemo["subtotal"]
        - creditmemo["discountAmount"]
        + creditmemo["shippingAmount"]
        + creditmemo["taxAmount"]
        + creditmemo["adjustmentPositive"]
        - creditmemo["adjustmentNegative"],
        2,
    )
    delta = round(creditmemo["grandTotal"] - expected_grand_total, 2)
    is_drifted = abs(delta) > epsilon
    return {
        "expectedGrandTotal": expected_grand_total,
        "delta": delta,
        "isDrifted": is_drifted,
    }
decide.js
export function evaluateCreditmemoTotalDrift(creditmemo, epsilon = 0.01) {
  const expectedGrandTotal = round2(
    creditmemo.subtotal
    - creditmemo.discountAmount
    + creditmemo.shippingAmount
    + creditmemo.taxAmount
    + creditmemo.adjustmentPositive
    - creditmemo.adjustmentNegative
  );
  const delta = round2(creditmemo.grandTotal - expectedGrandTotal);
  const isDrifted = Math.abs(delta) > epsilon;
  return { expectedGrandTotal, delta, isDrifted };
}

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 drifted credit memo: entity_id, increment_id, order_id, stored_grand_total, expected_grand_total, delta, and the contributing fields, for a merchant or developer to review in Sales, Credit Memos. There is no code path in this script that edits an existing credit memo, because there is no supported PUT or PATCH endpoint for it and creditmemo entities are meant to be immutable financial records.

6

Wire it together with a dry run guard

The loop ties every piece together. DRY_RUN only changes log verbosity here, since this script never writes: it reports every drifted credit memo either way. Any real correction, such as a custom totals-recollection script that calls Magento's actual Creditmemo\Total\* collectors and CreditmemoRepositoryInterface::save on a non-posted or draft record, belongs behind its own separate DRY_RUN=false plus manual approval, never as an automatic follow-up to this detector.

Run it safe

This script never writes to an existing credit memo. There is no supported endpoint to overwrite a posted creditmemo's grand_total, so every drift found here is a lead for a human to review in the admin, Sales, Credit Memos, or correct through a supported flow such as void and reissue.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through recent credit memos, recomputes the expected grand total 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_total_drift.py
"""Flag Magento 2 credit memos whose grand_total was never refreshed after an
adjustment edit.

In the admin credit memo creation form, the grand total shown and saved is
only recalculated by the Update Qty's JavaScript handler, which fires on item
quantity changes. It is never wired to the Refund Shipping, Adjustment Refund
(adjustment_positive), or Adjustment Fee (adjustment_negative) input fields,
so editing those alone can leave grand_total stale in both the UI and the
persisted record unless a qty update or the actual submission forces
Magento's server side total collectors to run. The same drift is reachable
through POST /V1/creditmemo, since the API does not independently re-validate
the total. There is no supported endpoint to fix a posted creditmemo's
grand_total, so this only reports the drift. Run on a schedule. Safe to run
again and again.
"""
import os
import logging
import datetime
import requests

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

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
LOOKBACK_DAYS = float(os.environ.get("LOOKBACK_DAYS", "7"))
EPSILON = float(os.environ.get("EPSILON", "0.01"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


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 since_iso(lookback_days):
    since = datetime.datetime.utcnow() - datetime.timedelta(days=lookback_days)
    return since.strftime("%Y-%m-%d %H:%M:%S")


def recent_creditmemos(since, page_size=100, current_page=1):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
        "searchCriteria[filterGroups][0][filters][0][value]": since,
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    return magento_get("/creditmemos", params)["items"]


def normalize_creditmemo(raw):
    return {
        "entityId": raw.get("entity_id"),
        "incrementId": raw.get("increment_id"),
        "orderId": raw.get("order_id"),
        "subtotal": float(raw.get("subtotal") or 0),
        "discountAmount": float(raw.get("discount_amount") or 0),
        "shippingAmount": float(raw.get("shipping_amount") or 0),
        "taxAmount": float(raw.get("tax_amount") or 0),
        "adjustmentPositive": float(raw.get("adjustment_positive") or 0),
        "adjustmentNegative": float(raw.get("adjustment_negative") or 0),
        "grandTotal": float(raw.get("grand_total") or 0),
    }


def evaluate_creditmemo_total_drift(creditmemo, epsilon=0.01):
    expected_grand_total = round(
        creditmemo["subtotal"]
        - creditmemo["discountAmount"]
        + creditmemo["shippingAmount"]
        + creditmemo["taxAmount"]
        + creditmemo["adjustmentPositive"]
        - creditmemo["adjustmentNegative"],
        2,
    )
    delta = round(creditmemo["grandTotal"] - expected_grand_total, 2)
    is_drifted = abs(delta) > epsilon
    return {
        "expectedGrandTotal": expected_grand_total,
        "delta": delta,
        "isDrifted": is_drifted,
    }


def run():
    since = since_iso(LOOKBACK_DAYS)
    flagged = []
    page = 1
    while True:
        raw_items = recent_creditmemos(since, current_page=page)
        if not raw_items:
            break
        for raw in raw_items:
            creditmemo = normalize_creditmemo(raw)
            result = evaluate_creditmemo_total_drift(creditmemo, EPSILON)
            if result["isDrifted"]:
                flagged.append({**creditmemo, **result})
        if len(raw_items) < 100:
            break
        page += 1

    for row in flagged:
        log.warning(
            "Creditmemo %s (order %s) grand_total drifted. stored=%.2f expected=%.2f delta=%.2f",
            row["incrementId"], row["orderId"], row["grandTotal"], row["expectedGrandTotal"], row["delta"],
        )

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


if __name__ == "__main__":
    run()
flag-creditmemo-total-drift.js
/**
 * Flag Magento 2 credit memos whose grand_total was never refreshed after an
 * adjustment edit.
 *
 * In the admin credit memo creation form, the grand total shown and saved is
 * only recalculated by the Update Qty's JavaScript handler, which fires on
 * item quantity changes. It is never wired to the Refund Shipping,
 * Adjustment Refund (adjustment_positive), or Adjustment Fee
 * (adjustment_negative) input fields, so editing those alone can leave
 * grand_total stale in both the UI and the persisted record unless a qty
 * update or the actual submission forces Magento's server side total
 * collectors to run. The same drift is reachable through POST
 * /V1/creditmemo, since the API does not independently re-validate the
 * total. There is no supported endpoint to fix a posted creditmemo's
 * grand_total, so this only reports the drift. Run on a schedule. Safe to
 * run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/credit-memo-total-not-refreshed/
 */
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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const EPSILON = Number(process.env.EPSILON || 0.01);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function evaluateCreditmemoTotalDrift(creditmemo, epsilon = 0.01) {
  const expectedGrandTotal = round2(
    creditmemo.subtotal
    - creditmemo.discountAmount
    + creditmemo.shippingAmount
    + creditmemo.taxAmount
    + creditmemo.adjustmentPositive
    - creditmemo.adjustmentNegative
  );
  const delta = round2(creditmemo.grandTotal - expectedGrandTotal);
  const isDrifted = Math.abs(delta) > epsilon;
  return { expectedGrandTotal, delta, isDrifted };
}

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

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

function sinceIso(lookbackDays) {
  const since = new Date(Date.now() - lookbackDays * 86400 * 1000);
  return since.toISOString().slice(0, 19).replace("T", " ");
}

async function recentCreditmemos(since, pageSize = 100, currentPage = 1) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
    "searchCriteria[filterGroups][0][filters][0][value]": since,
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": currentPage,
  };
  const data = await magentoGet("/creditmemos", params);
  return data.items;
}

function normalizeCreditmemo(raw) {
  return {
    entityId: raw.entity_id,
    incrementId: raw.increment_id,
    orderId: raw.order_id,
    subtotal: Number(raw.subtotal || 0),
    discountAmount: Number(raw.discount_amount || 0),
    shippingAmount: Number(raw.shipping_amount || 0),
    taxAmount: Number(raw.tax_amount || 0),
    adjustmentPositive: Number(raw.adjustment_positive || 0),
    adjustmentNegative: Number(raw.adjustment_negative || 0),
    grandTotal: Number(raw.grand_total || 0),
  };
}

export async function run() {
  const since = sinceIso(LOOKBACK_DAYS);
  const flagged = [];
  let page = 1;

  while (true) {
    const rawItems = await recentCreditmemos(since, 100, page);
    if (!rawItems.length) break;

    for (const raw of rawItems) {
      const creditmemo = normalizeCreditmemo(raw);
      const result = evaluateCreditmemoTotalDrift(creditmemo, EPSILON);
      if (result.isDrifted) flagged.push({ ...creditmemo, ...result });
    }

    if (rawItems.length < 100) break;
    page++;
  }

  for (const row of flagged) {
    console.warn(
      `Creditmemo ${row.incrementId} (order ${row.orderId}) grand_total drifted. ` +
      `stored=${row.grandTotal.toFixed(2)} expected=${row.expectedGrandTotal.toFixed(2)} delta=${row.delta.toFixed(2)}`
    );
  }

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

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

Add a test

The drift rule is the part most worth testing, because it decides whether a credit memo gets flagged for review. Because we kept evaluate_creditmemo_total_drift pure, the test needs no network, no Magento store, and no admin token. It just feeds in plain fixture totals and checks the answer.

test_creditmemo_drift.py
from flag_creditmemo_total_drift import evaluate_creditmemo_total_drift


def creditmemo(**over):
    base = {
        "subtotal": 100.0,
        "discountAmount": 0.0,
        "shippingAmount": 10.0,
        "taxAmount": 8.0,
        "adjustmentPositive": 0.0,
        "adjustmentNegative": 0.0,
        "grandTotal": 118.0,
    }
    base.update(over)
    return base


def test_matching_totals_are_not_drifted():
    result = evaluate_creditmemo_total_drift(creditmemo())
    assert result["isDrifted"] is False
    assert result["expectedGrandTotal"] == 118.0
    assert result["delta"] == 0.0


def test_over_refunded_grand_total_is_drifted():
    result = evaluate_creditmemo_total_drift(creditmemo(grandTotal=140.0))
    assert result["isDrifted"] is True
    assert result["delta"] == 22.0


def test_under_refunded_grand_total_is_drifted():
    result = evaluate_creditmemo_total_drift(creditmemo(grandTotal=100.0))
    assert result["isDrifted"] is True
    assert result["delta"] == -18.0


def test_stale_after_adjustment_fee_typed_but_not_recalculated():
    # Adjustment Fee (adjustment_negative) was typed in but grand_total never moved.
    cm = creditmemo(adjustmentNegative=15.0, grandTotal=118.0)
    result = evaluate_creditmemo_total_drift(cm)
    assert result["isDrifted"] is True
    assert result["expectedGrandTotal"] == 103.0
    assert result["delta"] == 15.0


def test_zero_shipping_still_matches_when_consistent():
    cm = creditmemo(shippingAmount=0.0, grandTotal=108.0)
    result = evaluate_creditmemo_total_drift(cm)
    assert result["isDrifted"] is False


def test_within_epsilon_is_not_drifted():
    result = evaluate_creditmemo_total_drift(creditmemo(grandTotal=118.005), epsilon=0.01)
    assert result["isDrifted"] is False
creditmemo-drift.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { evaluateCreditmemoTotalDrift } from "./flag-creditmemo-total-drift.js";

const creditmemo = (over = {}) => ({
  subtotal: 100.0,
  discountAmount: 0.0,
  shippingAmount: 10.0,
  taxAmount: 8.0,
  adjustmentPositive: 0.0,
  adjustmentNegative: 0.0,
  grandTotal: 118.0,
  ...over,
});

test("matching totals are not drifted", () => {
  const result = evaluateCreditmemoTotalDrift(creditmemo());
  assert.equal(result.isDrifted, false);
  assert.equal(result.expectedGrandTotal, 118.0);
  assert.equal(result.delta, 0.0);
});

test("over refunded grand total is drifted", () => {
  const result = evaluateCreditmemoTotalDrift(creditmemo({ grandTotal: 140.0 }));
  assert.equal(result.isDrifted, true);
  assert.equal(result.delta, 22.0);
});

test("under refunded grand total is drifted", () => {
  const result = evaluateCreditmemoTotalDrift(creditmemo({ grandTotal: 100.0 }));
  assert.equal(result.isDrifted, true);
  assert.equal(result.delta, -18.0);
});

test("stale after adjustment fee typed but not recalculated", () => {
  const cm = creditmemo({ adjustmentNegative: 15.0, grandTotal: 118.0 });
  const result = evaluateCreditmemoTotalDrift(cm);
  assert.equal(result.isDrifted, true);
  assert.equal(result.expectedGrandTotal, 103.0);
  assert.equal(result.delta, 15.0);
});

test("zero shipping still matches when consistent", () => {
  const cm = creditmemo({ shippingAmount: 0.0, grandTotal: 108.0 });
  const result = evaluateCreditmemoTotalDrift(cm);
  assert.equal(result.isDrifted, false);
});

test("within epsilon is not drifted", () => {
  const result = evaluateCreditmemoTotalDrift(creditmemo({ grandTotal: 118.005 }), 0.01);
  assert.equal(result.isDrifted, false);
});

Case studies

Restocking fee

The support team that typed a fee and trusted the screen

A furniture store deducted a restocking fee on returned items by opening the credit memo screen and typing the amount into Adjustment Fee. The grand total on screen never moved, so the agents assumed the field was informational and saved anyway, week after week.

Running the detection script against three months of credit memos turned up dozens where the stored grand_total was exactly the restocking fee too high, matching the pattern of an adjustment that was typed but never recalculated. Finance now reviews the flagged list weekly and corrects the affected refunds through a supported flow instead of trusting the on screen total.

Return shipping

The API integration that skipped its own math

A merchant's custom returns portal called POST /V1/creditmemo directly to create refunds, including a return shipping adjustment, but a bug in the integration sent a grand_total computed before the shipping adjustment was added to the payload.

The script's recomputation, driven entirely by the credit memo's own stored fields, caught every one of those records without needing to know anything about the returns portal itself. The integration team fixed the payload builder, and the script kept running to confirm no new drift appeared.

What good looks like

After this runs on a schedule, a credit memo whose grand total never caught up with an adjustment edit is caught within one detection cycle instead of sitting quietly in the sales ledger. The report carries the credit memo's increment id, its order, the stored and expected totals, and the exact delta, so whoever responds can decide fast whether to review it in the admin or correct it through a supported flow. Keep the actual correction gated behind a human and Magento's own total collectors, since that is what keeps the script from ever overwriting a financial record on a guess.

FAQ

Why does the grand total on my Magento 2 credit memo not update when I change the refund shipping or adjustment fields?

In the admin credit memo creation form, the grand total shown on screen is only recalculated by the Update Qty's JavaScript handler, which fires when an item quantity changes. It is never wired to the Refund Shipping, Adjustment Refund, or Adjustment Fee inputs, so editing those fields alone leaves grand_total stale in the UI and, unless a qty update or the actual submission runs Magento's server side total collectors, in the saved record too. This is a confirmed, longstanding core bug reproduced across 2.2.x, 2.3.x, and 2.4.x.

Can this same drift happen through the REST API instead of the admin form?

Yes. POST /V1/creditmemo does not independently re-validate that grand_total equals subtotal minus discount plus shipping_amount plus tax_amount plus adjustment_positive minus adjustment_negative for the submitted items, so a client or a partial update flow can create or store a creditmemo whose grand_total disagrees with what its own fields compute to.

Can I fix a drifted credit memo's grand_total through the REST API?

Not safely and not directly. There is no supported PUT or PATCH endpoint to overwrite a posted creditmemo's grand_total, since creditmemos are treated as immutable financial records once created, and a correct recomputation has to run through Magento's server side total collectors, not a raw field write. The safe pattern is to detect and report the drift so a merchant or developer can review it in Sales, Credit Memos, or correct it through a supported flow such as void and reissue.

Related field notes

Citations

On the problem:

  1. GitHub Issue: Grand total in the new Credit memo view is not updated when amounts are changed. github.com/magento/magento2/issues/17341
  2. GitHub Issue: Magento 2.1.9, Refunding / Credit Memo Total Value is not updated. github.com/magento/magento2/issues/11798
  3. Magento Community: discussion thread on the grand total in the new Credit memo view not being updated. community.magento.com grand total in the new credit memo view is not updated

On the solution:

  1. Adobe Commerce: Credit memo endpoints, REST API reference. developer.adobe.com/commerce/webapi/rest/modules/order-credit-memos
  2. Adobe Commerce: search using REST endpoints, including searchCriteria. developer.adobe.com/commerce/webapi/rest/use-rest/performing-searches
  3. Adobe Commerce: CreditmemoRepositoryInterface, Sales module API reference. developer.adobe.com/commerce/webapi/rest/modules/order-credit-memos

Stuck on a tricky one?

If you have a problem in Magento 2 or Adobe Commerce orders, payments, 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 clear up your credit memo totals?

If this saved you a confusing refund report or a credit memo that never added up, 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