Diagnostic Orders

BigCommerce order total wrong when only one of price_ex_tax or price_inc_tax is set

Someone's checkout, ERP sync, or migration script set price_inc_tax on a line item, or total_inc_tax on the order, and left the matching ex_tax field untouched. BigCommerce did not complain. It saved exactly what it was given. Now the order's totals do not add up against tax_total or the line items, and nobody notices until an accounting export or a customer's order history looks wrong. Here is why the V2 Orders API lets this happen and a small script that finds every order it happened to.

Python and Node.js BigCommerce V2 Orders API Report only by default
A calculator sitting on top of a table next to another calculator
Photo by iSawRed on Unsplash
The short answer

BigCommerce's V2 Orders API lets you override computed money fields, but every override is defined as a tax-inclusive and tax-exclusive pair: a line item's price_inc_tax requires price_ex_tax (and vice versa), and an order's total_inc_tax requires total_ex_tax (and vice versa). If a client sets only one side, BigCommerce does not reject the request and does not auto-derive the missing value from store tax rules. It stores what it was given, so the other field keeps its stale or default value, usually 0.00. Run a small Python or Node.js script that lists orders in a date range with GET /v2/orders, reads each order's totals and GET /v2/orders/{id}/products line items, and flags any order where one side of a pair is zero while the other is not, or where the sums do not reconcile against total_inc_tax within a cent. Full code, tests, and a dry run guard are below.

The problem in plain words

The BigCommerce V2 Orders API (POST or PUT /v2/orders) is generous about letting integrators override money fields instead of recalculating everything from catalog prices and tax rules. That is useful when a custom checkout, an ERP sync, or a migration script already knows the right numbers. But each override field has a partner. A line item's price_inc_tax is meant to be set together with price_ex_tax. An order's total_inc_tax is meant to be set together with total_ex_tax. BigCommerce does not enforce that pairing.

So when a client sends only price_inc_tax and never touches price_ex_tax, or only sets total_ex_tax and leaves total_inc_tax alone, the API happily accepts the request. The field that was set gets the new value. The field that was not set keeps whatever it already was, often 0.00, or a stale catalog price left over from before the override. Nothing in the response says the order is now inconsistent. The order looks saved and normal until someone reads both fields side by side, or a downstream system sums the line items and compares that sum against total_inc_tax or total_tax and finds they do not agree.

PUT /v2/orders/{id} sets total_inc_tax only BigCommerce saves exactly what was sent No auto-derive total_ex_tax stays 0.00 Totals do not reconcile
Only one side of the price_ex_tax and price_inc_tax (or total_ex_tax and total_inc_tax) pair got a real value. BigCommerce never tried to fill in the other side.

Why it happens

The V2 Orders API is documented as accepting these override fields, but it treats each request as authoritative, not as a set of constraints to validate. A few common ways stores end up with a desynced order:

Because the write succeeds and the admin UI usually renders whichever field it prefers, the mismatch is invisible until a report, an accounting export, or a customer support ticket compares the inc_tax and ex_tax sides, or sums the line items against total_tax. See the citations at the end for the exact docs and support threads.

The key insight

BigCommerce will not tell you when a tax-field pair is only half set. So the safe pattern is not "trust total_inc_tax" or "trust total_ex_tax." It is "read both sides of every pair, and every line item's pair, and flag any order where one side is zero or default while the other is not, or where the line-item sum does not reconcile against the order total within a cent." All of these fields are decimal strings, so every comparison has to happen after parsing to Decimal, never as a float diff.

The fix, as a flow

We do not touch the live checkout or any order-write flow. We add a job that lists candidate orders in a date range, pulls each order's totals and line items, and runs a pure decision function that returns a list of findings, empty when the order is consistent.

List orders by date range Read totals and line items Check every pair ex_tax vs inc_tax Findings non-empty? yes no, consistent Report, or guarded repair
The job only ever reports findings by default. A correction write is a separate, explicitly authorized step, gated by DRY_RUN and a per-order confirmation list.

Build it step by step

1

Get a store hash and an API access token

Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Orders (read, and modify only if you plan to enable the guarded repair) scope. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MIN_DATE_CREATED="-30 days"
export DRY_RUN="true"   # start safe, change to false to write a guarded repair
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MIN_DATE_CREATED="-30 days"
export DRY_RUN="true"   // start safe, change to false to write a guarded repair
2

