Diagnostic Orders
Status change skips side effect actions like capture or void
An order gets marked Refunded, or Cancelled, or Completed, and the status looks right in the admin. But nothing was actually refunded, voided, or captured in the payment gateway. BigCommerce's status_id field is only a label on the order record. It has no hook back into the gateway. Only the Action menu, Refund, Void transaction, Capture funds, actually talks to the payment processor and updates status_id as a side effect. Write status_id directly and you get the label with none of the money movement. Here is why that gap opens up and a script that flags every order where the status claims an action that never happened.
BigCommerce's admin Action menu, Refund, Void transaction, Capture funds, is what actually calls the payment gateway. The gateway call succeeds or fails first, and only then does BigCommerce update the order's status_id as a side effect of that action. The status_id field itself carries no logic back to the gateway. So when an integration or a careless script writes status_id directly with PUT /v2/orders/{id}, for example setting it to 4 (Refunded) or 5 (Cancelled), BigCommerce updates the label the order shows, but no capture, void, or refund transaction is ever created, and no money moves. Run a small Python or Node.js script that pulls orders whose status_id implies a completed payment action, reads each order's transactions with GET /v2/orders/{id}/transactions, and flags any order where the implied action, a refund, a void, or a capture, has no matching successful transaction to back it up. Full code, tests, and citations are below.
The problem in plain words
In the BigCommerce admin, clicking Refund, Void transaction, or Capture funds on an order does two things in a fixed order. First it calls the payment gateway and waits for a real result. Then, and only if that call succeeds, it writes the corresponding status_id and creates a transaction row recording what the gateway did. That transaction row is the receipt. The status_id is just the label that gets updated once the receipt exists.
The status_id field does not know any of that history. It is a plain integer on the order resource, and BigCommerce's V2 Orders API lets you write it directly with PUT /v2/orders/{id}. Nothing on the receiving end asks "did a matching gateway call happen." If an integration syncs order status from an external system, or a support script "fixes" an order by setting status_id to 4 for Refunded, the write succeeds immediately. The order now displays Refunded. But no refund transaction exists, no money moved, and payment_status still reflects whatever the last real gateway transaction actually did.
Why it happens
The gap exists because BigCommerce keeps the label and the action deliberately separate at the API level, and a few common patterns end up writing only the label:
- An external system of record (an ERP, a CRM, a support tool) treats BigCommerce's order status as a mirror of its own state, and syncs it with a plain
PUT /v2/orders/{id}whenever its own record changes, without ever calling BigCommerce's payment action endpoints. - A support agent or script "corrects" an order's status by hand to match what happened outside BigCommerce, for example a refund processed directly in the gateway's dashboard, and updates status_id to match without going through the Refund action.
- A migration or bulk-import job sets status_id on historical orders to reflect their final state, Cancelled, Refunded, Completed, with no accompanying transaction history, because the transactions never existed in the source system either.
- An integration assumes status_id 5 (Cancelled) always implies a void happened, when in fact a Cancelled order on an authorize-only gateway can be cancelled before it was ever captured, meaning there is nothing to void, or after a capture, meaning a void was required but skipped.
This is a well documented point of confusion. BigCommerce's own support article on Order Actions draws an explicit line between status labels and the Action menu, and other support threads confirm merchants have manually set Refunded only to discover the customer was never paid back. See the citations at the end for the specific sources.
status_id is not proof anything happened at the gateway. The transaction record is. So the safe pattern is not "trust the order's current status." It is "for every order whose status_id implies a completed payment action, refunded, voided, or captured, check GET /v2/orders/{id}/transactions for a matching, successful transaction of that exact type." An order at status_id 4 or 14 needs an ok refund transaction. An order at status_id 5 that started as an authorize-only charge needs an ok void. An order at status_id 2, 9, 10, or 11 that started as authorize-only needs an ok capture or purchase. Anything missing is a real gap, not a false alarm, and it gets reported, never silently repaired.
The fix, as a flow
We do not touch the Action menu or the checkout flow. We add a read-only reconciler that lists candidate orders by status_id, pulls each order's transaction history, runs it through one pure decision function, and reports any order whose status implies a payment action that the transaction log does not back up.
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-only is enough for detection) scope so it can read order status and transactions. 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 CANDIDATE_STATUS_IDS="4,5,10,14"
export DRY_RUN="true" # start safe, this tool only reports by default
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export CANDIDATE_STATUS_IDS="4,5,10,14"
export DRY_RUN="true" // start safe, this tool only reports by default
Talk to the V2 Orders and Transactions 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 raises on a non-2xx response. This tool only reads; it never writes status_id or calls a payment action on its own.
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 []
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) : [];
}
List candidate orders and read their transactions
Call GET /v2/orders?status_id={4,5,10,14}&limit=250, paginated, to pull orders whose status implies a completed payment action: 4 and 14 imply a refund, 5 implies a possible void, 2, 9, 10, and 11 imply a possible capture on an authorize-only gateway. For each candidate, call GET /v2/orders/{id}/transactions to get the transaction list the decision needs: type and status.
CANDIDATE_STATUS_IDS = [4, 5, 10, 14]
def candidate_orders(status_ids=None):
status_ids = status_ids or CANDIDATE_STATUS_IDS
page = 1
while True:
found_any = False
for status_id in status_ids:
orders = bc_get("/orders", {"status_id": status_id, "page": page, "limit": 250})
for order in orders:
found_any = True
yield order
if not found_any:
return
page += 1
def order_transactions(order_id):
return bc_get(f"/orders/{order_id}/transactions")
const CANDIDATE_STATUS_IDS = [4, 5, 10, 14];
async function* candidateOrders(statusIds = CANDIDATE_STATUS_IDS) {
let page = 1;
while (true) {
let foundAny = false;
for (const statusId of statusIds) {
const orders = await bcGet("/orders", { status_id: statusId, page, limit: 250 });
for (const order of orders) {
foundAny = true;
yield order;
}
}
if (!foundAny) return;
page += 1;
}
}
async function orderTransactions(orderId) {
return bcGet(`/orders/${orderId}/transactions`);
}
Decide, with one pure function
Keep the decision in its own function that takes the order (with its status_id and payment_status) and its transactions, and returns a violation code or none. It only looks at transactions with status "ok", since a pending or declined attempt does not count as an actual side effect. An order is only considered authorize-only if its ok transactions include an auth with no matching capture or purchase, since a Cancelled order that was never captured has nothing to void.
REFUND_STATUS_IDS = {4, 14} # Refunded, Partially Refunded
CANCELLED_STATUS_ID = 5 # Cancelled
CAPTURE_IMPLIED_STATUS_IDS = {2, 9, 10, 11} # Shipped, Awaiting Shipment, Completed, Awaiting Fulfillment
def find_status_without_payment_action(order, transactions):
ok_txns = [t for t in transactions if t.get("status") == "ok"]
has = lambda ttype: any(t.get("type") == ttype for t in ok_txns)
had_auth_only = has("auth") and not has("capture") and not has("purchase")
status_id = order["status_id"]
if status_id in REFUND_STATUS_IDS and not has("refund"):
return "MISSING_REFUND"
if status_id == CANCELLED_STATUS_ID and had_auth_only and not has("void"):
return "MISSING_VOID"
if status_id in CAPTURE_IMPLIED_STATUS_IDS and had_auth_only:
return "MISSING_CAPTURE"
return None
const REFUND_STATUS_IDS = new Set([4, 14]); // Refunded, Partially Refunded
const CANCELLED_STATUS_ID = 5; // Cancelled
const CAPTURE_IMPLIED_STATUS_IDS = new Set([2, 9, 10, 11]); // Shipped, Awaiting Shipment, Completed, Awaiting Fulfillment
export function findStatusWithoutPaymentAction(order, transactions) {
const okTxns = (transactions || []).filter((t) => t.status === "ok");
const has = (ttype) => okTxns.some((t) => t.type === ttype);
const hadAuthOnly = has("auth") && !has("capture") && !has("purchase");
const statusId = order.status_id;
if (REFUND_STATUS_IDS.has(statusId) && !has("refund")) return "MISSING_REFUND";
if (statusId === CANCELLED_STATUS_ID && hadAuthOnly && !has("void")) return "MISSING_VOID";
if (CAPTURE_IMPLIED_STATUS_IDS.has(statusId) && hadAuthOnly) return "MISSING_CAPTURE";
return null;
}
Cross-check payment_status, then report, never auto-repair
Before flagging, also compare the order's payment_status field against status_id, since a genuine mismatch there (a "Refunded" order whose payment_status still reads "captured") is strong confirmation the violation is real rather than a quirk of the transaction feed. When find_status_without_payment_action returns a violation code, emit a report with order_id, status_id, payment_status, missing_action, and last_transaction. Do not call the gateway from here. The true external state (funds may already have settled outside BigCommerce, or the gap may be intentional) is unknown to the script.
def build_report(order, transactions, violation):
last_transaction = transactions[-1] if transactions else None
return {
"order_id": order["id"],
"status_id": order["status_id"],
"payment_status": order.get("payment_status"),
"missing_action": violation,
"last_transaction": last_transaction,
}
def apply_remediation(order_id, action, dry_run=True):
"""Gated remediation. Never called automatically; only after a human
confirms the specific order_id list from the report."""
if dry_run:
log.info("DRY_RUN: would call payment_actions/%s for order %s", action, order_id)
return None
# POST https://api.bigcommerce.com/stores/{store_hash}/v3/orders/{order_id}/payment_actions/{action}
raise NotImplementedError("Wire this to the v3 payment_actions endpoint only after manual approval")
function buildReport(order, transactions, violation) {
const lastTransaction = transactions.length ? transactions[transactions.length - 1] : null;
return {
orderId: order.id,
statusId: order.status_id,
paymentStatus: order.payment_status,
missingAction: violation,
lastTransaction,
};
}
async function applyRemediation(orderId, action, dryRun = true) {
// Gated remediation. Never called automatically; only after a human
// confirms the specific order_id list from the report.
if (dryRun) {
console.log(`DRY_RUN: would call payment_actions/${action} for order ${orderId}`);
return null;
}
// POST https://api.bigcommerce.com/stores/{store_hash}/v3/orders/{order_id}/payment_actions/{action}
throw new Error("Wire this to the v3 payment_actions endpoint only after manual approval");
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard applies even to the reporting path, since this tool is flag-and-report by design. On the first few runs, review every flagged order manually against the gateway's own dashboard. Only after a human confirms a genuine gap should the dedicated Payment Actions endpoints (payment_actions/capture, payment_actions/void, payment_actions/refund_quotes then payment_actions/refunds) be called for that specific order_id, and even then behind DRY_RUN=false.
This tool never writes status_id and never calls a payment action endpoint on its own. It only reports. If the team confirms a genuine gap, remediate with the dedicated Payment Actions endpoints, never by rewriting status_id, and only after a human has approved the exact order_id list. Auto-calling capture, void, or refund on a guess can double-charge or double-refund a real customer.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs exactly what it found, and never calls a payment action or writes status_id on its own. Every remediation path is gated behind DRY_RUN and requires a human-approved order_id.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find BigCommerce orders whose status_id implies a completed payment action,
a refund, a void, or a capture, that never actually happened at the gateway.
BigCommerce's admin Action menu (Refund, Void transaction, Capture funds) is
what calls the payment gateway; it updates status_id only as a side effect
after that call succeeds. status_id itself is a plain label with no hook back
into the gateway. Writing it directly with PUT /v2/orders/{id} changes the
label instantly but skips the gateway call entirely, so an order can read
Refunded or Cancelled with no refund or void transaction ever created. This
job lists candidate orders by status_id, reads each order's transactions, and
flags any order whose implied payment action has no matching successful
transaction to back it up. It never writes status_id and never calls a
payment action on its own; it only reports, for a human to confirm before any
remediation. Run on demand or on a schedule. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/status-change-skips-payment-side-effects/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_status_without_payment_action")
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"
CANDIDATE_STATUS_IDS = [
int(s.strip()) for s in os.environ.get("CANDIDATE_STATUS_IDS", "4,5,10,14").split(",") if s.strip()
]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
REFUND_STATUS_IDS = {4, 14} # Refunded, Partially Refunded
CANCELLED_STATUS_ID = 5 # Cancelled
CAPTURE_IMPLIED_STATUS_IDS = {2, 9, 10, 11} # Shipped, Awaiting Shipment, Completed, Awaiting Fulfillment
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 find_status_without_payment_action(order: dict, transactions: list) -> "str | None":
"""Pure decision. No network, no side effects.
order: dict with at least {'id': int, 'status_id': int, 'payment_status': str}
transactions: list of dicts with at least {'type': str, 'status': str}
Only transactions with status "ok" count as real side effects. An order
is treated as authorize-only if it has an ok "auth" transaction with no
matching ok "capture" or "purchase". Returns a violation code
(MISSING_REFUND, MISSING_VOID, MISSING_CAPTURE) or None if the status
and the transaction history are consistent.
"""
ok_txns = [t for t in transactions if t.get("status") == "ok"]
has = lambda ttype: any(t.get("type") == ttype for t in ok_txns)
had_auth_only = has("auth") and not has("capture") and not has("purchase")
status_id = order["status_id"]
if status_id in REFUND_STATUS_IDS and not has("refund"):
return "MISSING_REFUND"
if status_id == CANCELLED_STATUS_ID and had_auth_only and not has("void"):
return "MISSING_VOID"
if status_id in CAPTURE_IMPLIED_STATUS_IDS and had_auth_only:
return "MISSING_CAPTURE"
return None
def candidate_orders():
"""Page through orders at the configured candidate status_ids."""
page = 1
while True:
found_any = False
for status_id in CANDIDATE_STATUS_IDS:
orders = bc_get("/orders", {"status_id": status_id, "page": page, "limit": 250})
for order in orders:
found_any = True
yield order
if not found_any:
return
page += 1
def order_transactions(order_id):
return bc_get(f"/orders/{order_id}/transactions")
def build_report(order, transactions, violation):
last_transaction = transactions[-1] if transactions else None
return {
"order_id": order["id"],
"status_id": order["status_id"],
"payment_status": order.get("payment_status"),
"missing_action": violation,
"last_transaction": last_transaction,
}
def apply_remediation(order_id, action):
"""Gated remediation. Never called from run(); only wire this up after a
human has confirmed a specific order_id list from the report below.
action is one of "capture", "void", "refund". Each maps to
POST https://api.bigcommerce.com/stores/{store_hash}/v3/orders/{order_id}/payment_actions/{action}
(refund additionally requires a prior refund_quotes call). Always keep
this behind DRY_RUN so a real gateway call only fires when a human has
approved the order.
"""
if DRY_RUN:
log.info("DRY_RUN: would call payment_actions/%s for order %s", action, order_id)
return None
raise NotImplementedError(
"Wire this to the v3 payment_actions endpoint only after manual, per-order approval"
)
def run():
flagged = 0
clean = 0
for order in candidate_orders():
order_id = order["id"]
transactions = order_transactions(order_id)
violation = find_status_without_payment_action(order, transactions)
if violation is None:
clean += 1
continue
flagged += 1
report = build_report(order, transactions, violation)
log.warning(
"order_id=%s status_id=%s payment_status=%s missing_action=%s last_transaction=%s",
report["order_id"], report["status_id"], report["payment_status"],
report["missing_action"], report["last_transaction"],
)
log.info("Done. %d order(s) flagged, %d order(s) consistent.", flagged, clean)
if __name__ == "__main__":
run()
/**
* Find BigCommerce orders whose status_id implies a completed payment action,
* a refund, a void, or a capture, that never actually happened at the
* gateway.
*
* BigCommerce's admin Action menu (Refund, Void transaction, Capture funds)
* is what calls the payment gateway; it updates status_id only as a side
* effect after that call succeeds. status_id itself is a plain label with no
* hook back into the gateway. Writing it directly with PUT /v2/orders/{id}
* changes the label instantly but skips the gateway call entirely, so an
* order can read Refunded or Cancelled with no refund or void transaction
* ever created. This job lists candidate orders by status_id, reads each
* order's transactions, and flags any order whose implied payment action has
* no matching successful transaction to back it up. It never writes
* status_id and never calls a payment action on its own; it only reports,
* for a human to confirm before any remediation.
*
* Guide: https://www.allanninal.dev/bigcommerce/status-change-skips-payment-side-effects/
*/
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 CANDIDATE_STATUS_IDS = (process.env.CANDIDATE_STATUS_IDS || "4,5,10,14")
.split(",")
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n));
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
const REFUND_STATUS_IDS = new Set([4, 14]); // Refunded, Partially Refunded
const CANCELLED_STATUS_ID = 5; // Cancelled
const CAPTURE_IMPLIED_STATUS_IDS = new Set([2, 9, 10, 11]); // Shipped, Awaiting Shipment, Completed, Awaiting Fulfillment
/**
* Pure decision. No network, no side effects.
*
* order: object with at least {id, status_id, payment_status}
* transactions: array of objects with at least {type, status}
*
* Only transactions with status "ok" count as real side effects. An order
* is treated as authorize-only if it has an ok "auth" transaction with no
* matching ok "capture" or "purchase". Returns a violation code
* (MISSING_REFUND, MISSING_VOID, MISSING_CAPTURE) or null if the status and
* the transaction history are consistent.
*/
export function findStatusWithoutPaymentAction(order, transactions) {
const okTxns = (transactions || []).filter((t) => t.status === "ok");
const has = (ttype) => okTxns.some((t) => t.type === ttype);
const hadAuthOnly = has("auth") && !has("capture") && !has("purchase");
const statusId = order.status_id;
if (REFUND_STATUS_IDS.has(statusId) && !has("refund")) return "MISSING_REFUND";
if (statusId === CANCELLED_STATUS_ID && hadAuthOnly && !has("void")) return "MISSING_VOID";
if (CAPTURE_IMPLIED_STATUS_IDS.has(statusId) && hadAuthOnly) return "MISSING_CAPTURE";
return null;
}
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* candidateOrders() {
let page = 1;
while (true) {
let foundAny = false;
for (const statusId of CANDIDATE_STATUS_IDS) {
const orders = await bcGet("/orders", { status_id: statusId, page, limit: 250 });
for (const order of orders) {
foundAny = true;
yield order;
}
}
if (!foundAny) return;
page += 1;
}
}
async function orderTransactions(orderId) {
return bcGet(`/orders/${orderId}/transactions`);
}
function buildReport(order, transactions, violation) {
const lastTransaction = transactions.length ? transactions[transactions.length - 1] : null;
return {
orderId: order.id,
statusId: order.status_id,
paymentStatus: order.payment_status,
missingAction: violation,
lastTransaction,
};
}
/**
* Gated remediation. Never called from run(); only wire this up after a
* human has confirmed a specific orderId list from the report below.
*
* action is one of "capture", "void", "refund". Each maps to
* POST https://api.bigcommerce.com/stores/{store_hash}/v3/orders/{order_id}/payment_actions/{action}
* (refund additionally requires a prior refund_quotes call). Always keep
* this behind DRY_RUN so a real gateway call only fires when a human has
* approved the order.
*/
async function applyRemediation(orderId, action) {
if (DRY_RUN) {
console.log(`DRY_RUN: would call payment_actions/${action} for order ${orderId}`);
return null;
}
throw new Error("Wire this to the v3 payment_actions endpoint only after manual, per-order approval");
}
export async function run() {
let flagged = 0;
let clean = 0;
for await (const order of candidateOrders()) {
const orderId = order.id;
const transactions = await orderTransactions(orderId);
const violation = findStatusWithoutPaymentAction(order, transactions);
if (violation === null) {
clean += 1;
continue;
}
flagged += 1;
const report = buildReport(order, transactions, violation);
console.warn(
`order_id=${report.orderId} status_id=${report.statusId} payment_status=${report.paymentStatus} ` +
`missing_action=${report.missingAction} last_transaction=${JSON.stringify(report.lastTransaction)}`
);
}
console.log(`Done. ${flagged} order(s) flagged, ${clean} order(s) consistent.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a real order gets flagged as having skipped a refund, void, or capture. Because find_status_without_payment_action takes only plain values and returns a plain string or none, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.
from find_status_without_payment_action import find_status_without_payment_action
def order(status_id, payment_status="captured"):
return {"id": 101, "status_id": status_id, "payment_status": payment_status}
def txn(type_, status="ok"):
return {"type": type_, "status": status}
def test_refunded_order_with_ok_refund_is_consistent():
assert find_status_without_payment_action(order(4), [txn("auth"), txn("capture"), txn("refund")]) is None
def test_refunded_order_with_no_refund_transaction_is_flagged():
assert find_status_without_payment_action(order(4), [txn("auth"), txn("capture")]) == "MISSING_REFUND"
def test_partially_refunded_order_with_no_refund_transaction_is_flagged():
assert find_status_without_payment_action(order(14), [txn("auth"), txn("capture")]) == "MISSING_REFUND"
def test_cancelled_order_authorize_only_with_no_void_is_flagged():
assert find_status_without_payment_action(order(5), [txn("auth")]) == "MISSING_VOID"
def test_cancelled_order_authorize_only_with_void_is_consistent():
assert find_status_without_payment_action(order(5), [txn("auth"), txn("void")]) is None
def test_cancelled_order_with_no_transactions_at_all_needs_no_void():
assert find_status_without_payment_action(order(5), []) is None
def test_shipped_order_authorize_only_never_captured_is_flagged():
assert find_status_without_payment_action(order(2), [txn("auth")]) == "MISSING_CAPTURE"
def test_completed_order_with_ok_capture_is_consistent():
assert find_status_without_payment_action(order(10), [txn("auth"), txn("capture")]) is None
def test_awaiting_fulfillment_order_with_purchase_transaction_is_consistent():
assert find_status_without_payment_action(order(11), [txn("purchase")]) is None
def test_pending_or_declined_transactions_do_not_count_as_the_side_effect():
txns = [txn("auth"), txn("refund", status="pending")]
assert find_status_without_payment_action(order(4), txns) == "MISSING_REFUND"
import { test } from "node:test";
import assert from "node:assert/strict";
import { findStatusWithoutPaymentAction } from "./find-status-without-payment-action.js";
const makeOrder = (statusId, paymentStatus = "captured") => ({
id: 101, status_id: statusId, payment_status: paymentStatus,
});
const txn = (type, status = "ok") => ({ type, status });
test("refunded order with ok refund is consistent", () => {
assert.equal(
findStatusWithoutPaymentAction(makeOrder(4), [txn("auth"), txn("capture"), txn("refund")]),
null
);
});
test("refunded order with no refund transaction is flagged", () => {
assert.equal(
findStatusWithoutPaymentAction(makeOrder(4), [txn("auth"), txn("capture")]),
"MISSING_REFUND"
);
});
test("partially refunded order with no refund transaction is flagged", () => {
assert.equal(
findStatusWithoutPaymentAction(makeOrder(14), [txn("auth"), txn("capture")]),
"MISSING_REFUND"
);
});
test("cancelled order never captured needs no void", () => {
assert.equal(findStatusWithoutPaymentAction(makeOrder(5), [txn("auth")]), null);
});
test("cancelled order authorized and captured is not a void case", () => {
assert.equal(
findStatusWithoutPaymentAction(makeOrder(5), [txn("auth"), txn("capture")]),
null
);
});
test("shipped order authorize only never captured is flagged", () => {
assert.equal(findStatusWithoutPaymentAction(makeOrder(2), [txn("auth")]), "MISSING_CAPTURE");
});
test("completed order with ok capture is consistent", () => {
assert.equal(
findStatusWithoutPaymentAction(makeOrder(10), [txn("auth"), txn("capture")]),
null
);
});
test("awaiting fulfillment order with purchase transaction is consistent", () => {
assert.equal(findStatusWithoutPaymentAction(makeOrder(11), [txn("purchase")]), null);
});
test("pending or declined transactions do not count as the side effect", () => {
const txns = [txn("auth"), txn("refund", "pending")];
assert.equal(findStatusWithoutPaymentAction(makeOrder(4), txns), "MISSING_REFUND");
});
Case studies
The integration that synced status, not transactions
A store ran its returns process through an external ERP. When the ERP marked a return complete, a nightly sync job wrote status_id 4 straight onto the matching BigCommerce order to keep the two systems looking aligned. For months, the order list looked correct, every returned order read Refunded.
Running the reconciler surfaced dozens of orders at status_id 4 with no ok refund transaction at all. The ERP's own refund step had been failing silently for a subset of gateways, and the status sync had been masking it the entire time. Nothing was auto-repaired; each flagged order_id went to finance to confirm and process the actual refund through the real Refund action.
The support team that cancelled orders by hand
A support agent handling an inventory issue would cancel affected orders by setting status_id to 5 directly through an internal admin tool, faster than clicking through the storefront Action menu. Most of those orders had only ever been authorized, never captured, so there was nothing to void, and the shortcut caused no harm, the reconciler correctly left them alone.
But a handful of those authorize-only orders had their hold expire or get voided out of band without ever being logged back to BigCommerce as a void transaction, so status_id read Cancelled with only an ok auth on record and nothing closing it out. The reconciler's MISSING_VOID flag exists precisely for that shape: an ok auth with no matching capture or purchase, and no void. Once flagged, the team could confirm with the gateway whether the hold had actually released, instead of assuming a Cancelled label meant the story was over.
After this runs on a schedule, status_id stops being trusted on its own. Every order whose status implies a refund, a void, or a capture gets checked against the actual transaction log, and any genuine gap surfaces with the order_id, status_id, payment_status, missing_action, and last_transaction a human needs to act on it. Nothing gets auto-refunded, auto-voided, or auto-captured. The only writes that ever happen are the dedicated Payment Actions calls, made deliberately, one approved order_id at a time.
FAQ
Why did marking a BigCommerce order Refunded not actually refund the customer?
Setting status_id to 4 (Refunded) with PUT /v2/orders/{id} only changes the label BigCommerce shows for the order. The actual refund is performed by the Refund action in the Order Actions menu, or the refund_quotes and refunds payment action endpoints, which call the payment gateway and then update the order as a side effect. Writing status_id directly skips the gateway call entirely, so the order reads Refunded while no money ever moved and no refund transaction was created.
How do I find orders where the status implies a payment action that never happened?
Pull orders whose status_id implies a completed payment action, Refunded, Partially Refunded, Cancelled, Shipped, Awaiting Shipment, Completed, or Awaiting Fulfillment, then call GET /v2/orders/{id}/transactions for each one and check whether a transaction of the matching type, refund, void, or capture, actually exists with status ok. If a Refunded order has no ok refund transaction, or a Cancelled order that was only authorized has no void, or a Shipped order that was only authorized has no capture, that order is flagged as missing its payment side effect.
Should a script auto-call capture, void, or refund on a flagged order?
No. The script cannot know the true state of money outside BigCommerce, the funds may already be settled through some other channel, or the merchant may have intentionally left the action undone. Auto-calling the payment action endpoints could double-charge or double-refund a real customer. Flag the order with its status_id, payment_status, missing_action, and last_transaction for a human to review, and only call the dedicated Payment Actions endpoints after that review confirms the specific order_id, gated behind a DRY_RUN flag.
Related field notes
Citations
On the problem:
- BigCommerce Support: Using Order Actions, the distinction between order status and the Action menu. support.bigcommerce.com using order actions
- BigCommerce Support: Manually Capturing Transactions (Authorize Only). support.bigcommerce.com manually capturing transactions (authorize only)
- BigCommerce Support Community: is there a way to use the API to automate payment capture. support.bigcommerce.com automate payment capture via API
On the solution:
- BigCommerce Developer Center: Payment Actions, capture, void, and refund endpoints. developer.bigcommerce.com payment actions
- BigCommerce Developer Center: Order Transactions endpoint. developer.bigcommerce.com order transactions
- BigCommerce Developer Center: Order Refunds. developer.bigcommerce.com order refunds
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 catch a status that lied to you?
If this saved you from trusting a Refunded or Cancelled label that was not backed by a real transaction, 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