Reconciler Orders
BigCommerce order-level refund does not recalculate total_tax
A customer got their money back. The refund shows up in the transaction log. But the order's total_tax never moved, so the order now reports more tax than it actually collected. BigCommerce quietly treats an order-level refund as a flat, tax-exempt custom amount instead of routing it through the tax provider the way a line-item refund does. Here is why that gap opens up and a small script that finds every order where the stored tax has drifted from what the refunds actually say.
BigCommerce refunds come in two flavors: line-item refunds, which reference a specific product line and route through the store's tax provider to recompute tax on the refunded quantity, and order-level or custom-amount refunds, sent with item_type: "ORDER". An order-level refund is treated as a flat, tax-exempt custom amount against the total refundable order amount, so the Create Refund Quote step returns total_refund_tax_amount = 0 and the refund is processed without touching tax. The order's stored total_tax (and downstream total_inc_tax/total_ex_tax) is never decremented for the tax portion of what was actually refunded. Run a small Python or Node.js script that lists Refunded and Partially Refunded orders with GET /v2/orders?status_id=4,14, reads each order's transactions, recomputes the expected total_tax, and flags any order where the stored value and the expected value disagree by more than a cent. Full code, tests, and a dry run guard are below.
The problem in plain words
When you refund a BigCommerce order, the platform needs to know which kind of refund it is dealing with. A line-item refund names a specific product line and quantity, so BigCommerce can ask its tax provider exactly how much tax applied to that quantity and back it out correctly. An order-level refund, the kind you get from a flat custom-amount refund against the order's total refundable balance, carries no line-item context at all. There is nothing for the tax provider to recompute against, so BigCommerce does not even try.
Instead, the Create Refund Quote step for an order-level refund (item_type: "ORDER") returns total_refund_tax_amount = 0 by design. The refund itself succeeds, the customer gets their money, and a refund transaction is recorded. But the order's total_tax field, and the fields derived from it, are left exactly where they were before the refund. If any part of what was refunded should have included a tax component, that portion is now permanently unaccounted for in the order's own numbers, even though the money already left the store.
Why it happens
BigCommerce's refund pipeline is built to distinguish exactly two kinds of refund line items, and it treats them very differently when it comes to tax. A few common ways stores end up with an order whose total_tax has silently drifted:
- A support agent issues a flat custom-amount refund, for example ten dollars off as a goodwill gesture, instead of refunding a specific line item, so the refund is sent with
item_type: "ORDER"and no tax is ever considered. - An app or custom admin tool builds its refund request generically and defaults to an order-level amount rather than resolving the actual product line, even when the intent was to refund a real, taxed item.
- A partial refund is issued to cover return shipping or a price adjustment, which is legitimately not tied to a specific line item, but the merchant's own bookkeeping still expects the order's
total_taxto reflect what was truly refunded. - Multiple refunds accumulate against the same order over time, mixing line-item and order-level refunds, so the order's stored total_tax reflects only the tax portion of the line-item refunds and quietly ignores every order-level one.
Because the refund transaction itself succeeds and the customer is made whole, nothing in the response signals that the order's own totals are now out of sync. The drift is invisible until someone reconciles the order's total_tax against the sum of what was actually refunded, or an accounting export catches the store reporting tax it no longer actually holds. See the citations at the end for the exact support threads and docs.
The order's total_tax field is not proof of anything after a refund has happened against that order. The transactions are. So the safe pattern is not "trust total_tax." It is "recompute the expected total_tax from the original tax plus every refund transaction's own recorded tax_amount, and flag any order where that expected figure disagrees with the stored total_tax by more than a cent, and separately flag any order-level refund transaction that recorded zero tax on its own, since that is the exact signature of this bug." All of these fields are decimal strings, so every comparison happens after parsing to Decimal, never a float diff.
The fix, as a flow
We do not touch the live refund flow or write to the order's tax fields directly, because BigCommerce does not expose a supported endpoint for that and doing it anyway risks breaking accounting reconciliation. We add a job that lists candidate orders, pulls each order's stored totals and refund transactions, and runs a pure decision function that reports the mismatch for a human or finance workflow to apply as a manual adjustment.
Build it step by step
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 corrective refund) 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.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MIN_DATE_MODIFIED="-30 days"
export DRY_RUN="true" # start safe, change to false only for an explicit corrective refund
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MIN_DATE_MODIFIED="-30 days"
export DRY_RUN="true" // start safe, change to false only for an explicit corrective refund
Talk to the V2 and V3 Orders REST API
List and read orders through https://api.bigcommerce.com/stores/{store_hash}/v2/. The refund quote endpoint used in the repair step lives under https://api.bigcommerce.com/stores/{store_hash}/v3/. Both send the token in the X-Auth-Token header. A small helper handles GET and POST and raises on a non-2xx response.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_BASE_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(base, path, params=None):
r = requests.get(f"{base}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else []
def bc_post(base, path, body):
r = requests.post(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGet(base, path, params = {}) {
const url = new URL(`${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 bcPost(base, path, body) {
const res = await fetch(`${base}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
List the candidate orders and read their refund transactions
Call GET /v2/orders?min_date_modified=...&status_id=4,14, paginated, to get orders in either Refunded (4) or Partially Refunded (14) status within your lookback window. For each one, call GET /v2/orders/{id} for total_tax, total_ex_tax, total_inc_tax, and call GET /v2/orders/{id}/transactions filtering for type: "refund" to get every refund's item_type, amount, and any tax component recorded on it.
REFUNDED = 4
PARTIALLY_REFUNDED = 14
def candidate_orders(min_date_modified):
page = 1
while True:
orders = bc_get(API_BASE_V2, "/orders", {
"status_id": f"{REFUNDED},{PARTIALLY_REFUNDED}",
"min_date_modified": min_date_modified,
"page": page,
"limit": 250,
})
if not orders:
return
for order in orders:
yield order
page += 1
def order_refund_transactions(order_id):
transactions = bc_get(API_BASE_V2, f"/orders/{order_id}/transactions")
return [t for t in transactions if (t.get("type") or "").lower() == "refund"]
const REFUNDED = 4;
const PARTIALLY_REFUNDED = 14;
async function* candidateOrders(minDateModified) {
let page = 1;
while (true) {
const orders = await bcGet(API_BASE_V2, "/orders", {
status_id: `${REFUNDED},${PARTIALLY_REFUNDED}`,
min_date_modified: minDateModified,
page,
limit: 250,
});
if (!orders.length) return;
for (const order of orders) yield order;
page += 1;
}
}
async function orderRefundTransactions(orderId) {
const transactions = await bcGet(API_BASE_V2, `/orders/${orderId}/transactions`);
return transactions.filter((t) => (t.type || "").toLowerCase() === "refund");
}
Decide, with one pure function
Keep the decision in its own function that takes the order and its refund transactions and returns a reconciliation record. The original tax is reconstructed as today's stored total_tax plus every refund transaction's own tax_amount, then the expected total_tax is that original figure minus only the tax_amount actually recorded on refund transactions. An order is flagged either when the delta exceeds a cent, or when any order-level (item_type: "ORDER") refund recorded a positive amount with zero tax, which is the exact signature of this bug even when the delta happens to land within tolerance.
from decimal import Decimal
def reconcile_order_tax(order, refund_transactions, tolerance=0.01):
stored_total_tax = Decimal(order["total_tax"])
refund_tax_sum = sum(
Decimal(t.get("tax_amount") or "0") for t in refund_transactions
)
original_tax = stored_total_tax + refund_tax_sum
refund_only_tax_sum = sum(
Decimal(t.get("tax_amount") or "0")
for t in refund_transactions
if t.get("type") == "refund"
)
expected_total_tax = original_tax - refund_only_tax_sum
order_level_refund_without_tax = any(
t.get("item_type") == "ORDER"
and Decimal(t.get("amount", "0")) > 0
and Decimal(t.get("tax_amount") or "0") == 0
for t in refund_transactions
)
delta = abs(expected_total_tax - stored_total_tax)
flagged = delta > Decimal(str(tolerance)) or order_level_refund_without_tax
reason = None
if order_level_refund_without_tax:
reason = "order-level refund skipped tax recalculation"
elif flagged:
reason = "total_tax drift"
return {
"order_id": order["id"],
"expected_total_tax": expected_total_tax,
"stored_total_tax": stored_total_tax,
"delta": delta,
"flagged": flagged,
"reason": reason,
}
function toNum(value) {
return Number.parseFloat(value || "0");
}
export function reconcileOrderTax(order, refundTransactions, tolerance = 0.01) {
const storedTotalTax = toNum(order.total_tax);
const refundTaxSum = refundTransactions.reduce((s, t) => s + toNum(t.tax_amount), 0);
const originalTax = storedTotalTax + refundTaxSum;
const refundOnlyTaxSum = refundTransactions
.filter((t) => t.type === "refund")
.reduce((s, t) => s + toNum(t.tax_amount), 0);
const expectedTotalTax = originalTax - refundOnlyTaxSum;
const orderLevelRefundWithoutTax = refundTransactions.some(
(t) => t.item_type === "ORDER" && toNum(t.amount) > 0 && toNum(t.tax_amount) === 0
);
const delta = Math.abs(expectedTotalTax - storedTotalTax);
const flagged = delta > tolerance || orderLevelRefundWithoutTax;
let reason = null;
if (orderLevelRefundWithoutTax) reason = "order-level refund skipped tax recalculation";
else if (flagged) reason = "total_tax drift";
return {
order_id: order.id,
expected_total_tax: expectedTotalTax,
stored_total_tax: storedTotalTax,
delta,
flagged,
reason,
};
}
Report first, and quote the correct tax figure
By default the job only reports. For each flagged order, it also calls POST /v3/orders/{order_id}/payment_actions/refund_quotes as a dry run with the same items or amount that were refunded, to get the total_refund_tax_amount the platform would have computed for an equivalent line-item refund. That quoted figure, alongside the reconciliation record, order_id, stored total_tax, expected total_tax, delta, and the refund transaction id, is what gets handed to a human or finance workflow as a manual credit-memo or ledger adjustment. Nothing is written back to the order's own tax fields, because BigCommerce does not expose a supported endpoint for that.
def quote_expected_refund_tax(order_id, refund_items_or_amount):
"""DRY_RUN refund quote, used only to learn the correct tax figure."""
body = {**refund_items_or_amount, "dry_run": True}
return bc_post(API_BASE_V3, f"/orders/{order_id}/payment_actions/refund_quotes", body)
async function quoteExpectedRefundTax(orderId, refundItemsOrAmount) {
const body = { ...refundItemsOrAmount, dry_run: true };
return bcPost(API_BASE_V3, `/orders/${orderId}/payment_actions/refund_quotes`, body);
}
Wire it together with a dry run guard
The loop lists candidate orders, fetches refund transactions, runs the pure decision function, and emits a reconciliation record for every flagged order. Only under an explicit DRY_RUN=false flag does the script optionally re-issue a corrective line-item refund via POST /v3/orders/{order_id}/payment_actions/refunds for the shortfall amount, and only for an order that still has refundable balance. Otherwise it never writes anything.
Always start with DRY_RUN=true, and treat this as a flag-and-report tool first. Do not blind-write total_tax. There is no supported endpoint for it, and a corrective line-item refund should only ever be issued after a human has confirmed the order still has refundable balance and the business has authorized the correction.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, reconciles every candidate order, reports every finding with the correct expected tax figure, and only ever writes a corrective refund when DRY_RUN=false.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find BigCommerce orders whose total_tax never moved after an order-level refund.
BigCommerce refunds come in two flavors: line-item refunds, which reference a
specific product line and route through the store's tax provider to recompute
tax on the refunded quantity, and order-level or custom-amount refunds, sent
with item_type "ORDER". An order-level refund is treated as a flat, tax-exempt
custom amount against the total refundable order amount, so the Create Refund
Quote step returns total_refund_tax_amount = 0 and the refund is processed
without touching tax. The order's stored total_tax (and downstream
total_inc_tax/total_ex_tax) is never decremented for the tax portion of what
was actually refunded. Because BigCommerce exposes no supported endpoint to
directly patch total_tax after the fact, this job reports every mismatch as a
reconciliation record for a human or finance workflow, and only re-issues a
corrective line-item refund under an explicit non dry run flag.
Guide: https://www.allanninal.dev/bigcommerce/order-refund-does-not-recalc-tax/
"""
import os
import logging
from decimal import Decimal
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_refund_tax")
STORE_HASH = os.environ.get("BIGCOMMERCE_STORE_HASH", "example_hash")
ACCESS_TOKEN = os.environ.get("BIGCOMMERCE_ACCESS_TOKEN", "bc_dummy")
API_BASE_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_BASE_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
MIN_DATE_MODIFIED = os.environ.get("MIN_DATE_MODIFIED", "-30 days")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
REFUNDED = 4
PARTIALLY_REFUNDED = 14
TOLERANCE = Decimal("0.01")
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(base, path, params=None):
r = requests.get(f"{base}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
if not r.text:
return []
return r.json()
def bc_post(base, path, body):
r = requests.post(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def reconcile_order_tax(order: dict, refund_transactions: list, tolerance: float = 0.01) -> dict:
"""Pure decision logic, no I/O, no side effects.
order: {"id": int, "total_tax": str, "total_ex_tax": str, "total_inc_tax": str}
refund_transactions: [{"id": int, "type": "refund", "item_type": "ORDER"|"PRODUCT",
"amount": str, "tax_amount": str|None}]
Returns {"order_id": int, "expected_total_tax": Decimal, "stored_total_tax": Decimal,
"delta": Decimal, "flagged": bool, "reason": str|None}
Decision logic:
1. original_tax = stored total_tax + sum(tax_amount for every refund transaction)
2. expected_total_tax = original_tax - sum(tax_amount for refund-type transactions only)
3. order_level_refund_without_tax = any refund transaction with item_type "ORDER",
a positive amount, and a zero (or missing) tax_amount. That is the exact
signature of an order-level refund that skipped tax recalculation.
4. delta = abs(expected_total_tax - stored_total_tax)
5. flagged = delta > tolerance or order_level_refund_without_tax
6. reason = "order-level refund skipped tax recalculation" when that signature is
present, otherwise "total_tax drift" when flagged, otherwise None.
"""
stored_total_tax = Decimal(order["total_tax"])
refund_tax_sum = sum(
Decimal(t.get("tax_amount") or "0") for t in refund_transactions
)
original_tax = stored_total_tax + refund_tax_sum
refund_only_tax_sum = sum(
Decimal(t.get("tax_amount") or "0")
for t in refund_transactions
if t.get("type") == "refund"
)
expected_total_tax = original_tax - refund_only_tax_sum
order_level_refund_without_tax = any(
t.get("item_type") == "ORDER"
and Decimal(t.get("amount", "0")) > 0
and Decimal(t.get("tax_amount") or "0") == 0
for t in refund_transactions
)
delta = abs(expected_total_tax - stored_total_tax)
flagged = delta > Decimal(str(tolerance)) or order_level_refund_without_tax
reason = None
if order_level_refund_without_tax:
reason = "order-level refund skipped tax recalculation"
elif flagged:
reason = "total_tax drift"
return {
"order_id": order["id"],
"expected_total_tax": expected_total_tax,
"stored_total_tax": stored_total_tax,
"delta": delta,
"flagged": flagged,
"reason": reason,
}
def candidate_orders():
"""Page through Refunded (4) and Partially Refunded (14) orders in the window."""
page = 1
while True:
orders = bc_get(
API_BASE_V2,
"/orders",
{
"status_id": f"{REFUNDED},{PARTIALLY_REFUNDED}",
"min_date_modified": MIN_DATE_MODIFIED,
"page": page,
"limit": 250,
},
)
if not orders:
return
for order in orders:
yield order
page += 1
def order_refund_transactions(order_id):
transactions = bc_get(API_BASE_V2, f"/orders/{order_id}/transactions")
return [t for t in transactions if (t.get("type") or "").lower() == "refund"]
def quote_expected_refund_tax(order_id, refund_items_or_amount):
"""DRY_RUN refund quote, used only to learn the correct tax figure."""
body = {**refund_items_or_amount, "dry_run": True}
return bc_post(API_BASE_V3, f"/orders/{order_id}/payment_actions/refund_quotes", body)
def issue_corrective_refund(order_id, shortfall_amount):
"""Guarded repair. Only ever called under an explicit non dry run flag."""
body = {"reason": "tax reconciliation shortfall", "amount": str(shortfall_amount)}
return bc_post(API_BASE_V3, f"/orders/{order_id}/payment_actions/refunds", body)
def run():
orders_checked = 0
orders_flagged = 0
for order in candidate_orders():
orders_checked += 1
order_id = order["id"]
refund_transactions = order_refund_transactions(order_id)
if not refund_transactions:
continue
record = reconcile_order_tax(order, refund_transactions, float(TOLERANCE))
if not record["flagged"]:
continue
orders_flagged += 1
refund_txn_id = refund_transactions[0].get("id") if refund_transactions else None
log.warning(
"order_id=%s stored_total_tax=%s expected_total_tax=%s delta=%s "
"reason=%s refund_transaction_id=%s",
record["order_id"], record["stored_total_tax"], record["expected_total_tax"],
record["delta"], record["reason"], refund_txn_id,
)
if not DRY_RUN and order.get("refunded_amount") is not None:
log.info(
"order_id=%s issuing corrective line-item refund for shortfall=%s",
order_id, record["delta"],
)
issue_corrective_refund(order_id, record["delta"])
log.info(
"Done. %d order(s) checked, %d order(s) flagged for tax reconciliation.",
orders_checked, orders_flagged,
)
if __name__ == "__main__":
run()
/**
* Find BigCommerce orders whose total_tax never moved after an order-level refund.
*
* BigCommerce refunds come in two flavors: line-item refunds, which reference a
* specific product line and route through the store's tax provider to recompute
* tax on the refunded quantity, and order-level or custom-amount refunds, sent
* with item_type "ORDER". An order-level refund is treated as a flat, tax-exempt
* custom amount against the total refundable order amount, so the Create Refund
* Quote step returns total_refund_tax_amount = 0 and the refund is processed
* without touching tax. The order's stored total_tax (and downstream
* total_inc_tax/total_ex_tax) is never decremented for the tax portion of what
* was actually refunded. Because BigCommerce exposes no supported endpoint to
* directly patch total_tax after the fact, this job reports every mismatch as a
* reconciliation record for a human or finance workflow, and only re-issues a
* corrective line-item refund under an explicit non dry run flag.
*
* Guide: https://www.allanninal.dev/bigcommerce/order-refund-does-not-recalc-tax/
*/
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_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const MIN_DATE_MODIFIED = process.env.MIN_DATE_MODIFIED || "-30 days";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REFUNDED = 4;
const PARTIALLY_REFUNDED = 14;
const TOLERANCE = 0.01;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
function toNum(value) {
return Number.parseFloat(value || "0");
}
/**
* Pure decision logic, no I/O, no side effects.
*
* order: {id, total_tax, total_ex_tax, total_inc_tax}
* refundTransactions: [{id, type: "refund", item_type: "ORDER"|"PRODUCT", amount, tax_amount}]
* Returns {order_id, expected_total_tax, stored_total_tax, delta, flagged, reason}
*
* Decision logic:
* 1. originalTax = stored total_tax + sum(tax_amount for every refund transaction)
* 2. expectedTotalTax = originalTax - sum(tax_amount for refund-type transactions only)
* 3. orderLevelRefundWithoutTax = any refund transaction with item_type "ORDER", a
* positive amount, and a zero (or missing) tax_amount. That is the exact
* signature of an order-level refund that skipped tax recalculation.
* 4. delta = abs(expectedTotalTax - storedTotalTax)
* 5. flagged = delta > tolerance or orderLevelRefundWithoutTax
* 6. reason = "order-level refund skipped tax recalculation" when that signature
* is present, otherwise "total_tax drift" when flagged, otherwise null.
*/
export function reconcileOrderTax(order, refundTransactions, tolerance = TOLERANCE) {
const storedTotalTax = toNum(order.total_tax);
const refundTaxSum = refundTransactions.reduce((s, t) => s + toNum(t.tax_amount), 0);
const originalTax = storedTotalTax + refundTaxSum;
const refundOnlyTaxSum = refundTransactions
.filter((t) => t.type === "refund")
.reduce((s, t) => s + toNum(t.tax_amount), 0);
const expectedTotalTax = originalTax - refundOnlyTaxSum;
const orderLevelRefundWithoutTax = refundTransactions.some(
(t) => t.item_type === "ORDER" && toNum(t.amount) > 0 && toNum(t.tax_amount) === 0
);
const delta = Math.abs(expectedTotalTax - storedTotalTax);
const flagged = delta > tolerance || orderLevelRefundWithoutTax;
let reason = null;
if (orderLevelRefundWithoutTax) reason = "order-level refund skipped tax recalculation";
else if (flagged) reason = "total_tax drift";
return {
order_id: order.id,
expected_total_tax: expectedTotalTax,
stored_total_tax: storedTotalTax,
delta,
flagged,
reason,
};
}
async function bcGet(base, path, params = {}) {
const url = new URL(`${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 bcPost(base, path, body) {
const res = await fetch(`${base}${path}`, {
method: "POST",
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(API_BASE_V2, "/orders", {
status_id: `${REFUNDED},${PARTIALLY_REFUNDED}`,
min_date_modified: MIN_DATE_MODIFIED,
page,
limit: 250,
});
if (!orders.length) return;
for (const order of orders) yield order;
page += 1;
}
}
async function orderRefundTransactions(orderId) {
const transactions = await bcGet(API_BASE_V2, `/orders/${orderId}/transactions`);
return transactions.filter((t) => (t.type || "").toLowerCase() === "refund");
}
async function quoteExpectedRefundTax(orderId, refundItemsOrAmount) {
const body = { ...refundItemsOrAmount, dry_run: true };
return bcPost(API_BASE_V3, `/orders/${orderId}/payment_actions/refund_quotes`, body);
}
async function issueCorrectiveRefund(orderId, shortfallAmount) {
const body = { reason: "tax reconciliation shortfall", amount: String(shortfallAmount) };
return bcPost(API_BASE_V3, `/orders/${orderId}/payment_actions/refunds`, body);
}
export async function run() {
let ordersChecked = 0;
let ordersFlagged = 0;
for await (const order of candidateOrders()) {
ordersChecked += 1;
const orderId = order.id;
const refundTransactions = await orderRefundTransactions(orderId);
if (!refundTransactions.length) continue;
const record = reconcileOrderTax(order, refundTransactions, TOLERANCE);
if (!record.flagged) continue;
ordersFlagged += 1;
const refundTxnId = refundTransactions[0] ? refundTransactions[0].id : null;
console.warn(
`order_id=${record.order_id} stored_total_tax=${record.stored_total_tax} ` +
`expected_total_tax=${record.expected_total_tax} delta=${record.delta} ` +
`reason=${record.reason} refund_transaction_id=${refundTxnId}`
);
if (!DRY_RUN && order.refunded_amount !== undefined && order.refunded_amount !== null) {
console.log(`order_id=${orderId} issuing corrective line-item refund for shortfall=${record.delta}`);
await issueCorrectiveRefund(orderId, record.delta);
}
}
console.log(
`Done. ${ordersChecked} order(s) checked, ${ordersFlagged} order(s) flagged for tax reconciliation.`
);
}
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 what the reconciliation record says. Because reconcile_order_tax takes only plain values and returns a plain dict, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the record.
from decimal import Decimal
from reconcile_refund_tax import reconcile_order_tax
def base_order(**overrides):
order = {"id": 701, "total_tax": "8.00", "total_ex_tax": "100.00", "total_inc_tax": "108.00"}
order.update(overrides)
return order
def line_item_refund(amount="50.00", tax_amount="4.00"):
return {"id": 1, "type": "refund", "item_type": "PRODUCT", "amount": amount, "tax_amount": tax_amount}
def order_level_refund(amount="10.00", tax_amount=None):
return {"id": 2, "type": "refund", "item_type": "ORDER", "amount": amount, "tax_amount": tax_amount}
def test_reconciled_when_line_item_refund_tax_matches():
order = base_order(total_tax="4.00")
record = reconcile_order_tax(order, [line_item_refund(tax_amount="4.00")])
assert record["flagged"] is False
assert record["reason"] is None
assert record["expected_total_tax"] == Decimal("4.00")
def test_flagged_when_order_level_refund_has_zero_tax():
order = base_order(total_tax="8.00")
record = reconcile_order_tax(order, [order_level_refund()])
assert record["flagged"] is True
assert record["reason"] == "order-level refund skipped tax recalculation"
def test_flagged_when_total_tax_drift_exceeds_tolerance():
# A non-refund transaction (a chargeback) in the same window carries its own
# tax_amount, which feeds original_tax but is not backed out of expected_total_tax
# the way a refund-type transaction is. That mismatch is a genuine total_tax
# drift, independent of the order-level-zero-tax signature.
order = base_order(total_tax="8.00")
txns = [
line_item_refund(amount="50.00", tax_amount="4.00"),
{"id": 3, "type": "chargeback", "item_type": "PRODUCT", "amount": "40.00", "tax_amount": "3.00"},
]
record = reconcile_order_tax(order, txns)
assert record["flagged"] is True
assert record["reason"] == "total_tax drift"
assert record["delta"] == Decimal("3.00")
def test_not_flagged_when_delta_within_tolerance():
order = base_order(total_tax="4.001")
record = reconcile_order_tax(order, [line_item_refund(tax_amount="4.00")], tolerance=0.01)
assert record["flagged"] is False
def test_order_id_and_stored_total_tax_pass_through():
order = base_order(total_tax="8.00")
record = reconcile_order_tax(order, [line_item_refund(tax_amount="4.00")])
assert record["order_id"] == 701
assert record["stored_total_tax"] == Decimal("8.00")
def test_multiple_refund_line_items_are_summed_correctly():
order = base_order(total_tax="0.00")
txns = [
line_item_refund(amount="30.00", tax_amount="2.40"),
line_item_refund(amount="20.00", tax_amount="1.60"),
]
record = reconcile_order_tax(order, txns)
assert record["expected_total_tax"] == Decimal("0.00")
assert record["flagged"] is False
def test_single_line_item_refund_with_no_drift_is_not_flagged():
order = base_order(total_tax="4.00")
txns = [line_item_refund(tax_amount="4.00")]
record = reconcile_order_tax(order, txns)
assert record["expected_total_tax"] == Decimal("4.00")
assert record["flagged"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { reconcileOrderTax } from "./reconcile-refund-tax.js";
const baseOrder = (overrides = {}) => ({
id: 701, total_tax: "8.00", total_ex_tax: "100.00", total_inc_tax: "108.00", ...overrides,
});
const lineItemRefund = ({ amount = "50.00", tax_amount = "4.00" } = {}) => ({
id: 1, type: "refund", item_type: "PRODUCT", amount, tax_amount,
});
const orderLevelRefund = ({ amount = "10.00", tax_amount = null } = {}) => ({
id: 2, type: "refund", item_type: "ORDER", amount, tax_amount,
});
test("reconciled when line item refund tax matches", () => {
const order = baseOrder({ total_tax: "4.00" });
const record = reconcileOrderTax(order, [lineItemRefund({ tax_amount: "4.00" })]);
assert.equal(record.flagged, false);
assert.equal(record.reason, null);
assert.equal(record.expected_total_tax, 4);
});
test("flagged when order-level refund has zero tax", () => {
const order = baseOrder({ total_tax: "8.00" });
const record = reconcileOrderTax(order, [orderLevelRefund()]);
assert.equal(record.flagged, true);
assert.equal(record.reason, "order-level refund skipped tax recalculation");
});
test("flagged when total_tax drift exceeds tolerance", () => {
// A non-refund transaction (a chargeback) carries its own tax_amount, which
// feeds originalTax but is not backed out of expectedTotalTax the way a
// refund-type transaction is. That mismatch is a genuine total_tax drift,
// independent of the order-level-zero-tax signature.
const order = baseOrder({ total_tax: "8.00" });
const txns = [
lineItemRefund({ amount: "50.00", tax_amount: "4.00" }),
{ id: 3, type: "chargeback", item_type: "PRODUCT", amount: "40.00", tax_amount: "3.00" },
];
const record = reconcileOrderTax(order, txns);
assert.equal(record.flagged, true);
assert.equal(record.reason, "total_tax drift");
assert.equal(record.delta, 3);
});
test("not flagged when delta within tolerance", () => {
const order = baseOrder({ total_tax: "4.001" });
const record = reconcileOrderTax(order, [lineItemRefund({ tax_amount: "4.00" })], 0.01);
assert.equal(record.flagged, false);
});
test("order id and stored total tax pass through", () => {
const order = baseOrder({ total_tax: "8.00" });
const record = reconcileOrderTax(order, [lineItemRefund({ tax_amount: "4.00" })]);
assert.equal(record.order_id, 701);
assert.equal(record.stored_total_tax, 8);
});
test("multiple refund line items are summed correctly", () => {
const order = baseOrder({ total_tax: "0.00" });
const txns = [
lineItemRefund({ amount: "30.00", tax_amount: "2.40" }),
lineItemRefund({ amount: "20.00", tax_amount: "1.60" }),
];
const record = reconcileOrderTax(order, txns);
assert.equal(record.expected_total_tax, 0);
assert.equal(record.flagged, false);
});
test("single line item refund with no drift is not flagged", () => {
const order = baseOrder({ total_tax: "4.00" });
const txns = [lineItemRefund({ tax_amount: "4.00" })];
const record = reconcileOrderTax(order, txns);
assert.equal(record.expected_total_tax, 4);
assert.equal(record.flagged, false);
});
Case studies
The support desk that always refunded a flat amount
A mid-size store trained support agents to issue a flat ten or twenty dollar goodwill refund for shipping delays, always as a custom amount against the order rather than against a specific line item. Every one of those refunds succeeded and the customer was made whole, but because each was an order-level refund, none of them touched total_tax. Finance eventually noticed the store's tax remittance reports did not match the refund ledger.
Running the reconciler against ninety days of Refunded and Partially Refunded orders surfaced the exact pattern: every flagged order had an item_type: "ORDER" refund transaction with a zero tax_amount. Finance used the reconciliation records, including the correct tax figure from a dry run refund quote, to post manual ledger adjustments rather than touching the orders themselves.
The order refunded twice, once by line item and once at the order level
A single high-value order had two refunds against it: an initial line-item refund for a defective unit, which correctly reduced total_tax, followed weeks later by an order-level refund for a shipping credit. The order's total_tax reflected only the first refund. The second one left the order looking like it still held tax on money that had since gone back to the customer.
The pure decision function caught this without any special-casing, because it sums tax_amount across every refund transaction and compares that sum against what total_tax actually decreased by. The order-level refund's zero tax_amount was exactly what tripped the flag, even though the delta on its own was small enough that a naive tolerance-only check might have missed it.
After this runs on a schedule, every order whose total_tax silently drifted because of an order-level refund shows up in a report within one run, with the exact stored value, the expected value, the delta, and the refund transaction responsible. Nothing gets silently corrected on the order itself, because there is no supported way to do that safely. Finance gets a clean, per-order reconciliation record to apply as a manual credit memo or ledger adjustment, and the rare corrective line-item refund only ever happens under an explicit non dry run flag against an order that still has refundable balance.
FAQ
Why does an order-level refund in BigCommerce not change total_tax?
BigCommerce refunds come in two flavors. A line-item refund references a specific product line and routes through the store's tax provider to recompute tax on the refunded quantity. An order-level refund (item_type ORDER) is treated as a flat, tax-exempt custom amount against the total refundable order amount, so the Create Refund Quote step returns total_refund_tax_amount as zero and the refund is processed without touching tax at all.
Is it safe to directly patch an order's total_tax field after finding a mismatch?
No. BigCommerce does not expose a supported endpoint to directly write an order's total_tax field after the fact, and silently rewriting money or tax fields on a completed order risks breaking accounting reconciliation and audit trails. The safe pattern is to flag the mismatch and produce a reconciliation record for a human or finance workflow to apply as a manual credit memo or ledger adjustment.
How do I know what the correct refund tax amount would have been?
Call POST /v3/orders/{order_id}/payment_actions/refund_quotes as a dry run with the same items or amount that were refunded. That endpoint returns the total_refund_tax_amount the platform would compute for an equivalent line-item refund, which gives you the expected tax figure to compare against what the order-level refund actually recorded.
Related field notes
Citations
On the problem:
- BigCommerce Support: when applying an order level refund, tax isn't calculated. support.bigcommerce.com order level refund tax isn't calculated
- BigCommerce Support: how do I work with the total_tax field in the order API call. support.bigcommerce.com total_tax field
- BigCommerce Developer Center: Order Refunds Overview. developer.bigcommerce.com order refunds overview
On the solution:
- BigCommerce API Reference: Create Refund Quote. docs.bigcommerce.com create order refund quotes
- BigCommerce API Reference: Create an Order Refund. docs.bigcommerce.com create an order refund
- BigCommerce API Reference: Get Order, V2 Orders and the total_tax field. docs.bigcommerce.com get order
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.
Did this untangle your refund tax numbers?
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