Reconciler Tax, currency, and reporting

Order tax off by a cent

The checkout page showed one tax figure. The order that BigCommerce actually persisted shows another, a cent or two apart. Nobody typed in a wrong number. Nobody touched the tax rate. It is BigCommerce's own line-item rounding doing exactly what it is documented to do, just in a way that quietly drifts from what the customer saw on screen. Here is why it happens and a small script that flags the orders where it happened, without touching a single tax total.

Python and Node.js BigCommerce REST API Safe by default (flag, do not write tax)
The short answer

BigCommerce's tax engine, manual or automatic, calculates sales tax at the individual line-item level, unit price times rate, rounding any result above a half cent up to the nearest cent, then sums those independently rounded line amounts into the order's persisted total_tax. The storefront cart or checkout can display a figure computed with subtotal-level rounding or an async tax provider recalculation at finalization, so the number the customer saw and the number BigCommerce writes to order.total_tax can differ by a cent or more, especially on multi-quantity lines or orders spanning multiple tax classes. Run a small Python or Node.js script that pulls each order's total_tax plus its authoritative GET /v2/orders/{id}/taxes breakdown and its GET /v2/orders/{id}/products line items, sums both independently, and writes a machine-readable note when they disagree by a cent or more. It never edits the tax itself. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce does not calculate tax once for the whole order and call it done. It calculates tax separately for each line item, unit price multiplied by quantity multiplied by the applicable rate, and rounds that single line's result to the nearest cent, rounding anything at or above a half cent up. Then it adds up all of those already-rounded line amounts to get the order's total_tax. That is the number that gets written to the order record and to each line's price_tax field through GET /v2/orders/{id}/products.

The storefront cart and checkout page do not always follow that exact same math at every moment. A cart summary can show a tax figure computed at the subtotal level, one multiplication against the whole cart, or a live estimate from an automatic tax provider that gets recalculated again when checkout actually finalizes. Neither of those figures is wrong on its own, they are just computed differently, at a different level of granularity, from the number BigCommerce eventually persists. On a single-quantity, single-tax-class order the two numbers usually land on the same cent. On a line with several units, or an order that spans more than one tax class, the per-line rounding can nudge the persisted total a cent or two away from whatever the customer watched tick by on screen.

Checkout shows subtotal-level estimate Order finalizes tax engine runs per line rounding differs order.total_tax off by a cent or two Per line: price x qty x rate rounded, half cent rounds up GET .../taxes disagrees with what buyer saw sum of lines Multi-qty lines and mixed tax classes widen the gap
Nobody typed a wrong number. BigCommerce rounded each line separately and summed the rounded results, which does not always match a subtotal-level estimate the buyer saw.

Why it happens

The order object, the taxes endpoint, and each line's tax field are all populated from the same per-line rounding rule, but the storefront's live display does not always mirror that same math at the same moment. A few common ways stores end up with a one or two cent gap:

This is a common source of confusion for support teams, because a customer will point at a screenshot of the checkout page and ask why the emailed receipt or the admin order shows a different number. Neither number is fabricated, they are both real calculations, just performed at different levels of the order. BigCommerce documents the per-line rounding behavior and the order taxes endpoint in the Developer Center and Support articles. See the citations at the end for the exact references.

The key insight

A one or two cent mismatch here does not mean the order overcharged or undercharged anyone in a way that needs an automatic fix. It means two different, both legitimate, rounding paths produced two different numbers. So the safe pattern is not "rewrite total_tax to whatever the taxes endpoint sums to." It is "compute the expected figure from the authoritative per-line data, flag the orders that disagree, and let a human decide whether a real credit or adjustment transaction is warranted." We do that with a machine-readable note on the order, never a direct edit to total_tax.

The fix, as a flow

We do not touch the order's tax fields. We add a job that walks orders in the statuses where a mismatch matters, pulls each order's total_tax alongside its /taxes breakdown and its /products line items, sums both independently, and compares each sum against the persisted total. When either disagrees by a cent or more, we write a note to the order and leave the totals alone for a human to resolve.

