Reconciler Orders and payments
BigCommerce orders stuck on Awaiting Payment after capture
The gateway shows the money captured. The transaction record on the order agrees. But the order itself still sits at status_id 7, Awaiting Payment, so it never reaches fulfillment. BigCommerce order status and payment status are decoupled from the actual gateway transaction, and the async confirmation that is supposed to move the order along can go missing. Here is why that gap opens up and a small script that finds the orders truly captured and safely advances only those.
Capturing an authorize-only payment sets payment_status to Pending Capture while the gateway processes the capture out of band. When the confirmation callback is slow, silently fails, or the merchant captured the funds directly in the gateway's own dashboard, the order's status_id never advances past 7 (Awaiting Payment) even though the transaction shows a successful capture. Run a small Python or Node.js script that lists orders at status_id 7, reads each order's transactions with GET /v2/orders/{id}/transactions, and moves only the ones with an unambiguous successful capture or sale transaction whose amount matches the order total to status_id 11 (Awaiting Fulfillment) with PUT /v2/orders/{id}. Full code, tests, and a dry run guard are below.
The problem in plain words
In BigCommerce, an order's status_id and its payment_status are two separate fields, and neither one is guaranteed to move just because money changed hands. When a payment method only authorizes at checkout, someone still has to capture it, either through the Capture Order Payment action in the BigCommerce admin (or API) or by charging the card directly in the gateway's own dashboard.
The capture itself is asynchronous. BigCommerce sets payment_status to Pending Capture and waits for the gateway to send back a confirmation. Most of the time that confirmation arrives quickly and the order moves forward on its own. But if the callback is slow, gets dropped, or never fires because the capture happened outside BigCommerce entirely, the order record is left exactly where it started: status_id 7, Awaiting Payment. The transaction row under that order, and the gateway itself, both agree the money is in. The order just never heard about it.
Why it happens
BigCommerce derives an order's visible status from its own event pipeline, not by polling the gateway. A few common ways stores end up with paid orders stuck on Awaiting Payment:
- An authorize-only payment method where the merchant later captures with the Capture Order Payment action, and the async job that is supposed to report back to the order is slow or silently fails.
- The merchant captures the charge directly in the payment gateway's own dashboard, bypassing BigCommerce's capture flow entirely, so BigCommerce never learns the capture happened.
- A store-side webhook processor listening for
store/order/statusUpdatedthat drops or reorders the event, since BigCommerce does not guarantee webhook ordering or dedupe delivery. A later, correct event can be overtaken by an earlier one, leaving the storefront-visible status stale. - A gateway confirmation callback that times out or errors on BigCommerce's side, with no automatic retry that ever reaches the order again.
This is a well known point of confusion for merchants: the money shows up in the payment gateway and the transaction log, but the order sitting in the admin still reads Awaiting Payment, and nothing about the storefront order tells you the two have drifted apart. See the citations at the end for the exact support threads and docs.
The order's status_id is not proof of anything. The transaction record is. So the safe pattern is not "advance every order stuck on Awaiting Payment." It is "advance only the orders whose transactions show an unambiguous successful capture or sale that matches the order total." We read GET /v2/orders/{id}/transactions directly instead of trusting a webhook, because that endpoint reflects what the gateway actually did, and we treat anything ambiguous, pending, declined, or mismatched in amount as a case for a human, never an auto-repair.
The fix, as a flow
We do not touch the live checkout or the capture flow. We add a job that lists candidate orders at status_id 7, pulls each one's transactions, and decides per order whether the evidence supports moving it to status_id 11 (Awaiting Fulfillment), flagging it for a human, or leaving it alone.
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 (modify) scope so it can read transactions and update status_id. 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 LOOKBACK_DAYS="14"
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="..."
export LOOKBACK_DAYS="14"
export DRY_RUN="true" // start safe, change to false to write
Talk to the V2 Orders REST API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v2/ with the token in the X-Auth-Token header. A small helper handles GET and PUT and raises on a non-2xx response. We reuse it to list orders, read transactions, and write the status update.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else []
def bc_put(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : [];
}
async function bcPut(path, body) {
const res = await fetch(`${API_BASE}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
List the candidate orders and read their transactions
Call GET /v2/orders?status_id=7&min_date_created=..., paginated, to get the current Awaiting Payment orders within your lookback window. For each one, call GET /v2/orders/{id}/transactions to get the transaction list the decision needs: type/event, status, amount, and the gateway fields.
AWAITING_PAYMENT = 7
def candidate_orders(lookback_days):
page = 1
while True:
orders = bc_get("/orders", {
"status_id": AWAITING_PAYMENT,
"min_date_created": f"-{lookback_days} days",
"page": page,
"limit": 50,
})
if not orders:
return
for order in orders:
yield order
page += 1
def order_transactions(order_id):
return bc_get(f"/orders/{order_id}/transactions")
const AWAITING_PAYMENT = 7;
async function* candidateOrders(lookbackDays) {
let page = 1;
while (true) {
const orders = await bcGet("/orders", {
status_id: AWAITING_PAYMENT,
min_date_created: `-${lookbackDays} days`,
page,
limit: 50,
});
if (!orders.length) return;
for (const order of orders) yield order;
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's status_id, its transactions, and its total, and returns one of three outcomes. The rule is strict on purpose. Only a matching, unambiguous successful capture or sale advances the order. A capture that is pending, declined, or off on amount gets flagged for a human. No capture-type transaction at all means the order is genuinely unpaid, so nothing happens.
from typing import Literal
AWAITING_PAYMENT = 7
CAPTURE_TYPES = {"capture", "sale"}
AMOUNT_EPSILON = 0.01
def decide_order_repair(
status_id: int, transactions: list, order_total: str
) -> Literal["advance_to_awaiting_fulfillment", "flag_for_review", "no_action"]:
if status_id != AWAITING_PAYMENT:
return "no_action"
total = float(order_total)
saw_capture_type = False
has_matching_success = False
has_problem_capture = False
for txn in transactions or []:
kind = (txn.get("type") or txn.get("event") or "").lower()
if kind not in CAPTURE_TYPES:
continue
saw_capture_type = True
status = (txn.get("status") or "").lower()
amount = float(txn.get("amount"))
matches = abs(amount - total) < AMOUNT_EPSILON
if status == "success" and matches:
has_matching_success = True
else:
has_problem_capture = True
if has_matching_success:
return "advance_to_awaiting_fulfillment"
if saw_capture_type and has_problem_capture:
return "flag_for_review"
return "no_action"
const AWAITING_PAYMENT = 7;
const CAPTURE_TYPES = new Set(["capture", "sale"]);
const AMOUNT_EPSILON = 0.01;
export function decideOrderRepair(statusId, transactions, orderTotal) {
if (statusId !== AWAITING_PAYMENT) return "no_action";
const total = Number.parseFloat(orderTotal);
let sawCaptureType = false;
let hasMatchingSuccess = false;
let hasProblemCapture = false;
for (const txn of transactions || []) {
const kind = (txn.type || txn.event || "").toLowerCase();
if (!CAPTURE_TYPES.has(kind)) continue;
sawCaptureType = true;
const status = (txn.status || "").toLowerCase();
const amount = Number.parseFloat(txn.amount);
const matches = Math.abs(amount - total) < AMOUNT_EPSILON;
if (status === "success" && matches) hasMatchingSuccess = true;
else hasProblemCapture = true;
}
if (hasMatchingSuccess) return "advance_to_awaiting_fulfillment";
if (sawCaptureType && hasProblemCapture) return "flag_for_review";
return "no_action";
}
Advance the order the same way the admin action would
When the decision is advance_to_awaiting_fulfillment, call PUT /v2/orders/{id} with {"status_id": 11}. That is the same status a merchant reaches by clicking through the order manually once payment is confirmed. Anything flagged for review is logged with enough detail, order id, total, transaction id, gateway, current and target status_id, for a human to check it in the BigCommerce admin without needing to re-run the whole job.
AWAITING_FULFILLMENT = 11
def advance_order(order_id):
return bc_put(f"/orders/{order_id}", {"status_id": AWAITING_FULFILLMENT})
const AWAITING_FULFILLMENT = 11;
async function advanceOrder(orderId) {
return bcPut(`/orders/${orderId}`, { status_id: AWAITING_FULFILLMENT });
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs the {order_id, order_total, transaction_id, gateway, current status_id, target status_id} tuple for each order it would advance, and each order it would flag. Read the output, agree with it, then switch it off. Run it on a schedule that matches how often your gateway confirmations lag, for example once an hour.
Always start with DRY_RUN=true, and never let the job advance an order whose transaction status is anything other than an unambiguous successful capture or sale. Forcing status_id 11 on an order that was not truly captured can trigger real, premature fulfillment.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only advances orders with an unambiguous successful capture or sale matching the order total, and only flags, never touches, anything ambiguous.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Advance BigCommerce orders that were captured but never left Awaiting Payment.
BigCommerce order status and payment status are decoupled from the real gateway
transaction. When a payment is authorize only, capturing it (by hand or with the
Capture Order Payment action) sets payment_status to Pending Capture while the
capture is processed out of band by the gateway. If the confirmation callback is
slow, silently fails, or the merchant captures directly in the gateway's own
dashboard, the order record never gets the follow up update and status_id stays
at 7 (Awaiting Payment) even though the transaction and the gateway both show the
money was captured. This job lists candidate orders at status_id 7, reads each
order's transactions, and advances only the ones with an unambiguous successful
capture or sale transaction whose amount matches the order total. Anything else
is flagged for manual review, never auto-advanced. Run on a schedule. Safe to run
again and again.
Guide: https://www.allanninal.dev/bigcommerce/orders-stuck-on-awaiting-payment-after-capture/
"""
import os
import logging
from typing import Literal
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("advance_captured_orders")
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"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "14"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AWAITING_PAYMENT = 7
AWAITING_FULFILLMENT = 11
CAPTURE_TYPES = {"capture", "sale"}
AMOUNT_EPSILON = 0.01
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
if not r.text:
return []
return r.json()
def bc_put(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def decide_order_repair(
status_id: int, transactions: list, order_total: str
) -> Literal["advance_to_awaiting_fulfillment", "flag_for_review", "no_action"]:
"""Pure decision. No network, no side effects.
if status_id != 7: no_action.
Find capture/sale transactions with status "success" and amount matching
order_total within a currency-aware epsilon. If at least one matches,
advance_to_awaiting_fulfillment. If a capture transaction exists but is
pending, declined, or amount mismatched, flag_for_review. If there is no
capture-type transaction at all, no_action (genuinely unpaid).
"""
if status_id != AWAITING_PAYMENT:
return "no_action"
try:
total = float(order_total)
except (TypeError, ValueError):
total = None
saw_capture_type = False
has_matching_success = False
has_problem_capture = False
for txn in transactions or []:
txn_kind = (txn.get("type") or txn.get("event") or "").lower()
if txn_kind not in CAPTURE_TYPES:
continue
saw_capture_type = True
txn_status = (txn.get("status") or "").lower()
try:
amount = float(txn.get("amount"))
except (TypeError, ValueError):
amount = None
amount_matches = (
total is not None and amount is not None and abs(amount - total) < AMOUNT_EPSILON
)
if txn_status == "success" and amount_matches:
has_matching_success = True
else:
has_problem_capture = True
if has_matching_success:
return "advance_to_awaiting_fulfillment"
if saw_capture_type and has_problem_capture:
return "flag_for_review"
return "no_action"
def candidate_orders():
"""Page through orders currently at Awaiting Payment (status_id 7)."""
page = 1
while True:
orders = bc_get(
"/orders",
{
"status_id": AWAITING_PAYMENT,
"min_date_created": f"-{LOOKBACK_DAYS} days",
"page": page,
"limit": 50,
},
)
if not orders:
return
for order in orders:
yield order
page += 1
def order_transactions(order_id):
return bc_get(f"/orders/{order_id}/transactions")
def advance_order(order_id):
return bc_put(f"/orders/{order_id}", {"status_id": AWAITING_FULFILLMENT})
def run():
advanced = 0
flagged = 0
for order in candidate_orders():
order_id = order["id"]
order_total = order.get("total_inc_tax") or order.get("total_ex_tax") or "0"
transactions = order_transactions(order_id)
decision = decide_order_repair(order.get("status_id"), transactions, order_total)
if decision == "no_action":
continue
if decision == "flag_for_review":
log.warning(
"Order %s flagged for review. total=%s status_id=%s",
order_id, order_total, order.get("status_id"),
)
flagged += 1
continue
gateway = None
gateway_transaction_id = None
transaction_id = None
for txn in transactions or []:
txn_kind = (txn.get("type") or txn.get("event") or "").lower()
if txn_kind in CAPTURE_TYPES and (txn.get("status") or "").lower() == "success":
gateway = txn.get("gateway")
gateway_transaction_id = txn.get("gateway_transaction_id")
transaction_id = txn.get("id")
break
log.info(
"order_id=%s order_total=%s transaction_id=%s gateway=%s "
"gateway_transaction_id=%s current_status_id=%s target_status_id=%s (%s)",
order_id, order_total, transaction_id, gateway, gateway_transaction_id,
order.get("status_id"), AWAITING_FULFILLMENT,
"dry run" if DRY_RUN else "advancing",
)
if not DRY_RUN:
advance_order(order_id)
advanced += 1
log.info(
"Done. %d order(s) %s, %d order(s) flagged for review.",
advanced, "to advance" if DRY_RUN else "advanced", flagged,
)
if __name__ == "__main__":
run()
/**
* Advance BigCommerce orders that were captured but never left Awaiting Payment.
*
* BigCommerce order status and payment status are decoupled from the real gateway
* transaction. When a payment is authorize only, capturing it (by hand or with the
* Capture Order Payment action) sets payment_status to Pending Capture while the
* capture is processed out of band by the gateway. If the confirmation callback is
* slow, silently fails, or the merchant captures directly in the gateway's own
* dashboard, the order record never gets the follow up update and status_id stays
* at 7 (Awaiting Payment) even though the transaction and the gateway both show the
* money was captured. This job lists candidate orders at status_id 7, reads each
* order's transactions, and advances only the ones with an unambiguous successful
* capture or sale transaction whose amount matches the order total. Anything else
* is flagged for manual review, never auto-advanced. Run on a schedule.
*
* Guide: https://www.allanninal.dev/bigcommerce/orders-stuck-on-awaiting-payment-after-capture/
*/
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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const AWAITING_PAYMENT = 7;
const AWAITING_FULFILLMENT = 11;
const CAPTURE_TYPES = new Set(["capture", "sale"]);
const AMOUNT_EPSILON = 0.01;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* if statusId !== 7: no_action.
* Find capture/sale transactions with status "success" and amount matching
* orderTotal within a currency-aware epsilon. If at least one matches,
* advance_to_awaiting_fulfillment. If a capture transaction exists but is
* pending, declined, or amount mismatched, flag_for_review. If there is no
* capture-type transaction at all, no_action (genuinely unpaid).
*/
export function decideOrderRepair(statusId, transactions, orderTotal) {
if (statusId !== AWAITING_PAYMENT) return "no_action";
const total = Number.parseFloat(orderTotal);
const hasTotal = Number.isFinite(total);
let sawCaptureType = false;
let hasMatchingSuccess = false;
let hasProblemCapture = false;
for (const txn of transactions || []) {
const txnKind = (txn.type || txn.event || "").toLowerCase();
if (!CAPTURE_TYPES.has(txnKind)) continue;
sawCaptureType = true;
const txnStatus = (txn.status || "").toLowerCase();
const amount = Number.parseFloat(txn.amount);
const amountMatches = hasTotal && Number.isFinite(amount) && Math.abs(amount - total) < AMOUNT_EPSILON;
if (txnStatus === "success" && amountMatches) {
hasMatchingSuccess = true;
} else {
hasProblemCapture = true;
}
}
if (hasMatchingSuccess) return "advance_to_awaiting_fulfillment";
if (sawCaptureType && hasProblemCapture) return "flag_for_review";
return "no_action";
}
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : [];
}
async function bcPut(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function* candidateOrders() {
let page = 1;
while (true) {
const orders = await bcGet("/orders", {
status_id: AWAITING_PAYMENT,
min_date_created: `-${LOOKBACK_DAYS} days`,
page,
limit: 50,
});
if (!orders.length) return;
for (const order of orders) yield order;
page += 1;
}
}
async function orderTransactions(orderId) {
return bcGet(`/orders/${orderId}/transactions`);
}
async function advanceOrder(orderId) {
return bcPut(`/orders/${orderId}`, { status_id: AWAITING_FULFILLMENT });
}
export async function run() {
let advanced = 0;
let flagged = 0;
for await (const order of candidateOrders()) {
const orderId = order.id;
const orderTotal = order.total_inc_tax || order.total_ex_tax || "0";
const transactions = await orderTransactions(orderId);
const decision = decideOrderRepair(order.status_id, transactions, orderTotal);
if (decision === "no_action") continue;
if (decision === "flag_for_review") {
console.warn(`Order ${orderId} flagged for review. total=${orderTotal} status_id=${order.status_id}`);
flagged += 1;
continue;
}
let gateway = null;
let gatewayTransactionId = null;
let transactionId = null;
for (const txn of transactions || []) {
const txnKind = (txn.type || txn.event || "").toLowerCase();
if (CAPTURE_TYPES.has(txnKind) && (txn.status || "").toLowerCase() === "success") {
gateway = txn.gateway;
gatewayTransactionId = txn.gateway_transaction_id;
transactionId = txn.id;
break;
}
}
console.log(
`order_id=${orderId} order_total=${orderTotal} transaction_id=${transactionId} gateway=${gateway} ` +
`gateway_transaction_id=${gatewayTransactionId} current_status_id=${order.status_id} ` +
`target_status_id=${AWAITING_FULFILLMENT} (${DRY_RUN ? "dry run" : "advancing"})`
);
if (!DRY_RUN) await advanceOrder(orderId);
advanced += 1;
}
console.log(
`Done. ${advanced} order(s) ${DRY_RUN ? "to advance" : "advanced"}, ${flagged} order(s) flagged for review.`
);
}
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 pushed toward fulfillment. Because decide_order_repair takes only plain values and returns a plain string, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.
from advance_captured_orders import decide_order_repair
def capture_txn(amount="50.00", type_="capture", status="success"):
return {"type": type_, "status": status, "amount": amount, "gateway": "test_gateway",
"gateway_transaction_id": "gw_123", "id": 1}
def test_no_action_when_status_is_not_awaiting_payment():
assert decide_order_repair(11, [capture_txn()], "50.00") == "no_action"
def test_advance_when_successful_capture_matches_total():
assert decide_order_repair(7, [capture_txn(amount="50.00")], "50.00") == "advance_to_awaiting_fulfillment"
def test_advance_when_successful_sale_matches_total():
assert decide_order_repair(7, [capture_txn(type_="sale", amount="50.00")], "50.00") == "advance_to_awaiting_fulfillment"
def test_no_action_when_no_capture_type_transaction_exists():
txns = [{"type": "authorization", "status": "success", "amount": "50.00"}]
assert decide_order_repair(7, txns, "50.00") == "no_action"
def test_flag_for_review_when_capture_is_pending():
assert decide_order_repair(7, [capture_txn(status="pending")], "50.00") == "flag_for_review"
def test_flag_for_review_when_amount_does_not_match():
assert decide_order_repair(7, [capture_txn(amount="40.00")], "50.00") == "flag_for_review"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideOrderRepair } from "./advance-captured-orders.js";
const captureTxn = ({ amount = "50.00", type = "capture", status = "success" } = {}) => ({
type, status, amount, gateway: "test_gateway", gateway_transaction_id: "gw_123", id: 1,
});
test("no_action when status is not awaiting payment", () => {
assert.equal(decideOrderRepair(11, [captureTxn()], "50.00"), "no_action");
});
test("advance when successful capture matches total", () => {
assert.equal(decideOrderRepair(7, [captureTxn({ amount: "50.00" })], "50.00"), "advance_to_awaiting_fulfillment");
});
test("advance when successful sale matches total", () => {
assert.equal(decideOrderRepair(7, [captureTxn({ type: "sale", amount: "50.00" })], "50.00"), "advance_to_awaiting_fulfillment");
});
test("no_action when no capture-type transaction exists", () => {
const txns = [{ type: "authorization", status: "success", amount: "50.00" }];
assert.equal(decideOrderRepair(7, txns, "50.00"), "no_action");
});
test("flag_for_review when capture is pending", () => {
assert.equal(decideOrderRepair(7, [captureTxn({ status: "pending" })], "50.00"), "flag_for_review");
});
test("flag_for_review when amount does not match", () => {
assert.equal(decideOrderRepair(7, [captureTxn({ amount: "40.00" })], "50.00"), "flag_for_review");
});
Case studies
The store where the confirmation always lagged an hour
A mid-size store used a gateway that batched its capture confirmations instead of sending them instantly. Every afternoon, staff would see a fresh pile of orders sitting on Awaiting Payment even though the morning's captures had all gone through. Someone was manually opening each order and clicking through the status change.
Now the reconciler runs once an hour. It reads the transactions directly instead of waiting on the gateway's own timeline, finds the captures that already succeeded, and advances those orders to Awaiting Fulfillment automatically. Staff only look at the handful the job flags for review.
The support team that captured funds outside BigCommerce
A support rep occasionally had to manually capture a held authorization directly in the payment processor's dashboard to resolve a customer issue quickly. BigCommerce never learned that capture happened, so those orders sat on Awaiting Payment indefinitely, invisible to the usual fulfillment queue.
The job caught these the same way it caught the slow-callback cases, because it reads the transaction record rather than trusting any particular event source. The order total matched, the capture was marked success, and it moved forward on the very next run.
After this runs on a schedule, a successful capture is never more than one run away from Awaiting Fulfillment, whether the confirmation came through cleanly, arrived late, or never arrived at all because someone captured outside BigCommerce. Orders with anything less than a clean, matching, successful capture stay exactly where they are, flagged for a human, so no one gets pushed toward fulfillment by mistake.
FAQ
Why does a BigCommerce order stay on Awaiting Payment after I captured it?
Capturing an authorize-only payment sets payment_status to Pending Capture while the gateway processes the capture out of band. If the gateway's confirmation callback is slow, fails silently, or you captured the funds directly in the gateway's own dashboard, the order record never gets the follow-up update, so status_id stays at 7 even though the money was actually captured.
Is it safe to auto-advance every order stuck on status_id 7?
No. Only advance an order when its transactions include an unambiguous successful capture or sale whose amount matches the order total. Orders with a pending, declined, or mismatched-amount transaction should be flagged for manual review instead, because forcing Awaiting Fulfillment on an order that was not truly captured can trigger premature fulfillment.
Why can I not just trust the store/order/statusUpdated webhook for this?
BigCommerce does not guarantee webhook ordering or dedupe delivery. A status-updated event tied to a capture can be dropped, delayed, or overtaken by an earlier event, which leaves a webhook-driven order status stale relative to the order's true payment and transaction state. Reading the transactions endpoint directly is the reliable source of truth.
Related field notes
Citations
On the problem:
- BigCommerce Support: why order statuses stay on Awaiting Payment. support.bigcommerce.com why are the statuses saying awaiting payment
- BigCommerce Support: manually capturing transactions for authorize-only payments. support.bigcommerce.com manually capturing transactions (authorize only)
- BigCommerce Developer Center: order status reference and status_id values. developer.bigcommerce.com order status
On the solution:
- BigCommerce API Reference: the Capture Order Payment action. docs.bigcommerce.com capture order payment
- BigCommerce Developer Center: the order transactions endpoint. developer.bigcommerce.com transactions
- BigCommerce Developer Center: the V2 Orders API, status_id, and PUT /v2/orders/{id}. developer.bigcommerce.com orders
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 clear your Awaiting Payment queue?
If this saved you a pile of manual clicks 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