Reconciler Orders and payments
Transactions total does not match the order
The order says one thing. The gateway's transaction ledger says another. A refund went through in the payment processor's own dashboard, a partial refund never got posted back as a transaction, or a refund_quote amount got overridden on the way out. Now total_inc_tax and refunded_amount on the BigCommerce order do not tie out against what the transactions feed actually shows, and nobody notices until a customer or an accountant asks why the numbers do not add up. Here is why it happens and a small script that flags the orders where it happened, without touching a single total.
On BigCommerce, an order's total_inc_tax and refunded_amount are written through the refund_quotes and refund_actions endpoints, while the actual money movement lives as separate purchase, refund, and void rows in GET /v2/orders/{id}/transactions. These two records can drift apart. Run a small Python or Node.js script that pulls both sides for every recently modified order, computes the net captured amount from settled transactions, compares it against the order's recorded total minus its refunded amount, and writes a machine-readable note to staff_notes when they disagree by more than a cent. It never edits the total or the refunded amount itself. Full code, tests, and a dry run guard are below.
The problem in plain words
BigCommerce keeps two records of what happened to an order's money. One is the order object itself, with fields like total_inc_tax and refunded_amount, updated whenever a refund is calculated through refund_quotes and applied through refund_actions. The other is the transaction ledger, a list of purchase, capture, refund, and void rows recorded against the original gateway through GET /v2/orders/{id}/transactions.
Most of the time these two records agree, because a refund processed through BigCommerce's own Payments API updates both at once. But there are several ordinary ways they stop agreeing. A merchant can issue a refund directly in the gateway's own dashboard, bypassing BigCommerce entirely, so the order never hears about it. A partial refund or a store credit refund can be applied without ever being posted back as a transaction row. A refund_quote amount can be overridden by a merchant typing in a different number instead of using the quoted amount, so the transaction and the order total disagree about how much actually moved. And if a store's order-updated webhook was deactivated after too many non-2xx responses, any downstream reconciliation job that depends on that webhook never sees the correcting update at all.
Why it happens
The order object and the transaction ledger are updated by separate code paths, and nothing forces them to move together outside BigCommerce's own refund flow. A few common ways stores end up with a mismatch:
- A refund processed directly in the payment gateway's dashboard, bypassing BigCommerce's Payments API entirely, so
refunded_amounton the order never changes. - A partial refund or a store-credit refund applied to the customer that is never posted back as a purchase, refund, or void row against the gateway.
- A refund_quote amount overridden by staff at the point of applying the refund, so the amount recorded in the transaction does not match what the order expects.
- A store's order-updated webhook deactivated after repeated non-2xx responses, so any downstream reconciliation system that relies on that webhook never sees the correcting update and keeps working from stale data.
This is a common source of confusion during a month-end close. Finance sees the transaction feed from the gateway, support sees the order status in the BigCommerce admin, and the two stop matching for orders that were touched outside the normal refund flow. BigCommerce documents the refund and order actions flow in the Help Center and the refunds and transactions endpoints in the Developer Center. See the citations at the end for the exact references.
A mismatch here does not tell you which side is wrong. It could be that the gateway moved money BigCommerce never recorded, or that the order total was edited without a matching transaction. So the safe pattern is not "patch the total to match the ledger" or the other way around. It is "compute both sides, flag the ones that disagree, and let a human with visibility into the gateway decide which record is authoritative." We do that with a machine-readable note on the order, never a direct edit to total_inc_tax or refunded_amount.
The fix, as a flow
We do not touch the order's totals. We add a job that walks the orders in the statuses where money is likely to have moved, pulls the transaction ledger for each, computes what should have been captured net of refunds, and compares that against what the order itself claims. When the two disagree by more than a rounding cent, we write a note to the order and leave the status alone for a human to resolve.
Build it step by step
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 and Transactions read scopes, 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.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export RECON_EPSILON_CENTS="1"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export RECON_EPSILON_CENTS="1"
export DRY_RUN="true" // start safe, change to false to write
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, read transactions, and later write the note.
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
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;
}
List the orders and pull each one's transactions
Read orders modified in the reconciliation window whose status_id is 2 (Shipped), 3 (Partially Shipped), 4 (Refunded), 10 (Completed), or 14 (Partially Refunded), since those are the statuses where money is likely to have moved. For each order, call GET /v2/orders/{id}/transactions to read every purchase, capture, refund, and void row for that gateway.
RECON_STATUS_IDS = {2, 3, 4, 10, 14}
def orders_to_check(min_date_modified):
page = 1
while True:
rows = bc("GET", f"/v2/orders?min_date_modified={min_date_modified}&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_transactions(order_id):
return bc("GET", f"/v2/orders/{order_id}/transactions") or []
const RECON_STATUS_IDS = new Set([2, 3, 4, 10, 14]);
async function* ordersToCheck(minDateModified) {
let page = 1;
while (true) {
const rows = await bc("GET", `/v2/orders?min_date_modified=${minDateModified}&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 orderTransactions(orderId) {
return (await bc("GET", `/v2/orders/${orderId}/transactions`)) || [];
}
Decide, with one pure function
Keep the comparison in its own function that takes the order's totals and the list of transactions and returns whether they tie out. It sums settled purchase and capture amounts, subtracts settled refunds, ignores anything failed or voided, and compares that net figure against total_inc_tax minus refunded_amount in integer cents so decimal-string rounding never causes a false alarm. A pure function like this needs no network to test, which we do later.
CHARGE_TYPES = {"purchase", "capture"}
def to_cents(amount):
return round(float(amount) * 100)
def reconcile_order_transactions(order, transactions, epsilon_cents=1):
settled_in = sum(to_cents(t["amount"]) for t in transactions
if t.get("success") and t.get("type") in CHARGE_TYPES)
settled_out = sum(to_cents(t["amount"]) for t in transactions
if t.get("success") and t.get("type") == "refund")
actual_net = settled_in - settled_out
expected_net = to_cents(order["totalIncTax"]) - to_cents(order["refundedAmount"])
diff_cents = actual_net - expected_net
return {
"isMismatched": abs(diff_cents) > epsilon_cents,
"expectedNet": expected_net,
"actualNet": actual_net,
"diffCents": diff_cents,
}
const CHARGE_TYPES = new Set(["purchase", "capture"]);
export function toCents(amount) {
return Math.round(Number(amount) * 100);
}
export function reconcileOrderTransactions(order, transactions, epsilonCents = 1) {
const settledIn = transactions
.filter((t) => t.success && CHARGE_TYPES.has(t.type))
.reduce((sum, t) => sum + toCents(t.amount), 0);
const settledOut = transactions
.filter((t) => t.success && t.type === "refund")
.reduce((sum, t) => sum + toCents(t.amount), 0);
const actualNet = settledIn - settledOut;
const expectedNet = toCents(order.totalIncTax) - toCents(order.refundedAmount);
const diffCents = actualNet - expectedNet;
return {
isMismatched: Math.abs(diffCents) > epsilonCents,
expectedNet,
actualNet,
diffCents,
};
}
Flag the order, never patch its totals
When an order is mismatched, write a machine-readable note to staff_notes through PUT /v2/orders/{id}, something like RECON_MISMATCH: expected=<n> actual=<n> diff=<n>. Leave status_id and the totals untouched. This is a tag for a human to review, not a correction, because the script cannot know which side of the mismatch is wrong.
def flag_order(order_id, result):
note = (f"RECON_MISMATCH: expected={result['expectedNet']} "
f"actual={result['actualNet']} diff={result['diffCents']}")
return bc("PUT", f"/v2/orders/{order_id}", json={"staff_notes": note})
async function flagOrder(orderId, result) {
const note = `RECON_MISMATCH: expected=${result.expectedNet} actual=${result.actualNet} diff=${result.diffCents}`;
return bc("PUT", `/v2/orders/${orderId}`, { staff_notes: note });
}
Wire it together with a dry run guard
The loop pulls each candidate order, fetches its transactions, 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 reconciliation window, for example once a day.
Always start with DRY_RUN=true. Never let this script write total_inc_tax, refunded_amount, or status_id. Only a follow-up run that a merchant has explicitly confirmed the authoritative side for should call the refund_quotes and refunds payment actions to post a missing transaction.
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 totals, only a note that flags it for a human.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Flag BigCommerce orders whose transactions do not add up to the order total.
Order totals (total_inc_tax, refunded_amount) and the gateway transaction ledger
are written through separate code paths. A gateway-side refund, a partial or
store-credit refund never posted back as a transaction, or an overridden
refund_quote amount can leave the two records disagreeing. This reads each
recent order's total and refunded_amount, sums its settled purchase, capture,
and refund transactions, and writes a RECON_MISMATCH note to staff_notes when
the two disagree by more than a cent. It never edits total_inc_tax,
refunded_amount, or status_id. 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_transactions_mismatch")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
MIN_DATE_MODIFIED = os.environ.get("MIN_DATE_MODIFIED", "")
EPSILON_CENTS = int(os.environ.get("RECON_EPSILON_CENTS", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
RECON_STATUS_IDS = {2, 3, 4, 10, 14}
CHARGE_TYPES = {"purchase", "capture"}
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 reconcile_order_transactions(order, transactions, epsilon_cents=1):
"""Pure decision function. No network calls.
order: {"totalIncTax": number|str, "refundedAmount": number|str}
transactions: list of {"type": "purchase"|"capture"|"refund"|"void", "amount": number|str, "success": bool}
"""
settled_in = sum(
to_cents(t["amount"]) for t in transactions
if t.get("success") and t.get("type") in CHARGE_TYPES
)
settled_out = sum(
to_cents(t["amount"]) for t in transactions
if t.get("success") and t.get("type") == "refund"
)
actual_net = settled_in - settled_out
expected_net = to_cents(order["totalIncTax"]) - to_cents(order["refundedAmount"])
diff_cents = actual_net - expected_net
return {
"isMismatched": abs(diff_cents) > epsilon_cents,
"expectedNet": expected_net,
"actualNet": actual_net,
"diffCents": diff_cents,
}
def orders_to_check():
page = 1
while True:
params = f"page={page}&limit=50"
if MIN_DATE_MODIFIED:
params += f"&min_date_modified={MIN_DATE_MODIFIED}"
rows = bc("GET", f"/v2/orders?{params}")
if not rows:
return
for row in rows:
if int(row["status_id"]) in RECON_STATUS_IDS:
yield row
page += 1
def order_transactions(order_id):
rows = bc("GET", f"/v2/orders/{order_id}/transactions") or []
return [
{"type": row.get("type"), "amount": row.get("amount"), "success": bool(row.get("success"))}
for row in rows
]
def flag_order(order_id, result):
note = (f"RECON_MISMATCH: expected={result['expectedNet']} "
f"actual={result['actualNet']} diff={result['diffCents']}")
return bc("PUT", f"/v2/orders/{order_id}", json={"staff_notes": note})
def run():
flagged = 0
for row in orders_to_check():
order = {"totalIncTax": row["total_inc_tax"], "refundedAmount": row["refunded_amount"]}
transactions = order_transactions(row["id"])
result = reconcile_order_transactions(order, transactions, EPSILON_CENTS)
if not result["isMismatched"]:
continue
log.warning(
"Order #%s mismatched. expected=%s actual=%s diff=%s. %s",
row["id"], result["expectedNet"], result["actualNet"], result["diffCents"],
"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()
/**
* Flag BigCommerce orders whose transactions do not add up to the order total.
*
* Order totals (total_inc_tax, refunded_amount) and the gateway transaction ledger
* are written through separate code paths. A gateway-side refund, a partial or
* store-credit refund never posted back as a transaction, or an overridden
* refund_quote amount can leave the two records disagreeing. This reads each
* recent order's total and refunded_amount, sums its settled purchase, capture,
* and refund transactions, and writes a RECON_MISMATCH note to staff_notes when
* the two disagree by more than a cent. It never edits total_inc_tax,
* refunded_amount, or status_id. Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/transactions-total-does-not-match-the-order/
*/
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 MIN_DATE_MODIFIED = process.env.MIN_DATE_MODIFIED || "";
const EPSILON_CENTS = Number(process.env.RECON_EPSILON_CENTS || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const RECON_STATUS_IDS = new Set([2, 3, 4, 10, 14]);
const CHARGE_TYPES = new Set(["purchase", "capture"]);
export function toCents(amount) {
return Math.round(Number(amount) * 100);
}
/**
* Pure decision function. No network calls.
* order: { totalIncTax: number|string, refundedAmount: number|string }
* transactions: Array<{ type: "purchase"|"capture"|"refund"|"void", amount: number|string, success: boolean }>
*/
export function reconcileOrderTransactions(order, transactions, epsilonCents = 1) {
const settledIn = transactions
.filter((t) => t.success && CHARGE_TYPES.has(t.type))
.reduce((sum, t) => sum + toCents(t.amount), 0);
const settledOut = transactions
.filter((t) => t.success && t.type === "refund")
.reduce((sum, t) => sum + toCents(t.amount), 0);
const actualNet = settledIn - settledOut;
const expectedNet = toCents(order.totalIncTax) - toCents(order.refundedAmount);
const diffCents = actualNet - expectedNet;
return {
isMismatched: Math.abs(diffCents) > epsilonCents,
expectedNet,
actualNet,
diffCents,
};
}
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) {
let params = `page=${page}&limit=50`;
if (MIN_DATE_MODIFIED) params += `&min_date_modified=${MIN_DATE_MODIFIED}`;
const rows = await bc("GET", `/v2/orders?${params}`);
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 orderTransactions(orderId) {
const rows = (await bc("GET", `/v2/orders/${orderId}/transactions`)) || [];
return rows.map((row) => ({ type: row.type, amount: row.amount, success: Boolean(row.success) }));
}
async function flagOrder(orderId, result) {
const note = `RECON_MISMATCH: expected=${result.expectedNet} actual=${result.actualNet} diff=${result.diffCents}`;
return bc("PUT", `/v2/orders/${orderId}`, { staff_notes: note });
}
export async function run() {
let flagged = 0;
for await (const row of ordersToCheck()) {
const order = { totalIncTax: row.total_inc_tax, refundedAmount: row.refunded_amount };
const transactions = await orderTransactions(row.id);
const result = reconcileOrderTransactions(order, transactions, EPSILON_CENTS);
if (!result.isMismatched) continue;
console.warn(
`Order #${row.id} mismatched. expected=${result.expectedNet} actual=${result.actualNet} diff=${result.diffCents}. ${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 reconciliation rule is the part most worth testing, because it decides which orders get flagged for a human to chase. Because we kept reconcile_order_transactions pure, arithmetic only, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.
from find_transactions_mismatch import reconcile_order_transactions, to_cents
def order(total_inc_tax="100.00", refunded_amount="0.00"):
return {"totalIncTax": total_inc_tax, "refundedAmount": refunded_amount}
def txn(amount, type="purchase", success=True):
return {"type": type, "amount": amount, "success": success}
def test_to_cents_rounds():
assert to_cents("50.00") == 5000
assert to_cents("9.99") == 999
def test_exact_match_not_mismatched():
result = reconcile_order_transactions(order("100.00", "0.00"), [txn("100.00")])
assert result["isMismatched"] is False
assert result["diffCents"] == 0
def test_over_refund_is_mismatched():
# order says 40 net expected, but only a 100 purchase and no refund transaction recorded
result = reconcile_order_transactions(order("100.00", "60.00"), [txn("100.00")])
assert result["isMismatched"] is True
assert result["diffCents"] == 6000
def test_missing_refund_transaction_is_mismatched():
# refunded_amount says 20 was refunded, but the ledger shows no refund row
result = reconcile_order_transactions(
order("100.00", "20.00"),
[txn("100.00")],
)
assert result["isMismatched"] is True
assert result["diffCents"] == 2000
def test_matching_refund_transaction_ties_out():
result = reconcile_order_transactions(
order("100.00", "20.00"),
[txn("100.00"), txn("20.00", type="refund")],
)
assert result["isMismatched"] is False
def test_failed_transaction_ignored():
result = reconcile_order_transactions(
order("100.00", "0.00"),
[txn("100.00"), txn("50.00", type="refund", success=False)],
)
assert result["isMismatched"] is False
def test_rounding_at_epsilon_boundary():
# exactly at epsilon should not trip, one cent over should
result_at = reconcile_order_transactions(order("100.00", "0.00"), [txn("100.01")], epsilon_cents=1)
assert result_at["isMismatched"] is False
result_over = reconcile_order_transactions(order("100.00", "0.00"), [txn("100.02")], epsilon_cents=1)
assert result_over["isMismatched"] is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { reconcileOrderTransactions, toCents } from "./find-transactions-mismatch.js";
const order = (totalIncTax = "100.00", refundedAmount = "0.00") => ({ totalIncTax, refundedAmount });
const txn = (amount, { type = "purchase", success = true } = {}) => ({ type, amount, success });
test("toCents rounds", () => {
assert.equal(toCents("50.00"), 5000);
assert.equal(toCents("9.99"), 999);
});
test("exact match is not mismatched", () => {
const result = reconcileOrderTransactions(order("100.00", "0.00"), [txn("100.00")]);
assert.equal(result.isMismatched, false);
assert.equal(result.diffCents, 0);
});
test("over refund is mismatched", () => {
const result = reconcileOrderTransactions(order("100.00", "60.00"), [txn("100.00")]);
assert.equal(result.isMismatched, true);
assert.equal(result.diffCents, 6000);
});
test("missing refund transaction is mismatched", () => {
const result = reconcileOrderTransactions(order("100.00", "20.00"), [txn("100.00")]);
assert.equal(result.isMismatched, true);
assert.equal(result.diffCents, 2000);
});
test("matching refund transaction ties out", () => {
const result = reconcileOrderTransactions(order("100.00", "20.00"), [
txn("100.00"),
txn("20.00", { type: "refund" }),
]);
assert.equal(result.isMismatched, false);
});
test("failed transaction ignored", () => {
const result = reconcileOrderTransactions(order("100.00", "0.00"), [
txn("100.00"),
txn("50.00", { type: "refund", success: false }),
]);
assert.equal(result.isMismatched, false);
});
test("rounding at the epsilon boundary", () => {
const resultAt = reconcileOrderTransactions(order("100.00", "0.00"), [txn("100.01")], 1);
assert.equal(resultAt.isMismatched, false);
const resultOver = reconcileOrderTransactions(order("100.00", "0.00"), [txn("100.02")], 1);
assert.equal(resultOver.isMismatched, true);
});
Case studies
The support agent who refunded in Stripe directly
A merchant's support team had access to the Stripe dashboard and got in the habit of issuing quick refunds there instead of walking a customer through BigCommerce's own refund flow. Stripe recorded the money leaving fine, but BigCommerce's refunded_amount never moved, so the order still looked fully paid in every report pulled from BigCommerce.
Running the reconciliation job once a day caught every one of those orders within twenty-four hours. Each got a RECON_MISMATCH note in staff_notes, and finance now had a short list to walk through by hand instead of discovering the gap during a quarterly audit.
The store that rounded refunds down to a whole dollar
A merchant's policy was to round customer refunds down to the nearest dollar as a goodwill gesture, so staff would take the amount refund_quotes returned and manually type in a smaller number when applying the refund. Over a few months this created dozens of orders where the transaction ledger and the order's refunded_amount disagreed by small amounts.
The script flagged these consistently because the diff was always a few cents, comfortably above the one-cent epsilon. Seeing the pattern in the flagged notes, the merchant changed the policy to always use the quoted amount, and the new mismatches stopped appearing.
After this runs on a schedule, a drift between the gateway ledger and the order record gets caught within one reconciliation cycle instead of during a stressful audit. Every flagged order carries the expected and actual net amounts right in staff_notes, so whoever reviews it starts with the numbers already computed. The totals themselves are never touched by the script, so there is no risk of it masking a real accounting problem or double-crediting a customer.
FAQ
Why does a BigCommerce order's total not match its transactions?
Order totals such as total_inc_tax and refunded_amount are updated through the refund_quotes and refund_actions endpoints, while the actual money movement is recorded as separate purchase, refund, and void transaction rows against the gateway. When a merchant refunds directly in the gateway dashboard, applies a partial or store-credit refund that is never posted back as a transaction, or overrides a refund_quote amount, the two records fall out of sync.
Can I just patch total_inc_tax or refunded_amount to fix the mismatch?
No. Blindly writing PUT /v2/orders/{id} to change the total or refunded_amount risks masking a real accounting error or double-crediting a customer, because the discrepancy can originate on either side, the gateway or the BigCommerce order record. The safe pattern is to flag the order with a machine-readable note and let a human confirm which side is authoritative before any correcting write happens.
How do I detect a transactions mismatch on a BigCommerce order?
Pull the order with GET /v2/orders/{id} for total_inc_tax and refunded_amount, then GET /v2/orders/{id}/transactions for every purchase, capture, refund, and void row. Sum the settled purchase and capture amounts, subtract settled refunds, and compare that net figure against total_inc_tax minus refunded_amount. Flag the order when the difference exceeds a small epsilon such as one cent.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: Order Refunds, refund_quotes and refund_actions. developer.bigcommerce.com/docs/store-operations/orders/refunds
- BigCommerce Help Center: Using Order Actions. support.bigcommerce.com/s/article/Using-Order-Actions
- BigCommerce Help Center: Processing Refunds. support.bigcommerce.com/s/article/Processing-Refunds
On the solution:
- BigCommerce API Reference: Order Transactions. developer.bigcommerce.com/docs/rest-management/transactions
- BigCommerce API Reference: Payment Actions, refund quotes, refunds, captures. developer.bigcommerce.com/docs/rest-management/transactions/payment-actions
- BigCommerce API Reference: Orders. developer.bigcommerce.com/docs/rest-management/orders
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, inventory, webhooks, 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 a reconciliation headache?
If this saved you a confusing audit 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