Scheduled job runs on a timer List orders status_id 0,1,7,9,11 Read total_tax + lines /taxes and /products Sums differ by >= 1 cent? yes no, leave it Write staff_notes TAX_MISMATCH note
The script only ever writes a note. It never edits total_tax or a line's price_tax, because a rounding difference is not automatically a wrong charge.

Build it step by step

1

Get a BigCommerce API access token

Create an API account in your BigCommerce control panel under Settings, API, and generate a token with the Orders read scope, plus Orders write if you want the script to write the reconciliation note. Keep the store hash and the token in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export TAX_EPSILON_CENTS="1"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export TAX_EPSILON_CENTS="1"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the BigCommerce REST API

Every call carries your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper sends the request, raises on a non-2xx response, and returns the parsed body. We reuse this helper to read orders, taxes, and products, and later to write the note.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"

def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    return r.json() if r.content else None
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : null;
}
3

List the orders and pull each one's tax detail

Read orders whose status_id is 0 (Incomplete), 1 (Pending), 7 (Awaiting Payment), 9 (Awaiting Shipment), or 11 (Awaiting Fulfillment), the statuses where a mismatch is still actionable, plus recently Completed or Shipped orders when you want to audit history. For each order, call GET /v2/orders/{id}/taxes for the authoritative per-rate breakdown and GET /v2/orders/{id}/products for each line's price_tax.

step3.py
RECON_STATUS_IDS = {0, 1, 7, 9, 11}

def orders_to_check():
    page = 1
    while True:
        rows = bc("GET", f"/v2/orders?page={page}&limit=50")
        if not rows:
            return
        for row in rows:
            if int(row["status_id"]) in RECON_STATUS_IDS:
                yield row
        page += 1

def order_taxes(order_id):
    return bc("GET", f"/v2/orders/{order_id}/taxes") or []

def order_products(order_id):
    return bc("GET", f"/v2/orders/{order_id}/products") or []
step3.js
const RECON_STATUS_IDS = new Set([0, 1, 7, 9, 11]);

async function* ordersToCheck() {
  let page = 1;
  while (true) {
    const rows = await bc("GET", `/v2/orders?page=${page}&limit=50`);
    if (!rows || rows.length === 0) return;
    for (const row of rows) {
      if (RECON_STATUS_IDS.has(Number(row.status_id))) yield row;
    }
    page++;
  }
}

async function orderTaxes(orderId) {
  return (await bc("GET", `/v2/orders/${orderId}/taxes`)) || [];
}

async function orderProducts(orderId) {
  return (await bc("GET", `/v2/orders/${orderId}/products`)) || [];
}
4

Decide, with one pure function

Keep the comparison in its own function that takes the order, the /taxes array, and the /products array, and returns whether they tie out. It sums the /taxes amounts, separately sums each line's price_tax, and compares both sums against order.total_tax in cents so decimal-string rounding never causes a false alarm. A pure function like this needs no network to test, which we do later.

decide.py
def to_cents(amount):
    return round(float(amount) * 100)

def find_tax_mismatch(order, order_taxes, order_products, tolerance_cents=1):
    sum_taxes_endpoint = round(sum(to_cents(t["amount"]) for t in order_taxes) )
    sum_products_tax = round(sum(to_cents(p["price_tax"]) for p in order_products))
    actual_tax = to_cents(order["total_tax"])

    delta_a = actual_tax - sum_taxes_endpoint
    delta_b = actual_tax - sum_products_tax

    if abs(delta_a) <= tolerance_cents and abs(delta_b) <= tolerance_cents:
        return None

    if abs(delta_a) >= abs(delta_b):
        source, delta_cents, expected_cents = "taxes_endpoint", delta_a, sum_taxes_endpoint
    else:
        source, delta_cents, expected_cents = "products_sum", delta_b, sum_products_tax

    return {
        "orderId": order["id"],
        "mismatch": True,
        "deltaCents": delta_cents,
        "expectedTax": expected_cents / 100,
        "actualTax": actual_tax / 100,
        "source": source,
    }