Talk to the V2 Orders REST API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v2/ with the token in the X-Auth-Token header. A small helper handles GET and PUT and raises on a non-2xx response. We reuse it to list orders, read each order's line items, and, only for an explicitly authorized repair, write corrected totals back.

step2.py
import os, requests

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

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}

def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else []

def bc_put(path, body):
    r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : [];
}

async function bcPut(path, body) {
  const res = await fetch(`${API_BASE}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
3

List the candidate orders and read their line items

Call GET /v2/orders?min_date_created=..., paginated with page and limit, to get orders in your date range. For each one, the order object already carries total_ex_tax, total_inc_tax, subtotal_ex_tax, subtotal_inc_tax, and total_tax. Call GET /v2/orders/{id}/products to get each line item's price_ex_tax, price_inc_tax, quantity, total_ex_tax, and total_inc_tax.

step3.py
def candidate_orders(min_date_created):
    page = 1
    while True:
        orders = bc_get("/orders", {
            "min_date_created": min_date_created,
            "page": page,
            "limit": 250,
        })
        if not orders:
            return
        for order in orders:
            yield order
        page += 1

def order_line_items(order_id):
    return bc_get(f"/orders/{order_id}/products")
step3.js
async function* candidateOrders(minDateCreated) {
  let page = 1;
  while (true) {
    const orders = await bcGet("/orders", {
      min_date_created: minDateCreated,
      page,
      limit: 250,
    });
    if (!orders.length) return;
    for (const order of orders) yield order;
    page += 1;
  }
}

async function orderLineItems(orderId) {
  return bcGet(`/orders/${orderId}/products`);
}
4

Decide, with one pure function

Keep the decision in its own function that takes the already-fetched order and its line items and returns a list of findings. Every money field is a decimal string, so every comparison happens after parsing to Decimal, never a float. A finding fires when one side of an ex_tax/inc_tax pair is zero or missing while the other is a real, non-default value, and separately when the line items plus shipping and handling minus discount do not sum to the order total within a cent.

decide.py
from decimal import Decimal, InvalidOperation

EPSILON = Decimal("0.01")

def to_decimal(value):
    if value in (None, ""):
        return None
    try:
        return Decimal(str(value))
    except InvalidOperation:
        return None

def check_pair(scope, entity_id, field_a, field_b, value_a, value_b, findings):
    a = to_decimal(value_a)
    b = to_decimal(value_b)
    a_set = a is not None and a != 0
    b_set = b is not None and b != 0
    if a_set != b_set:
        findings.append({
            "scope": scope, "id": entity_id, "field_pair": (field_a, field_b),
            "value_a": a or Decimal("0"), "value_b": b or Decimal("0"),
            "reason": "partial_override",
        })

def find_tax_override_desync(order, line_items, epsilon=EPSILON):
    findings = []

    check_pair("order", order.get("id"), "total_ex_tax", "total_inc_tax",
               order.get("total_ex_tax"), order.get("total_inc_tax"), findings)

    for item in line_items or []:
        check_pair("line_item", item.get("id"), "price_ex_tax", "price_inc_tax",
                   item.get("price_ex_tax"), item.get("price_inc_tax"), findings)

    line_sum = sum((to_decimal(i.get("total_inc_tax")) or Decimal("0")) for i in (line_items or []))
    shipping = to_decimal(order.get("shipping_cost_inc_tax")) or Decimal("0")
    handling = to_decimal(order.get("handling_cost_inc_tax")) or Decimal("0")
    discount = to_decimal(order.get("discount_amount")) or Decimal("0")
    computed_total = line_sum + shipping + handling - discount
    order_total = to_decimal(order.get("total_inc_tax")) or Decimal("0")

    if abs(computed_total - order_total) > epsilon:
        findings.append({
            "scope": "order", "id": order.get("id"),
            "field_pair": ("computed_total_inc_tax", "total_inc_tax"),
            "value_a": computed_total, "value_b": order_total,
            "reason": "total_mismatch",
        })

    return findings
decide.js
const EPSILON = 0.01;

function toNumber(value) {
  if (value === null || value === undefined || value === "") return null;
  const n = Number.parseFloat(value);
  return Number.isFinite(n) ? n : null;
}

function checkPair(scope, entityId, fieldA, fieldB, valueA, valueB, findings) {
  const a = toNumber(valueA);
  const b = toNumber(valueB);
  const aSet = a !== null && a !== 0;
  const bSet = b !== null && b !== 0;
  if (aSet !== bSet) {
    findings.push({
      scope, id: entityId, field_pair: [fieldA, fieldB],
      value_a: a || 0, value_b: b || 0,
      reason: "partial_override",
    });
  }
}

export function findTaxOverrideDesync(order, lineItems, epsilon = EPSILON) {
  const findings = [];

  checkPair("order", order.id, "total_ex_tax", "total_inc_tax",
    order.total_ex_tax, order.total_inc_tax, findings);

  for (const item of lineItems || []) {
    checkPair("line_item", item.id, "price_ex_tax", "price_inc_tax",
      item.price_ex_tax, item.price_inc_tax, findings);
  }

  const lineSum = (lineItems || []).reduce((sum, i) => sum + (toNumber(i.total_inc_tax) || 0), 0);
  const shipping = toNumber(order.shipping_cost_inc_tax) || 0;
  const handling = toNumber(order.handling_cost_inc_tax) || 0;
  const discount = toNumber(order.discount_amount) || 0;
  const computedTotal = lineSum + shipping + handling - discount;
  const orderTotal = toNumber(order.total_inc_tax) || 0;

  if (Math.abs(computedTotal - orderTotal) > epsilon) {
    findings.push({
      scope: "order", id: order.id,
      field_pair: ["computed_total_inc_tax", "total_inc_tax"],
      value_a: computedTotal, value_b: orderTotal,
      reason: "total_mismatch",
    });
  }

  return findings;
}
5

Report first, always

By default the job only reports. It writes each finding, order id, scope, the field pair, both parsed values, and the reason, to a log line and to a summary count. Retroactively changing tax amounts on an order that may already be invoiced or shipped is a financial and compliance call that belongs to the business, not to an automated script.

report.py
def report_finding(finding):
    print(
        f"scope={finding['scope']} id={finding['id']} "
        f"field_pair={finding['field_pair']} value_a={finding['value_a']} "
        f"value_b={finding['value_b']} reason={finding['reason']}"
    )
report.js
function reportFinding(finding) {
  console.log(
    `scope=${finding.scope} id=${finding.id} field_pair=${finding.field_pair} ` +
    `value_a=${finding.value_a} value_b=${finding.value_b} reason=${finding.reason}`
  );
}
6

Wire it together with a dry run guard

The loop lists candidate orders, fetches line items, runs the pure function, and reports every finding. A guarded repair path exists for orders the business has explicitly confirmed are still status_id 0 (Incomplete) or 11 (Awaiting Fulfillment) and have not been invoiced or shipped, recomputing both total_ex_tax and total_inc_tax from the authoritative line-item sums and writing them with PUT /v2/orders/{id}. That write only ever happens with DRY_RUN=false and the order id present in an explicit confirmation list, never on every flagged order automatically.

Run it safe

Always start with DRY_RUN=true, and treat this as a flag-and-report tool first. Only enable a write for a specific order after the business has confirmed the order has not been invoiced or shipped and has explicitly authorized the correction. Never bulk-correct historical tax amounts without that sign off.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, reports every finding, and only ever writes a correction when DRY_RUN=false and the order id appears in an explicit CONFIRMED_ORDER_IDS list.

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

find_tax_override_desync.py
"""Find BigCommerce orders where only one side of a tax override pair was set.

The V2 Orders API (POST/PUT /v2/orders) lets integrators override computed money
fields, but each override is defined in tax-inclusive/exclusive pairs: a line
item's price_inc_tax requires price_ex_tax (and vice versa), and an order's
total_inc_tax requires total_ex_tax (and vice versa). If a client sets only one
side of a pair, BigCommerce does not reject the request or auto-derive the
missing value. It stores exactly what it was given, so the untouched field keeps
its stale or default value, often 0.00. This produces an order whose totals do
not reconcile against tax_total or the sum of its line items. Because correcting
historical tax amounts is a financial and compliance decision, this job reports
findings by default and only writes a guarded repair for orders explicitly
confirmed as not yet invoiced or shipped.

Guide: https://www.allanninal.dev/bigcommerce/order-total-partial-tax-field-override/
"""
import os
import logging
from decimal import Decimal, InvalidOperation

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
MIN_DATE_CREATED = os.environ.get("MIN_DATE_CREATED", "-30 days")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CONFIRMED_ORDER_IDS = {
    int(x) for x in os.environ.get("CONFIRMED_ORDER_IDS", "").split(",") if x.strip()
}

EPSILON = Decimal("0.01")
REPAIRABLE_STATUS_IDS = {0, 11}  # Incomplete, Awaiting Fulfillment

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}


def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    if not r.text:
        return []
    return r.json()


def bc_put(path, body):
    r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def to_decimal(value):
    if value in (None, ""):
        return None
    try:
        return Decimal(str(value))
    except InvalidOperation:
        return None


def _check_pair(scope, entity_id, field_a, field_b, value_a, value_b, findings):
    a = to_decimal(value_a)
    b = to_decimal(value_b)
    a_set = a is not None and a != 0
    b_set = b is not None and b != 0
    if a_set != b_set:
        findings.append({
            "scope": scope,
            "id": entity_id,
            "field_pair": (field_a, field_b),
            "value_a": a if a is not None else Decimal("0"),
            "value_b": b if b is not None else Decimal("0"),
            "reason": "partial_override",
        })


def find_tax_override_desync(order: dict, line_items: list, epsilon: Decimal = EPSILON) -> list:
    """Pure decision logic, no I/O.

    Takes an already-fetched order dict (from GET /v2/orders/{id}) and its line
    items (from GET /v2/orders/{id}/products), both with money fields as strings.
    Returns a list of finding dicts: {"scope": "order"|"line_item", "id": ...,
    "field_pair": (a, b), "value_a": Decimal, "value_b": Decimal,
    "reason": "partial_override"|"total_mismatch"}. Empty list means the order
    is internally consistent.
    """
    findings = []

    _check_pair(
        "order", order.get("id"), "total_ex_tax", "total_inc_tax",
        order.get("total_ex_tax"), order.get("total_inc_tax"), findings,
    )

    for item in line_items or []:
        _check_pair(
            "line_item", item.get("id"), "price_ex_tax", "price_inc_tax",
            item.get("price_ex_tax"), item.get("price_inc_tax"), findings,
        )

    line_sum = sum(
        (to_decimal(item.get("total_inc_tax")) or Decimal("0")) for item in (line_items or [])
    )
    shipping = to_decimal(order.get("shipping_cost_inc_tax")) or Decimal("0")
    handling = to_decimal(order.get("handling_cost_inc_tax")) or Decimal("0")
    discount = to_decimal(order.get("discount_amount")) or Decimal("0")
    computed_total = line_sum + shipping + handling - discount
    order_total = to_decimal(order.get("total_inc_tax")) or Decimal("0")

    if abs(computed_total - order_total) > epsilon:
        findings.append({
            "scope": "order",
            "id": order.get("id"),
            "field_pair": ("computed_total_inc_tax", "total_inc_tax"),
            "value_a": computed_total,
            "value_b": order_total,
            "reason": "total_mismatch",
        })

    return findings


def candidate_orders():
    """Page through orders created within the configured date window."""
    page = 1
    while True:
        orders = bc_get(
            "/orders",
            {"min_date_created": MIN_DATE_CREATED, "page": page, "limit": 250},
        )
        if not orders:
            return
        for order in orders:
            yield order
        page += 1


def order_line_items(order_id):
    return bc_get(f"/orders/{order_id}/products")


def repair_order(order, computed_total_ex_tax, computed_total_inc_tax):
    """Guarded repair. Only ever called for confirmed, still-open orders."""
    return bc_put(
        f"/orders/{order['id']}",
        {
            "total_ex_tax": str(computed_total_ex_tax),
            "total_inc_tax": str(computed_total_inc_tax),
        },
    )


def run():
    orders_checked = 0
    orders_with_findings = 0
    total_findings = 0

    for order in candidate_orders():
        orders_checked += 1
        order_id = order["id"]
        line_items = order_line_items(order_id)

        findings = find_tax_override_desync(order, line_items)

        if not findings:
            continue

        orders_with_findings += 1
        total_findings += len(findings)

        for finding in findings:
            log.warning(
                "scope=%s id=%s field_pair=%s value_a=%s value_b=%s reason=%s order_id=%s",
                finding["scope"], finding["id"], finding["field_pair"],
                finding["value_a"], finding["value_b"], finding["reason"], order_id,
            )

        status_id = order.get("status_id")
        if order_id in CONFIRMED_ORDER_IDS and status_id in REPAIRABLE_STATUS_IDS:
            line_sum_inc = sum(
                (to_decimal(i.get("total_inc_tax")) or Decimal("0")) for i in (line_items or [])
            )
            line_sum_ex = sum(
                (to_decimal(i.get("total_ex_tax")) or Decimal("0")) for i in (line_items or [])
            )
            shipping_inc = to_decimal(order.get("shipping_cost_inc_tax")) or Decimal("0")
            shipping_ex = to_decimal(order.get("shipping_cost_ex_tax")) or Decimal("0")
            handling_inc = to_decimal(order.get("handling_cost_inc_tax")) or Decimal("0")
            handling_ex = to_decimal(order.get("handling_cost_ex_tax")) or Decimal("0")
            discount = to_decimal(order.get("discount_amount")) or Decimal("0")

            recomputed_inc = line_sum_inc + shipping_inc + handling_inc - discount
            recomputed_ex = line_sum_ex + shipping_ex + handling_ex - discount

            log.info(
                "order_id=%s confirmed repair candidate. recomputed_total_ex_tax=%s "
                "recomputed_total_inc_tax=%s (%s)",
                order_id, recomputed_ex, recomputed_inc,
                "dry run" if DRY_RUN else "writing",
            )
            if not DRY_RUN:
                repair_order(order, recomputed_ex, recomputed_inc)

    log.info(
        "Done. %d order(s) checked, %d order(s) with findings, %d finding(s) total.",
        orders_checked, orders_with_findings, total_findings,
    )


if __name__ == "__main__":
    run()
find-tax-override-desync.js
/**
 * Find BigCommerce orders where only one side of a tax override pair was set.
 *
 * The V2 Orders API (POST/PUT /v2/orders) lets integrators override computed
 * money fields, but each override is defined in tax-inclusive/exclusive pairs:
 * a line item's price_inc_tax requires price_ex_tax (and vice versa), and an
 * order's total_inc_tax requires total_ex_tax (and vice versa). If a client
 * sets only one side of a pair, BigCommerce does not reject the request or
 * auto-derive the missing value. It stores exactly what it was given, so the
 * untouched field keeps its stale or default value, often 0.00. This produces
 * an order whose totals do not reconcile against tax_total or the sum of its
 * line items. Because correcting historical tax amounts is a financial and
 * compliance decision, this job reports findings by default and only writes a
 * guarded repair for orders explicitly confirmed as not yet invoiced or shipped.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/order-total-partial-tax-field-override/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const MIN_DATE_CREATED = process.env.MIN_DATE_CREATED || "-30 days";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CONFIRMED_ORDER_IDS = new Set(
  (process.env.CONFIRMED_ORDER_IDS || "")
    .split(",")
    .map((s) => s.trim())
    .filter(Boolean)
    .map(Number)
);

const EPSILON = 0.01;
const REPAIRABLE_STATUS_IDS = new Set([0, 11]); // Incomplete, Awaiting Fulfillment

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

function toNumber(value) {
  if (value === null || value === undefined || value === "") return null;
  const n = Number.parseFloat(value);
  return Number.isFinite(n) ? n : null;
}

function checkPair(scope, entityId, fieldA, fieldB, valueA, valueB, findings) {
  const a = toNumber(valueA);
  const b = toNumber(valueB);
  const aSet = a !== null && a !== 0;
  const bSet = b !== null && b !== 0;
  if (aSet !== bSet) {
    findings.push({
      scope,
      id: entityId,
      field_pair: [fieldA, fieldB],
      value_a: a !== null ? a : 0,
      value_b: b !== null ? b : 0,
      reason: "partial_override",
    });
  }
}

/**
 * Pure decision logic, no I/O.
 *
 * Takes an already-fetched order object (from GET /v2/orders/{id}) and its
 * line items (from GET /v2/orders/{id}/products), both with money fields as
 * strings. Returns a list of finding objects: {scope, id, field_pair,
 * value_a, value_b, reason}. Empty array means the order is internally
 * consistent.
 */
export function findTaxOverrideDesync(order, lineItems, epsilon = EPSILON) {
  const findings = [];

  checkPair(
    "order", order.id, "total_ex_tax", "total_inc_tax",
    order.total_ex_tax, order.total_inc_tax, findings
  );

  for (const item of lineItems || []) {
    checkPair(
      "line_item", item.id, "price_ex_tax", "price_inc_tax",
      item.price_ex_tax, item.price_inc_tax, findings
    );
  }

  const lineSum = (lineItems || []).reduce(
    (sum, item) => sum + (toNumber(item.total_inc_tax) || 0), 0
  );
  const shipping = toNumber(order.shipping_cost_inc_tax) || 0;
  const handling = toNumber(order.handling_cost_inc_tax) || 0;
  const discount = toNumber(order.discount_amount) || 0;
  const computedTotal = lineSum + shipping + handling - discount;
  const orderTotal = toNumber(order.total_inc_tax) || 0;

  if (Math.abs(computedTotal - orderTotal) > epsilon) {
    findings.push({
      scope: "order",
      id: order.id,
      field_pair: ["computed_total_inc_tax", "total_inc_tax"],
      value_a: computedTotal,
      value_b: orderTotal,
      reason: "total_mismatch",
    });
  }

  return findings;
}

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined && value !== null) url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : [];
}

async function bcPut(path, body) {
  const res = await fetch(`${API_BASE}${path}`, {
    method: "PUT",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function* candidateOrders() {
  let page = 1;
  while (true) {
    const orders = await bcGet("/orders", {
      min_date_created: MIN_DATE_CREATED,
      page,
      limit: 250,
    });
    if (!orders.length) return;
    for (const order of orders) yield order;
    page += 1;
  }
}

async function orderLineItems(orderId) {
  return bcGet(`/orders/${orderId}/products`);
}

async function repairOrder(order, computedTotalExTax, computedTotalIncTax) {
  return bcPut(`/orders/${order.id}`, {
    total_ex_tax: String(computedTotalExTax),
    total_inc_tax: String(computedTotalIncTax),
  });
}

export async function run() {
  let ordersChecked = 0;
  let ordersWithFindings = 0;
  let totalFindings = 0;

  for await (const order of candidateOrders()) {
    ordersChecked += 1;
    const orderId = order.id;
    const lineItems = await orderLineItems(orderId);

    const findings = findTaxOverrideDesync(order, lineItems);

    if (!findings.length) continue;

    ordersWithFindings += 1;
    totalFindings += findings.length;

    for (const finding of findings) {
      console.warn(
        `scope=${finding.scope} id=${finding.id} field_pair=${finding.field_pair} ` +
        `value_a=${finding.value_a} value_b=${finding.value_b} reason=${finding.reason} order_id=${orderId}`
      );
    }

    const statusId = order.status_id;
    if (CONFIRMED_ORDER_IDS.has(orderId) && REPAIRABLE_STATUS_IDS.has(statusId)) {
      const lineSumInc = (lineItems || []).reduce((s, i) => s + (toNumber(i.total_inc_tax) || 0), 0);
      const lineSumEx = (lineItems || []).reduce((s, i) => s + (toNumber(i.total_ex_tax) || 0), 0);
      const shippingInc = toNumber(order.shipping_cost_inc_tax) || 0;
      const shippingEx = toNumber(order.shipping_cost_ex_tax) || 0;
      const handlingInc = toNumber(order.handling_cost_inc_tax) || 0;
      const handlingEx = toNumber(order.handling_cost_ex_tax) || 0;
      const discount = toNumber(order.discount_amount) || 0;

      const recomputedInc = lineSumInc + shippingInc + handlingInc - discount;
      const recomputedEx = lineSumEx + shippingEx + handlingEx - discount;

      console.log(
        `order_id=${orderId} confirmed repair candidate. recomputed_total_ex_tax=${recomputedEx} ` +
        `recomputed_total_inc_tax=${recomputedInc} (${DRY_RUN ? "dry run" : "writing"})`
      );
      if (!DRY_RUN) await repairOrder(order, recomputedEx, recomputedInc);
    }
  }

  console.log(
    `Done. ${ordersChecked} order(s) checked, ${ordersWithFindings} order(s) with findings, ${totalFindings} finding(s) total.`
  );
}

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

Add a test

The decision function is the part most worth testing, because it decides which orders get reported and which ones a human might later authorize for repair. Because find_tax_override_desync takes only plain values and returns a plain list, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the findings.

test_override_desync.py
from decimal import Decimal

from find_tax_override_desync import find_tax_override_desync


def base_order(**overrides):
    order = {
        "id": 501,
        "total_ex_tax": "100.00",
        "total_inc_tax": "108.00",
        "shipping_cost_inc_tax": "0.00",
        "handling_cost_inc_tax": "0.00",
        "discount_amount": "0.00",
    }
    order.update(overrides)
    return order


def line_item(**overrides):
    item = {
        "id": 9001,
        "price_ex_tax": "100.00",
        "price_inc_tax": "108.00",
        "quantity": 1,
        "total_ex_tax": "100.00",
        "total_inc_tax": "108.00",
    }
    item.update(overrides)
    return item


def test_consistent_order_has_no_findings():
    order = base_order()
    items = [line_item()]
    assert find_tax_override_desync(order, items) == []


def test_order_level_partial_override_is_flagged():
    order = base_order(total_ex_tax="0.00")
    items = [line_item()]
    findings = find_tax_override_desync(order, items)
    reasons = [f["reason"] for f in findings]
    assert "partial_override" in reasons
    order_finding = next(f for f in findings if f["scope"] == "order" and f["reason"] == "partial_override")
    assert order_finding["field_pair"] == ("total_ex_tax", "total_inc_tax")
    assert order_finding["value_a"] == Decimal("0")
    assert order_finding["value_b"] == Decimal("108.00")


def test_line_item_partial_override_is_flagged():
    order = base_order()
    items = [line_item(price_ex_tax=None)]
    findings = find_tax_override_desync(order, items)
    line_finding = next(f for f in findings if f["scope"] == "line_item")
    assert line_finding["field_pair"] == ("price_ex_tax", "price_inc_tax")
    assert line_finding["reason"] == "partial_override"


def test_total_mismatch_is_flagged_beyond_epsilon():
    order = base_order(total_inc_tax="200.00")
    items = [line_item()]
    findings = find_tax_override_desync(order, items)
    mismatch = next(f for f in findings if f["reason"] == "total_mismatch")
    assert mismatch["value_b"] == Decimal("200.00")


def test_rounding_within_epsilon_is_not_flagged():
    order = base_order(total_inc_tax="108.005")
    items = [line_item()]
    findings = find_tax_override_desync(order, items, epsilon=Decimal("0.01"))
    assert all(f["reason"] != "total_mismatch" for f in findings)
find-tax-override-desync.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findTaxOverrideDesync } from "./find-tax-override-desync.js";

const baseOrder = (overrides = {}) => ({
  id: 501,
  total_ex_tax: "100.00",
  total_inc_tax: "108.00",
  shipping_cost_inc_tax: "0.00",
  handling_cost_inc_tax: "0.00",
  discount_amount: "0.00",
  ...overrides,
});

const lineItem = (overrides = {}) => ({
  id: 9001,
  price_ex_tax: "100.00",
  price_inc_tax: "108.00",
  quantity: 1,
  total_ex_tax: "100.00",
  total_inc_tax: "108.00",
  ...overrides,
});

test("consistent order has no findings", () => {
  const findings = findTaxOverrideDesync(baseOrder(), [lineItem()]);
  assert.deepEqual(findings, []);
});

test("order-level partial override is flagged", () => {
  const order = baseOrder({ total_ex_tax: "0.00" });
  const findings = findTaxOverrideDesync(order, [lineItem()]);
  const orderFinding = findings.find((f) => f.scope === "order" && f.reason === "partial_override");
  assert.ok(orderFinding);
  assert.deepEqual(orderFinding.field_pair, ["total_ex_tax", "total_inc_tax"]);
  assert.equal(orderFinding.value_a, 0);
  assert.equal(orderFinding.value_b, 108);
});

test("line item partial override is flagged", () => {
  const items = [lineItem({ price_ex_tax: null })];
  const findings = findTaxOverrideDesync(baseOrder(), items);
  const lineFinding = findings.find((f) => f.scope === "line_item");
  assert.ok(lineFinding);
  assert.deepEqual(lineFinding.field_pair, ["price_ex_tax", "price_inc_tax"]);
});

test("total mismatch is flagged beyond epsilon", () => {
  const order = baseOrder({ total_inc_tax: "200.00" });
  const findings = findTaxOverrideDesync(order, [lineItem()]);
  const mismatch = findings.find((f) => f.reason === "total_mismatch");
  assert.ok(mismatch);
  assert.equal(mismatch.value_b, 200);
});

test("rounding within epsilon is not flagged", () => {
  const order = baseOrder({ total_inc_tax: "108.005" });
  const findings = findTaxOverrideDesync(order, [lineItem()], 0.01);
  assert.ok(findings.every((f) => f.reason !== "total_mismatch"));
});

Case studies

Custom checkout

The headless storefront that only sent price_inc_tax

A merchant ran a custom, tax-inclusive-first storefront on top of BigCommerce. Their checkout calculated a single tax-inclusive price per item and posted it as price_inc_tax on order creation, assuming BigCommerce would back-fill price_ex_tax from the store's tax settings. It never did. Every order created through that storefront had price_ex_tax sitting at 0.00 on every line item.

Running the scan against a month of orders surfaced the pattern immediately: a consistent partial_override finding on every line item from that channel, and nowhere else. The fix was on the storefront's checkout integration, sending both fields going forward, and the report gave the finance team an exact list of historical orders to reconcile manually.

ERP migration

The migration script that only knew pre-tax totals

An ERP migration imported two years of historical orders into a new BigCommerce store. The source system only tracked pre-tax totals, so the script set total_ex_tax on every imported order and left total_inc_tax at its default. Nobody noticed until an accounting export compared total_inc_tax against the line items and found thousands of dollars of orders reporting an inc-tax total of zero.

The scan flagged every one of those orders with a partial_override finding at the order level, all sharing the same signature. Because the orders were long since shipped and invoiced, the team used the report purely for a one-time accounting adjustment rather than writing anything back to BigCommerce.

What good looks like

After this runs on a schedule, every order with a half-set tax override pair or a total that does not reconcile against its line items shows up in a report within one run, with the exact field pair and both values so a human can judge what happened. Nothing gets silently corrected. A write only ever happens for an order the business has explicitly confirmed is still open and has authorized for repair.

FAQ

Why is my BigCommerce order total wrong after I set price_ex_tax or price_inc_tax?

BigCommerce's V2 Orders API treats price_ex_tax and price_inc_tax, and total_ex_tax and total_inc_tax, as override pairs. If your integration sends only one side of a pair, BigCommerce stores exactly what it was given and does not derive the missing value from store tax rules. The untouched field keeps its stale or default value, often 0.00, so the order becomes internally inconsistent.

Will BigCommerce reject an order update that sets only one side of a tax pair?

No. The V2 Orders API does not validate that both sides of a price_ex_tax/price_inc_tax or total_ex_tax/total_inc_tax pair are present and consistent. The request succeeds, the order is saved with the mismatch, and nothing in the API response calls it out.

Is it safe to auto-correct historical orders that have this mismatch?

Not by default. Retroactively changing tax amounts on an order that has already been invoiced, shipped, or reported to a tax authority is a financial and compliance decision, not a purely technical one. The safe default is to detect and report affected orders, and only write a correction, behind a dry run flag and an explicit per-order confirmation, when the business has authorized it and the order has not yet been invoiced or shipped.

Related field notes

Citations

On the problem:

  1. BigCommerce API Reference: Create Order, price_ex_tax and price_inc_tax override pairing. docs.bigcommerce.com create order
  2. BigCommerce Support: how do I work with the total_tax field in the order API call. support.bigcommerce.com total_tax field
  3. BigCommerce Support: tax and cart totals not adding up. support.bigcommerce.com tax and cart totals not adding up

On the solution:

  1. BigCommerce API Reference: Orders V2, total_ex_tax and total_inc_tax override fields. docs.bigcommerce.com orders
  2. BigCommerce API Reference: Update Order. docs.bigcommerce.com update order
  3. BigCommerce Developer Center: Orders Overview. developer.bigcommerce.com orders overview

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, webhooks, 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 your order totals?

If this saved you a pile of manual reconciliation or caught orders you would have otherwise missed, 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