Skip to content

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.

Python and Node.js Adobe Commerce REST API Safe by default (report only)
Using a calculator at a desk
Photo by Towfiqu barbhuiya on Unsplash
The short answer

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.

Invoice 1 simple products Invoice 2 virtual product its tax slice is dropped base_tax_amount short on invoice 2 Order shows a false total_due Both invoices show correct items and quantities. Only the tax total, and the order's total_paid, are wrong.
Every item gets invoiced, but the second invoice's tax total quietly comes out short, so total_paid never catches up to base_grand_total.

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:

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.

The key insight

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.

Fetch order grand_total, tax, total_due Fetch invoices for this order_id, paginated Sum invoices grand_total, tax_amount total_due > 0 and shortfall is tax? yes Report + exit non-zero order, invoices, delta no Matched left alone
The script only ever reports. No invoice is edited or voided; a human reconciles flagged orders in the Admin.

Build it step by step

1

Get an admin token

Authenticate against the admin token endpoint with an admin username and password, or use an integration access token if you already have one. Keep the base URL, credentials, and the list of order ids to audit in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export ORDER_IDS="1001,1002,1003"
export AMOUNT_EPSILON="0.01"
export DRY_RUN="true"   # report only, this script never edits an invoice
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export ORDER_IDS="1001,1002,1003"
export AMOUNT_EPSILON="0.01"
export DRY_RUN="true"   // report only, this script never edits an invoice
2

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.

step2.py
import os, requests

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

def get_token(username, password):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/integration/admin/token",
        json={"username": username, "password": password},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def get_order(token, order_id):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/orders/{order_id}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");

async function getToken(username, password) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function getOrder(token, orderId) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/orders/${orderId}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

List 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.

step3.py
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
step3.js
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;
  }
}
4

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.

shortfall.py
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,
    }
shortfall.js
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 };
}
5

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.

report.py
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],
    }
report.js
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),
  };
}
6

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.

Run it safe

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.

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.
detect_invoice_tax_shortfall.py
"""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-invoice-tax-shortfall.js
/**
 * 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.

test_manual_invoice_shortfall.py
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
shortfall.test.js
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

Simple plus virtual product

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.

Split shipment invoicing

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.

What good looks like

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:

  1. 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
  2. Wrong tax value on invoice, magento2 issue 39455. github.com/magento/magento2/issues/39455
  3. Generating Invoice does recalculate tax but wrong, magento2 issue 31366. github.com/magento/magento2/issues/31366

On the solution:

  1. Step 8, Create an invoice, Adobe Commerce REST API tutorial. developer.adobe.com commerce/webapi/rest/tutorials/orders/order-create-invoice
  2. Search using REST endpoints, Adobe Commerce webapi. developer.adobe.com commerce/webapi/rest/use-rest/performing-searches
  3. 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.

Contact me on LinkedIn

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

Back to all Magento field notes