decide.js
export function toCents(amount) {
  return Math.round(Number(amount) * 100);
}

export function findTaxMismatch(order, orderTaxes, orderProducts, toleranceCents = 1) {
  const sumTaxesEndpoint = orderTaxes.reduce((sum, t) => sum + toCents(t.amount), 0);
  const sumProductsTax = orderProducts.reduce((sum, p) => sum + toCents(p.price_tax), 0);
  const actualTax = toCents(order.total_tax);

  const deltaA = actualTax - sumTaxesEndpoint;
  const deltaB = actualTax - sumProductsTax;

  if (Math.abs(deltaA) <= toleranceCents && Math.abs(deltaB) <= toleranceCents) {
    return null;
  }

  const useA = Math.abs(deltaA) >= Math.abs(deltaB);
  const source = useA ? "taxes_endpoint" : "products_sum";
  const deltaCents = useA ? deltaA : deltaB;
  const expectedCents = useA ? sumTaxesEndpoint : sumProductsTax;

  return {
    orderId: order.id,
    mismatch: true,
    deltaCents,
    expectedTax: expectedCents / 100,
    actualTax: actualTax / 100,
    source,
  };
}
5

Flag the order, never patch its tax

When an order is mismatched, write a machine-readable note to staff_notes through PUT /v2/orders/{id}, something like TAX_MISMATCH: total_tax=X taxes_sum=Y delta=Z cents, needs manual credit or adjustment. Leave total_tax and every line's price_tax untouched. If a human later confirms against the /taxes breakdown that this is a true undercharge or overcharge, the correcting step is a separate, manually confirmed POST /v2/orders/{id}/transactions refund or adjustment for the exact delta, never an automatic edit of the recorded tax.

apply.py
def flag_order(order_id, result):
    note = (f"TAX_MISMATCH: total_tax={result['actualTax']} "
            f"taxes_sum={result['expectedTax']} delta={result['deltaCents']} cents "
            f"- needs manual credit/adjustment")
    return bc("PUT", f"/v2/orders/{order_id}", json={"staff_notes": note})
apply.js
async function flagOrder(orderId, result) {
  const note = `TAX_MISMATCH: total_tax=${result.actualTax} taxes_sum=${result.expectedTax} delta=${result.deltaCents} cents - needs manual credit/adjustment`;
  return bc("PUT", `/v2/orders/${orderId}`, { staff_notes: note });
}
6

Wire it together with a dry run guard

The loop pulls each candidate order, fetches its /taxes and /products detail, runs the pure decision function, and only writes the note when DRY_RUN is off. Leave DRY_RUN=true on the first runs so the script only logs which orders it would flag. Read the log, confirm it looks right against a few orders you already know about, then switch it off. Run it on a schedule that matches your tax reconciliation window, for example once a day.

Run it safe

Always start with DRY_RUN=true. Never let this script write total_tax or a line's price_tax. Only a follow-up step that a human has explicitly confirmed as a true undercharge or overcharge should issue a refund or adjustment transaction for the exact delta, and that write stays gated behind manual confirmation, never automatic.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and never edits an order's tax, only a note that flags it for a human.

View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.

