Diagnostic Credit Memos and Refunds
Manual invoice missing tax leaves a false amount due
An admin invoices an order in two passes, say the simple products first and a virtual product on its own, using Sales, Orders, Invoice in the Admin. Every item ends up invoiced, the merchant is sure the order is fully paid, but the order still shows an amount due. Nobody touched a discount or shipping. The gap is tax, and it is missing from the second invoice because of a real, reproduced core bug, not a store misconfiguration. Here is why it happens and a script that finds every order it hit.
When an admin manually creates a partial invoice, invoicing a subset of an order's items rather than all of them at once, Magento's Sales\Model\Order\Invoice\Total collectors for Tax, Subtotal, and Grand Total prorate tax across invoices using each item's invoiced quantity ratio. A documented core bug, magento2 issue 38978, reproduced on 2.4.3-p3, causes the tax portion for items that land on a later invoice to be dropped instead of allocated. That invoice's base_tax_amount and base_grand_total come out short by exactly the missing item's tax. Because Magento only derives the order's total_paid by summing each invoice's own already wrong grand_total, never by re-deriving it independently, total_paid ends up less than base_grand_total, and the order shows a total_due that should not exist. Full code, tests, and a dry run guard are below.
The problem in plain words
Invoicing an order in one shot, through the invoice link on a fresh order, almost always works the way you expect. The trouble shows up when an admin invoices it in pieces from Sales, Orders, Invoice, for example creating an invoice for the simple products in the cart and, separately, a second invoice for a virtual product or a later shipment of items.
Each invoice is supposed to carry its fair share of the order's tax, prorated by how much of each item's quantity that invoice covers. On the second, later invoice, the totals collector loses the tax slice that belongs to the items on that invoice instead of adding it in. The invoice still shows the right item prices and quantities. Only its tax line and, as a result, its grand total, come out short. Multiply that shortfall across every partial invoice pattern in the store and the order's books stop matching what was actually collected.
Why it happens
The design intent is sound: Sales\Model\Order\Invoice\Total\Tax, alongside the Subtotal and Grand Total collectors, is supposed to prorate the order's tax across every invoice by the ratio of quantity invoiced on that invoice to quantity ordered for each line. A core bug in that collector chain instead drops the tax for items that land on a second or later invoice. A few things make this easy to miss:
- It only shows up on manually created, partial invoices from Sales, Orders, Invoice, not on the single, full invoice most orders get.
- It has been reproduced specifically when simple products are invoiced separately from a virtual product, but the underlying proration logic is not limited to that one product mix.
- The invoice's line items and quantities all look correct. Only
base_tax_amountand, downstream,base_grand_totalare short, so a quick glance at the invoice grid does not raise a flag. - Magento never re-derives the order's
total_paidindependently. It only sums each invoice's owngrand_total, so a short invoice total quietly becomes a short order total, andtotal_dueshows a balance even though the merchant considers every item invoiced.
This is a real, reported core defect, confirmed to reproduce on 2.4.3-p3, not a one off store misconfiguration. See the citations at the end for the exact GitHub issues that document it.
An invoice is an immutable financial record once it exists, and there is no supported REST write that edits an existing invoice's totals. So the only responsible move is to detect the shortfall independently, by comparing the order's own base_grand_total and base_tax_amount against what its invoices actually total, and report it with enough detail that a human can reconcile the order, for example with a credit memo without invoice or by cancelling and reissuing the affected invoice, rather than trying to patch a posted document.
The fix, as a flow
For each order we care about, we read the order's base_grand_total, base_tax_amount, and total_due, then pull every invoice issued against that order and sum their base_grand_total and base_tax_amount. We flag the order only when there is a real total_due, the invoiced grand total falls short of the order's grand total, and that shortfall is attributable to tax specifically, not to shipping, discount, or a genuinely un-invoiced item.
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 AMOUNT_EPSILON="0.01"
export DRY_RUN="true" # report only, this script never edits an invoice
// 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 AMOUNT_EPSILON="0.01"
export DRY_RUN="true" // report only, this script never edits an invoice
Read the order's totals
POST to /rest/V1/integration/admin/token for a bearer token, then GET /rest/V1/orders/{orderId}. Keep base_grand_total, base_tax_amount, and total_due, since those are the independent baseline every invoice sum 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 every invoice issued against that order
GET /rest/V1/invoices with a searchCriteria filter on order_id using conditionType=eq, and page through with pageSize and currentPage in case an order has many invoices. Each result carries its own base_grand_total and base_tax_amount, which is exactly what we sum and compare.
def get_invoices_for_order(token, order_id, page_size=100):
invoices = []
page = 1
while True:
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]": page_size,
"searchCriteria[currentPage]": page,
}
r = requests.get(
f"{MAGENTO_URL}/rest/V1/invoices",
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
items = body.get("items", [])
invoices.extend(items)
if len(items) < page_size:
return invoices
page += 1
async function getInvoicesForOrder(token, orderId, pageSize = 100) {
const invoices = [];
let page = 1;
while (true) {
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]": String(pageSize),
"searchCriteria[currentPage]": String(page),
});
const res = await fetch(`${MAGENTO_URL}/rest/V1/invoices?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
const items = body.items || [];
invoices.push(...items);
if (items.length < pageSize) return invoices;
page += 1;
}
}
Decide, with one pure function
Keep the decision in its own function that takes only plain numbers already pulled from the order and invoice JSON: the order's base_grand_total, base_tax_amount, and total_due, plus the list of invoice totals. It sums the invoiced grand total and tax, computes both deltas, and flags the order only when total_due is real and both the grand total and the tax are short, which is the tell tale signature of this bug rather than a normal partial invoice in progress.
def detect_invoice_tax_shortfall(order, invoices, epsilon=0.01):
invoiced_grand_total = sum(inv.get("baseGrandTotal", 0) or 0 for inv in invoices)
invoiced_tax = sum(inv.get("baseTaxAmount", 0) or 0 for inv in invoices)
grand_total_delta = order["baseGrandTotal"] - invoiced_grand_total
tax_delta = order["baseTaxAmount"] - invoiced_tax
is_shortfall = (
order["totalDue"] > epsilon
and tax_delta > epsilon
and grand_total_delta > epsilon
)
return {
"isShortfall": is_shortfall,
"invoicedGrandTotal": invoiced_grand_total,
"invoicedTax": invoiced_tax,
"taxDelta": tax_delta,
"grandTotalDelta": grand_total_delta,
}
export function detectInvoiceTaxShortfall(order, invoices, epsilon = 0.01) {
const invoicedGrandTotal = invoices.reduce((sum, inv) => sum + (inv.baseGrandTotal || 0), 0);
const invoicedTax = invoices.reduce((sum, inv) => sum + (inv.baseTaxAmount || 0), 0);
const grandTotalDelta = order.baseGrandTotal - invoicedGrandTotal;
const taxDelta = order.baseTaxAmount - invoicedTax;
const isShortfall = order.totalDue > epsilon && taxDelta > epsilon && grandTotalDelta > epsilon;
return { isShortfall, invoicedGrandTotal, invoicedTax, taxDelta, grandTotalDelta };
}
There is no REST write for this, build a report record instead
Invoice totals are immutable once created, and there is no PUT endpoint on /rest/V1/invoices/{id} for totals. When a shortfall is confirmed, the safe action is to write a report record with the order id, increment id, expected tax, invoiced tax, delta, and the affected invoice ids, then exit non-zero so CI or alerting can pick it up. A human reconciles the order in the Admin, for example with a credit memo without invoice or by cancelling and reissuing the affected invoice.
def build_report_row(order, invoices, result):
return {
"order_id": order["entity_id"],
"increment_id": order["increment_id"],
"expected_tax": round(order["baseTaxAmount"], 4),
"invoiced_tax": round(result["invoicedTax"], 4),
"delta": round(result["taxDelta"], 4),
"invoice_ids": [inv["entity_id"] for inv in invoices],
}
function buildReportRow(order, invoices, result) {
return {
order_id: order.entity_id,
increment_id: order.increment_id,
expected_tax: Math.round(order.baseTaxAmount * 10000) / 10000,
invoiced_tax: Math.round(result.invoicedTax * 10000) / 10000,
delta: Math.round(result.taxDelta * 10000) / 10000,
invoice_ids: invoices.map((inv) => inv.entity_id),
};
}
Wire it together and exit non-zero on a hit
The loop authenticates once, walks every configured order id, pulls its totals and invoices, runs the pure shortfall function, and writes a report row for every order it flags. DRY_RUN defaults to true and, either way, this script never writes to Magento. The only thing DRY_RUN=false changes is whether it also prints the CSV path to stdout for a pipeline to pick up; the script always exits non-zero when it finds at least one shortfall, so CI or alerting notices.
This script never edits, voids, or cancels an invoice, because Magento has no endpoint for that. It reports the order id, increment id, expected tax, invoiced tax, delta, and invoice ids so a human can reconcile it, then exits non-zero so the finding is not silently missed.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, and only ever performs reads, never a write to an order, invoice, or credit memo.
"""Detect Magento 2 or Adobe Commerce orders where a manually created partial
invoice dropped its share of tax, leaving a false amount due.
When an admin manually invoices an order in more than one pass, for example
invoicing simple products separately from a virtual product via Sales,
Orders, Invoice, Magento's Sales\\Model\\Order\\Invoice\\Total collectors for
Tax, Subtotal, and Grand Total prorate tax across invoices by each item's
invoiced quantity ratio. A documented core bug, magento2 issue 38978,
reproduced on 2.4.3-p3, causes the tax portion belonging to items on a later
invoice to be dropped instead of allocated. That invoice's base_tax_amount
and base_grand_total come out short by exactly the missing item's tax.
Because Magento only derives total_paid by summing each invoice's own
already wrong grand_total, the order ends up with a total_due that should
not exist.
This script never edits, voids, or cancels an invoice, since Magento has no
supported REST write for that. It compares the order's own base_grand_total
and base_tax_amount against what its invoices actually total, writes a
report row for every order it flags, and exits non-zero so CI or alerting
notices. A human reconciles the order in the Admin, for example with a
credit memo without invoice or by cancelling and reissuing the affected
invoice. Safe to run again and again.
"""
import os
import csv
import sys
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_invoice_tax_shortfall")
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()]
AMOUNT_EPSILON = float(os.environ.get("AMOUNT_EPSILON", "0.01"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OUTPUT_CSV = os.environ.get("OUTPUT_CSV", "invoice_tax_shortfalls.csv")
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "100"))
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_invoices_for_order(token, order_id, page_size=PAGE_SIZE):
invoices = []
page = 1
while True:
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]": page_size,
"searchCriteria[currentPage]": page,
}
r = requests.get(
f"{MAGENTO_URL}/rest/V1/invoices",
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
items = body.get("items", [])
invoices.extend(items)
if len(items) < page_size:
return invoices
page += 1
def detect_invoice_tax_shortfall(order, invoices, epsilon=AMOUNT_EPSILON):
invoiced_grand_total = sum(inv.get("baseGrandTotal", 0) or 0 for inv in invoices)
invoiced_tax = sum(inv.get("baseTaxAmount", 0) or 0 for inv in invoices)
grand_total_delta = order["baseGrandTotal"] - invoiced_grand_total
tax_delta = order["baseTaxAmount"] - invoiced_tax
is_shortfall = (
order["totalDue"] > epsilon
and tax_delta > epsilon
and grand_total_delta > epsilon
)
return {
"isShortfall": is_shortfall,
"invoicedGrandTotal": invoiced_grand_total,
"invoicedTax": invoiced_tax,
"taxDelta": tax_delta,
"grandTotalDelta": grand_total_delta,
}
def order_to_struct(order):
return {
"baseGrandTotal": order.get("base_grand_total", 0) or 0,
"baseTaxAmount": order.get("base_tax_amount", 0) or 0,
"totalDue": order.get("total_due", 0) or 0,
}
def invoice_to_struct(invoice):
return {
"baseGrandTotal": invoice.get("base_grand_total", 0) or 0,
"baseTaxAmount": invoice.get("base_tax_amount", 0) or 0,
}
def build_report_row(order, invoices, result):
return {
"order_id": order.get("entity_id"),
"increment_id": order.get("increment_id"),
"expected_tax": round(order.get("base_tax_amount", 0) or 0, 4),
"invoiced_tax": round(result["invoicedTax"], 4),
"delta": round(result["taxDelta"], 4),
"invoice_ids": ";".join(str(inv.get("entity_id")) for inv in invoices),
}
def run():
token = get_token()
flagged = []
for order_id in ORDER_IDS:
order = get_order(token, order_id)
invoices = get_invoices_for_order(token, order_id)
result = detect_invoice_tax_shortfall(
order_to_struct(order),
[invoice_to_struct(inv) for inv in invoices],
)
if not result["isShortfall"]:
continue
row = build_report_row(order, invoices, result)
flagged.append(row)
log.warning(
"Order %s missing invoice tax: expected_tax=%s invoiced_tax=%s delta=%s invoice_ids=%s",
row["increment_id"], row["expected_tax"], row["invoiced_tax"], row["delta"], row["invoice_ids"],
)
if flagged:
with open(OUTPUT_CSV, "w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=[
"order_id", "increment_id", "expected_tax", "invoiced_tax", "delta", "invoice_ids",
])
writer.writeheader()
writer.writerows(flagged)
log.info("Wrote report to %s%s", OUTPUT_CSV, "" if not DRY_RUN else " (dry run, report only)")
log.info("Done. %d order(s) flagged with a missing invoice tax shortfall.", len(flagged))
return flagged
if __name__ == "__main__":
flagged_orders = run()
sys.exit(1 if flagged_orders else 0)
/**
* Detect Magento 2 or Adobe Commerce orders where a manually created partial
* invoice dropped its share of tax, leaving a false amount due.
*
* When an admin manually invoices an order in more than one pass, for example
* invoicing simple products separately from a virtual product via Sales,
* Orders, Invoice, Magento's Sales\Model\Order\Invoice\Total collectors for
* Tax, Subtotal, and Grand Total prorate tax across invoices by each item's
* invoiced quantity ratio. A documented core bug, magento2 issue 38978,
* reproduced on 2.4.3-p3, causes the tax portion belonging to items on a
* later invoice to be dropped instead of allocated. That invoice's
* base_tax_amount and base_grand_total come out short by exactly the
* missing item's tax. Because Magento only derives total_paid by summing
* each invoice's own already wrong grand_total, the order ends up with a
* total_due that should not exist.
*
* This script never edits, voids, or cancels an invoice, since Magento has
* no supported REST write for that. It compares the order's own
* base_grand_total and base_tax_amount against what its invoices actually
* total, writes a report row for every order it flags, and exits non-zero
* so CI or alerting notices. A human reconciles the order in the Admin, for
* example with a credit memo without invoice or by cancelling and
* reissuing the affected invoice. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/magento/manual-invoice-missing-tax/
*/
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 AMOUNT_EPSILON = Number(process.env.AMOUNT_EPSILON || 0.01);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 100);
export function detectInvoiceTaxShortfall(order, invoices, epsilon = AMOUNT_EPSILON) {
const invoicedGrandTotal = invoices.reduce((sum, inv) => sum + (inv.baseGrandTotal || 0), 0);
const invoicedTax = invoices.reduce((sum, inv) => sum + (inv.baseTaxAmount || 0), 0);
const grandTotalDelta = order.baseGrandTotal - invoicedGrandTotal;
const taxDelta = order.baseTaxAmount - invoicedTax;
const isShortfall = order.totalDue > epsilon && taxDelta > epsilon && grandTotalDelta > epsilon;
return { isShortfall, invoicedGrandTotal, invoicedTax, taxDelta, grandTotalDelta };
}
export function orderToStruct(order) {
return {
baseGrandTotal: order.base_grand_total || 0,
baseTaxAmount: order.base_tax_amount || 0,
totalDue: order.total_due || 0,
};
}
export function invoiceToStruct(invoice) {
return {
baseGrandTotal: invoice.base_grand_total || 0,
baseTaxAmount: invoice.base_tax_amount || 0,
};
}
export function buildReportRow(order, invoices, result) {
return {
order_id: order.entity_id,
increment_id: order.increment_id,
expected_tax: Math.round((order.base_tax_amount || 0) * 10000) / 10000,
invoiced_tax: Math.round(result.invoicedTax * 10000) / 10000,
delta: Math.round(result.taxDelta * 10000) / 10000,
invoice_ids: invoices.map((inv) => inv.entity_id).join(";"),
};
}
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 getInvoicesForOrder(token, orderId, pageSize = PAGE_SIZE) {
const invoices = [];
let page = 1;
while (true) {
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]": String(pageSize),
"searchCriteria[currentPage]": String(page),
});
const res = await fetch(`${MAGENTO_URL}/rest/V1/invoices?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
const body = await res.json();
const items = body.items || [];
invoices.push(...items);
if (items.length < pageSize) return invoices;
page += 1;
}
}
export async function run() {
const token = await getToken();
const flagged = [];
for (const orderId of ORDER_IDS) {
const order = await getOrder(token, orderId);
const invoices = await getInvoicesForOrder(token, orderId);
const result = detectInvoiceTaxShortfall(
orderToStruct(order),
invoices.map(invoiceToStruct),
);
if (!result.isShortfall) continue;
const row = buildReportRow(order, invoices, result);
flagged.push(row);
console.warn(`Order ${row.increment_id} missing invoice tax: expected_tax=${row.expected_tax} invoiced_tax=${row.invoiced_tax} delta=${row.delta} invoice_ids=${row.invoice_ids}`);
}
console.log(`Done. ${flagged.length} order(s) flagged with a missing invoice tax shortfall.${DRY_RUN ? " (dry run, report only)" : ""}`);
return flagged;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run()
.then((flagged) => { if (flagged.length) process.exit(1); })
.catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The shortfall rule is the part most worth testing, because it decides whether an order gets flagged. Since detect_invoice_tax_shortfall and detectInvoiceTaxShortfall are pure, no network and no Magento instance are needed. The tests feed in plain numbers mirroring the issue 38978 scenario, two invoices where the second is missing its item's tax slice, plus the epsilon edge and a legitimately un-invoiced item that should not be flagged.
from detect_invoice_tax_shortfall import detect_invoice_tax_shortfall
def order(**over):
base = {"baseGrandTotal": 110.00, "baseTaxAmount": 10.00, "totalDue": 0.00}
base.update(over)
return base
def test_two_invoices_second_missing_tax_is_shortfall():
# invoice 1 covers simple products with its share of tax, invoice 2 (virtual
# product) drops its 2.00 tax slice per magento2 issue 38978
invoices = [
{"baseGrandTotal": 88.00, "baseTaxAmount": 8.00},
{"baseGrandTotal": 20.00, "baseTaxAmount": 0.00},
]
result = detect_invoice_tax_shortfall(order(totalDue=2.00), invoices)
assert result["isShortfall"] is True
assert result["taxDelta"] == 2.00
assert result["grandTotalDelta"] == 2.00
def test_fully_matched_invoices_is_not_shortfall():
invoices = [
{"baseGrandTotal": 88.00, "baseTaxAmount": 8.00},
{"baseGrandTotal": 22.00, "baseTaxAmount": 2.00},
]
result = detect_invoice_tax_shortfall(order(totalDue=0.00), invoices)
assert result["isShortfall"] is False
def test_zero_total_due_is_not_shortfall_even_with_tax_delta():
# a rounding blip in tax with nothing actually owed should not be flagged
invoices = [{"baseGrandTotal": 110.00, "baseTaxAmount": 8.00}]
result = detect_invoice_tax_shortfall(order(totalDue=0.00), invoices)
assert result["isShortfall"] is False
def test_legitimately_uninvoiced_item_is_not_a_tax_shortfall():
# order still has an un-invoiced item; grand total is short but tax is not
invoices = [{"baseGrandTotal": 60.00, "baseTaxAmount": 10.00}]
result = detect_invoice_tax_shortfall(order(totalDue=50.00), invoices)
assert result["taxDelta"] == 0.00
assert result["isShortfall"] is False
def test_within_epsilon_is_not_shortfall():
invoices = [{"baseGrandTotal": 109.995, "baseTaxAmount": 9.995}]
result = detect_invoice_tax_shortfall(order(totalDue=0.01), invoices, epsilon=0.01)
assert result["isShortfall"] is False
def test_no_invoices_at_all_with_due_and_tax_is_shortfall():
result = detect_invoice_tax_shortfall(order(totalDue=110.00), [])
assert result["isShortfall"] is True
assert result["invoicedGrandTotal"] == 0
assert result["invoicedTax"] == 0
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectInvoiceTaxShortfall } from "./detect-invoice-tax-shortfall.js";
const order = (over = {}) => ({ baseGrandTotal: 110.00, baseTaxAmount: 10.00, totalDue: 0.00, ...over });
test("two invoices, second missing tax, is a shortfall", () => {
const invoices = [
{ baseGrandTotal: 88.00, baseTaxAmount: 8.00 },
{ baseGrandTotal: 20.00, baseTaxAmount: 0.00 },
];
const result = detectInvoiceTaxShortfall(order({ totalDue: 2.00 }), invoices);
assert.equal(result.isShortfall, true);
assert.equal(result.taxDelta, 2.00);
assert.equal(result.grandTotalDelta, 2.00);
});
test("fully matched invoices is not a shortfall", () => {
const invoices = [
{ baseGrandTotal: 88.00, baseTaxAmount: 8.00 },
{ baseGrandTotal: 22.00, baseTaxAmount: 2.00 },
];
const result = detectInvoiceTaxShortfall(order({ totalDue: 0.00 }), invoices);
assert.equal(result.isShortfall, false);
});
test("zero total due is not a shortfall even with a tax delta", () => {
const invoices = [{ baseGrandTotal: 110.00, baseTaxAmount: 8.00 }];
const result = detectInvoiceTaxShortfall(order({ totalDue: 0.00 }), invoices);
assert.equal(result.isShortfall, false);
});
test("legitimately uninvoiced item is not a tax shortfall", () => {
const invoices = [{ baseGrandTotal: 60.00, baseTaxAmount: 10.00 }];
const result = detectInvoiceTaxShortfall(order({ totalDue: 50.00 }), invoices);
assert.equal(result.taxDelta, 0.00);
assert.equal(result.isShortfall, false);
});
test("within epsilon is not a shortfall", () => {
const invoices = [{ baseGrandTotal: 109.995, baseTaxAmount: 9.995 }];
const result = detectInvoiceTaxShortfall(order({ totalDue: 0.01 }), invoices, 0.01);
assert.equal(result.isShortfall, false);
});
test("no invoices at all with due and tax is a shortfall", () => {
const result = detectInvoiceTaxShortfall(order({ totalDue: 110.00 }), []);
assert.equal(result.isShortfall, true);
assert.equal(result.invoicedGrandTotal, 0);
assert.equal(result.invoicedTax, 0);
});
Case studies
A subscription add-on that quietly left an amount due
A store sold a physical bundle alongside a virtual warranty add-on. Fulfillment invoiced the physical items as soon as they shipped, then a second team invoiced the virtual warranty a day later from the order screen. Every item was invoiced, support closed the ticket, but the order kept showing a small amount due that nobody could explain from the line items alone.
Running the audit against the last quarter of orders surfaced the exact pattern from issue 38978: the second invoice's base_tax_amount was 0.00 when it should have carried the warranty's tax share. The report gave finance the order and invoice ids they needed to reconcile the balance without touching either posted invoice.
A backorder that split one order into two invoices
An order had one item on backorder, so the admin invoiced the in-stock items right away and invoiced the backordered item weeks later when stock arrived. The second invoice's grand total came out lower than the item's price plus tax should have been, and the order sat with a stubborn total_due long after the merchant considered it fully invoiced and shipped.
The script flagged the order because both the grand total and the tax fell short by the same amount, ruling out a shipping or discount mismatch. The team used the reported delta to raise the issue with their support contract and reconcile the specific order manually, leaving the original invoices as the record they are.
After running this on a schedule, a dropped invoice tax slice stops being an unexplained balance that support has to chase line by line. You get a dated report of the order id, increment id, expected tax, invoiced tax, delta, and every invoice id involved for each order that disagrees with its own totals, plus a non-zero exit code so CI or alerting cannot miss it. Every posted invoice stays exactly as issued, since that is what a financial record is supposed to do, and reconciliation happens through a human in the Admin.
FAQ
Why does a manually invoiced Magento order show an amount due when it is fully invoiced?
When an admin manually creates a partial invoice, such as invoicing simple products separately from a virtual product, Magento's invoice totals collectors prorate tax across invoices by each item's invoiced quantity ratio. A documented core bug, magento2 issue 38978, causes the tax belonging to items on a later invoice to be dropped instead of allocated. That invoice's base_tax_amount and base_grand_total come out short, so the order's total_paid ends up less than base_grand_total and total_due shows a balance that should not exist.
Can I fix a wrong invoice tax amount through the REST API?
No. An invoice is an immutable financial record once it is created, and there is no PUT endpoint on /rest/V1/invoices that edits an existing invoice's totals. The safe response is to detect and report the shortfall, then let a human reconcile it in the Admin, for example with a credit memo without invoice or a manual invoice cancellation, rather than mutating posted invoice data.
How do I detect which orders have this missing invoice tax?
Read the order's base_grand_total, base_tax_amount, and total_due from GET /rest/V1/orders/{orderId}, then pull every invoice for that order from GET /rest/V1/invoices filtered on order_id and sum each invoice's base_grand_total and base_tax_amount. Flag the order when total_due is greater than a small epsilon and the order's base_tax_amount exceeds the summed invoiced tax by more than that epsilon, while the summed invoice grand total also falls short of the order's grand total. That combination points at a dropped tax slice rather than a legitimately un-invoiced item.
Related field notes
Citations
On the problem:
- Missing tax from Grand Total for Invoice created in the admin manually, and order shows an amount due when it should not, magento2 issue 38978. github.com/magento/magento2/issues/38978
- Wrong tax value on invoice, magento2 issue 39455. github.com/magento/magento2/issues/39455
- Generating Invoice does recalculate tax but wrong, magento2 issue 31366. github.com/magento/magento2/issues/31366
On the solution:
- Step 8, Create an invoice, Adobe Commerce REST API tutorial. developer.adobe.com commerce/webapi/rest/tutorials/orders/order-create-invoice
- Search using REST endpoints, Adobe Commerce webapi. developer.adobe.com commerce/webapi/rest/use-rest/performing-searches
- Retrieve filtered responses for REST endpoints, Adobe Commerce webapi. developer.adobe.com commerce/webapi/rest/use-rest/retrieve-filtered-responses
Stuck on a tricky one?
If you have a problem in Magento orders, invoices, credit memos, 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 a false amount due?
If this saved you a confusing reconciliation or a wrong finance report, 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