find_tax_mismatch.py
"""Flag BigCommerce orders whose persisted tax does not match its own line detail.

BigCommerce's tax engine rounds sales tax per line item, unit price times rate,
rounding a half cent or above up to the nearest cent, then sums those independently
rounded line amounts into order.total_tax. The storefront cart or checkout can show
a subtotal-level estimate or an async tax provider figure, so what the customer saw
and what BigCommerce persisted can differ by a cent or more. This reads each order's
total_tax alongside the authoritative /taxes breakdown and the /products line detail,
sums both independently, and writes a TAX_MISMATCH note to staff_notes when they
disagree by a cent or more. It never edits total_tax or price_tax. Run on a schedule.
Safe to run again and again.
"""
import os
import logging
import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
TOLERANCE_CENTS = int(os.environ.get("TAX_EPSILON_CENTS", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

RECON_STATUS_IDS = {0, 1, 7, 9, 11}


def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    return r.json() if r.content else None


def to_cents(amount):
    return round(float(amount) * 100)


def find_tax_mismatch(order, order_taxes, order_products, tolerance_cents=1):
    """Pure decision function. No network calls.

    order: {"id": int, "total_tax": str, "status_id": int}
    order_taxes: list of {"name": str, "amount": str, "rate": str}
    order_products: list of {"price_tax": str, "quantity": int, "price_ex_tax": str}
    """
    sum_taxes_endpoint = sum(to_cents(t["amount"]) for t in order_taxes)
    sum_products_tax = sum(to_cents(p["price_tax"]) for p in order_products)
    actual_tax = to_cents(order["total_tax"])

    delta_a = actual_tax - sum_taxes_endpoint
    delta_b = actual_tax - sum_products_tax

    if abs(delta_a) <= tolerance_cents and abs(delta_b) <= tolerance_cents:
        return None

    if abs(delta_a) >= abs(delta_b):
        source, delta_cents, expected_cents = "taxes_endpoint", delta_a, sum_taxes_endpoint
    else:
        source, delta_cents, expected_cents = "products_sum", delta_b, sum_products_tax

    return {
        "orderId": order["id"],
        "mismatch": True,
        "deltaCents": delta_cents,
        "expectedTax": expected_cents / 100,
        "actualTax": actual_tax / 100,
        "source": source,
    }


def orders_to_check():
    page = 1
    while True:
        rows = bc("GET", f"/v2/orders?page={page}&limit=50")
        if not rows:
            return
        for row in rows:
            if int(row["status_id"]) in RECON_STATUS_IDS:
                yield row
        page += 1


def order_taxes(order_id):
    rows = bc("GET", f"/v2/orders/{order_id}/taxes") or []
    return [{"name": row.get("name"), "amount": row.get("amount"), "rate": row.get("rate")} for row in rows]


def order_products(order_id):
    rows = bc("GET", f"/v2/orders/{order_id}/products") or []
    return [
        {"price_tax": row.get("price_tax"), "quantity": row.get("quantity"), "price_ex_tax": row.get("price_ex_tax")}
        for row in rows
    ]


def flag_order(order_id, result):
    note = (f"TAX_MISMATCH: total_tax={result['actualTax']} "
            f"taxes_sum={result['expectedTax']} delta={result['deltaCents']} cents "
            f"- needs manual credit/adjustment")
    return bc("PUT", f"/v2/orders/{order_id}", json={"staff_notes": note})


def run():
    flagged = 0
    for row in orders_to_check():
        order = {"id": row["id"], "total_tax": row["total_tax"], "status_id": row["status_id"]}
        taxes = order_taxes(row["id"])
        products = order_products(row["id"])
        result = find_tax_mismatch(order, taxes, products, TOLERANCE_CENTS)
        if result is None:
            continue
        log.warning(
            "Order #%s tax mismatched via %s. total_tax=%s expected=%s delta=%s cents. %s",
            row["id"], result["source"], result["actualTax"], result["expectedTax"], result["deltaCents"],
            "would flag" if DRY_RUN else "flagging",
        )
        if not DRY_RUN:
            flag_order(row["id"], result)
        flagged += 1
    log.info("Done. %d order(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")


if __name__ == "__main__":
    run()
find-tax-mismatch.js
/**
 * Flag BigCommerce orders whose persisted tax does not match its own line detail.
 *
 * BigCommerce's tax engine rounds sales tax per line item, unit price times rate,
 * rounding a half cent or above up to the nearest cent, then sums those independently
 * rounded line amounts into order.total_tax. The storefront cart or checkout can show
 * a subtotal-level estimate or an async tax provider figure, so what the customer saw
 * and what BigCommerce persisted can differ by a cent or more. This reads each order's
 * total_tax alongside the authoritative /taxes breakdown and the /products line detail,
 * sums both independently, and writes a TAX_MISMATCH note to staff_notes when they
 * disagree by a cent or more. It never edits total_tax or price_tax. Run on a schedule.
 * Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/order-tax-off-by-a-cent/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy_token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const TOLERANCE_CENTS = Number(process.env.TAX_EPSILON_CENTS || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const RECON_STATUS_IDS = new Set([0, 1, 7, 9, 11]);

export function toCents(amount) {
  return Math.round(Number(amount) * 100);
}

/**
 * Pure decision function. No network calls.
 * order: { id: number, total_tax: string, status_id: number }
 * orderTaxes: Array<{ name: string, amount: string, rate: string }>
 * orderProducts: Array<{ price_tax: string, quantity: number, price_ex_tax: string }>
 */
export function findTaxMismatch(order, orderTaxes, orderProducts, toleranceCents = 1) {
  const sumTaxesEndpoint = orderTaxes.reduce((sum, t) => sum + toCents(t.amount), 0);
  const sumProductsTax = orderProducts.reduce((sum, p) => sum + toCents(p.price_tax), 0);
  const actualTax = toCents(order.total_tax);

  const deltaA = actualTax - sumTaxesEndpoint;
  const deltaB = actualTax - sumProductsTax;

  if (Math.abs(deltaA) <= toleranceCents && Math.abs(deltaB) <= toleranceCents) {
    return null;
  }

  const useA = Math.abs(deltaA) >= Math.abs(deltaB);
  const source = useA ? "taxes_endpoint" : "products_sum";
  const deltaCents = useA ? deltaA : deltaB;
  const expectedCents = useA ? sumTaxesEndpoint : sumProductsTax;

  return {
    orderId: order.id,
    mismatch: true,
    deltaCents,
    expectedTax: expectedCents / 100,
    actualTax: actualTax / 100,
    source,
  };
}

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : null;
}

async function* ordersToCheck() {
  let page = 1;
  while (true) {
    const rows = await bc("GET", `/v2/orders?page=${page}&limit=50`);
    if (!rows || rows.length === 0) return;
    for (const row of rows) {
      if (RECON_STATUS_IDS.has(Number(row.status_id))) yield row;
    }
    page++;
  }
}

async function orderTaxes(orderId) {
  const rows = (await bc("GET", `/v2/orders/${orderId}/taxes`)) || [];
  return rows.map((row) => ({ name: row.name, amount: row.amount, rate: row.rate }));
}

async function orderProducts(orderId) {
  const rows = (await bc("GET", `/v2/orders/${orderId}/products`)) || [];
  return rows.map((row) => ({ price_tax: row.price_tax, quantity: row.quantity, price_ex_tax: row.price_ex_tax }));
}

async function flagOrder(orderId, result) {
  const note = `TAX_MISMATCH: total_tax=${result.actualTax} taxes_sum=${result.expectedTax} delta=${result.deltaCents} cents - needs manual credit/adjustment`;
  return bc("PUT", `/v2/orders/${orderId}`, { staff_notes: note });
}

export async function run() {
  let flagged = 0;
  for await (const row of ordersToCheck()) {
    const order = { id: row.id, total_tax: row.total_tax, status_id: row.status_id };
    const taxes = await orderTaxes(row.id);
    const products = await orderProducts(row.id);
    const result = findTaxMismatch(order, taxes, products, TOLERANCE_CENTS);
    if (result === null) continue;
    console.warn(
      `Order #${row.id} tax mismatched via ${result.source}. total_tax=${result.actualTax} expected=${result.expectedTax} delta=${result.deltaCents} cents. ${DRY_RUN ? "would flag" : "flagging"}`
    );
    if (!DRY_RUN) await flagOrder(row.id, result);
    flagged++;
  }
  console.log(`Done. ${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}

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

Add a test

The comparison rule is the part most worth testing, because it decides which orders get flagged for a human to chase. Because we kept find_tax_mismatch pure, arithmetic only, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_order_tax_mismatch.py
from find_tax_mismatch import find_tax_mismatch, to_cents


def order(total_tax="10.00", order_id=1, status_id=9):
    return {"id": order_id, "total_tax": total_tax, "status_id": status_id}


def tax_row(amount, name="Automatic Tax", rate="8.0000"):
    return {"name": name, "amount": amount, "rate": rate}


def product_row(price_tax, quantity=1, price_ex_tax="50.00"):
    return {"price_tax": price_tax, "quantity": quantity, "price_ex_tax": price_ex_tax}


def test_to_cents_rounds():
    assert to_cents("10.00") == 1000
    assert to_cents("9.99") == 999


def test_exact_match_returns_none():
    result = find_tax_mismatch(order("10.00"), [tax_row("10.00")], [product_row("10.00")])
    assert result is None


def test_one_cent_within_tolerance_returns_none():
    result = find_tax_mismatch(order("10.00"), [tax_row("10.00")], [product_row("10.01")])
    assert result is None


def test_two_cent_mismatch_via_products_sum():
    result = find_tax_mismatch(order("10.02"), [tax_row("10.02")], [product_row("10.00")])
    assert result is not None
    assert result["mismatch"] is True
    assert result["source"] == "products_sum"
    assert result["deltaCents"] == 2


def test_mismatch_via_taxes_endpoint():
    result = find_tax_mismatch(order("10.03"), [tax_row("10.00")], [product_row("10.03")])
    assert result is not None
    assert result["source"] == "taxes_endpoint"
    assert result["deltaCents"] == 3


def test_multi_quantity_line_rounding_flags():
    # three units rounding per line differently than one lump sum
    result = find_tax_mismatch(
        order("2.55"),
        [tax_row("2.52")],
        [product_row("2.55", quantity=3)],
    )
    assert result is not None
    assert result["deltaCents"] == 3


def test_picks_larger_magnitude_source():
    result = find_tax_mismatch(order("10.05"), [tax_row("10.00")], [product_row("10.02")])
    assert result["source"] == "taxes_endpoint"
    assert result["deltaCents"] == 5
tax-mismatch.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findTaxMismatch, toCents } from "./find-tax-mismatch.js";

const order = (totalTax = "10.00", id = 1, statusId = 9) => ({ id, total_tax: totalTax, status_id: statusId });
const taxRow = (amount, { name = "Automatic Tax", rate = "8.0000" } = {}) => ({ name, amount, rate });
const productRow = (priceTax, { quantity = 1, priceExTax = "50.00" } = {}) => ({
  price_tax: priceTax, quantity, price_ex_tax: priceExTax,
});

test("toCents rounds", () => {
  assert.equal(toCents("10.00"), 1000);
  assert.equal(toCents("9.99"), 999);
});

test("exact match returns null", () => {
  const result = findTaxMismatch(order("10.00"), [taxRow("10.00")], [productRow("10.00")]);
  assert.equal(result, null);
});

test("one cent within tolerance returns null", () => {
  const result = findTaxMismatch(order("10.00"), [taxRow("10.00")], [productRow("10.01")]);
  assert.equal(result, null);
});

test("two cent mismatch via products sum", () => {
  const result = findTaxMismatch(order("10.02"), [taxRow("10.02")], [productRow("10.00")]);
  assert.notEqual(result, null);
  assert.equal(result.mismatch, true);
  assert.equal(result.source, "products_sum");
  assert.equal(result.deltaCents, 2);
});

test("mismatch via taxes endpoint", () => {
  const result = findTaxMismatch(order("10.03"), [taxRow("10.00")], [productRow("10.03")]);
  assert.notEqual(result, null);
  assert.equal(result.source, "taxes_endpoint");
  assert.equal(result.deltaCents, 3);
});

test("multi quantity line rounding flags", () => {
  const result = findTaxMismatch(order("2.55"), [taxRow("2.52")], [productRow("2.55", { quantity: 3 })]);
  assert.notEqual(result, null);
  assert.equal(result.deltaCents, 3);
});

test("picks larger magnitude source", () => {
  const result = findTaxMismatch(order("10.05"), [taxRow("10.00")], [productRow("10.02")]);
  assert.equal(result.source, "taxes_endpoint");
  assert.equal(result.deltaCents, 5);
});

Case studies

Multi-quantity line

The bulk order that never matched the receipt

A hardware store sold fasteners in packs, and a single line often carried a quantity of twelve or more at a low unit price. Support kept getting the same complaint, the receipt total and the order in the admin showed tax a cent or two apart, and staff assumed the tax rate had been edited by mistake.

Running the reconciliation script showed the pattern immediately, every flagged order had a high-quantity line, and the delta always traced back to per-line rounding on those lines. Nothing had been miscoded. Support now points customers to the note instead of escalating it as a bug.

Mixed tax classes

The store selling across state lines with different rates

A merchant sold both taxable and reduced-rate items in the same cart, so a single order could carry two or three separate tax classes. The storefront's cart summary rounded the blended estimate one way, while BigCommerce's order engine rounded each class independently and summed the rounded pieces.

The script flagged these consistently at a one or two cent delta, small enough that nobody thought it deserved a manual refund, but visible enough that the merchant now runs the job weekly and closes each note with a short comment rather than letting it pile up unexplained.

What good looks like

After this runs on a schedule, a one or two cent tax drift gets caught and explained instead of turning into a confused support ticket or a line item in an audit nobody can trace. Every flagged order carries the expected and actual tax figures right in staff_notes, so whoever reviews it starts with the numbers already computed. The tax fields themselves are never touched by the script, so there is no risk of it papering over a real undercharge or overcharge with a silent rewrite.

FAQ

Why is a BigCommerce order's tax off by a cent?

BigCommerce's tax engine calculates and rounds sales tax per line item, rounding any result above a half cent up to the nearest cent, then sums those independently rounded amounts into order.total_tax. The storefront cart can show a subtotal-level rounding or an async tax provider figure at checkout, so what the customer saw and what gets persisted to the order can differ by one or more cents, especially on multi-quantity lines or orders spanning multiple tax classes.

Can I just patch order.total_tax to fix a one cent mismatch?

No. total_tax and the per-line price_tax fields are largely read only values derived when the order was created, and writing a new number after the fact does not change what was actually charged to the card or remitted to the tax provider. The safe pattern is to flag the order with a machine readable note and let a human confirm whether a real credit or adjustment transaction is needed before any money moves.

How do I detect a tax mismatch on a BigCommerce order?

Pull the order with GET /v2/orders/{id} for total_tax, then GET /v2/orders/{id}/taxes for the authoritative per rate breakdown and GET /v2/orders/{id}/products for each line's price_tax. Sum the taxes endpoint amounts and separately sum the line item price_tax values, compare both sums against order.total_tax, and flag the order when either difference is a cent or more.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: Order Taxes. developer.bigcommerce.com/docs/rest-management/orders/order-taxes
  2. BigCommerce Support: Basic (Manual) Tax Setup. support.bigcommerce.com/s/article/Manual-Tax-Setup
  3. BigCommerce Community: Tax and Cart totals not adding up. support.bigcommerce.com tax and cart totals not adding up

On the solution:

  1. BigCommerce Developer Center: Order Taxes (REST Management). developer.bigcommerce.com/docs/rest-management/orders/order-taxes
  2. BigCommerce Developer Center: Orders (REST Management). developer.bigcommerce.com/docs/rest-management/orders
  3. BigCommerce API Reference: Get Order. docs.bigcommerce.com/developer/api-reference/rest/admin/management/orders/get-order

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, tax, inventory, or fulfillment 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 tax reconciliation headache?

If this saved you a confusing support ticket or a wrong revenue 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 BigCommerce field notes