Reconciler Orders and payments

Paid order stuck on Incomplete

The gateway shows a successful capture. The money is real. But the BigCommerce order still sits at status_id 0, Incomplete, invisible to your fulfillment queue and to most order exports and webhooks. Here is why that gap opens up and a small script that finds the confirmed paid orders and moves them forward safely.

Python and Node.js REST Management API v2 Safe by default (dry run)
The short answer

An order is written at status_id 0 (Incomplete) the instant a shopper reaches the payment page, before BigCommerce knows the gateway result. A second call is supposed to flip it to Awaiting Fulfillment (status_id 11). When that call is delayed, dropped, or the gateway never notifies BigCommerce, the order stays Incomplete forever even though a transaction and a capture exist on the gateway side. Run a small Python or Node.js script that lists Incomplete orders, checks each one's /v2/orders/{id}/transactions for a successful purchase or capture, and moves only the unambiguous matches to status_id 11 with PUT /v2/orders/{id}. Anything with conflicting transactions, like a capture followed by a void, is flagged for a human instead. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce does not wait for the payment result before it writes the order. The storefront checkout creates the order record first, at status_id 0, the moment the shopper lands on the payment step. Separately, the payment gateway is supposed to call back with the transaction result, and that second call is what moves the order to Awaiting Fulfillment or Awaiting Payment.

Those two things happen on different timelines. If the gateway callback is slow, times out, gets dropped by a network hiccup, or the gateway itself (an offsite redirect gateway, PayPal, or a webhook based integration) simply fails to tell BigCommerce the capture succeeded, the order record never gets its second write. It just sits at status_id 0. The shopper's card was charged. The gateway shows a settled transaction. But BigCommerce still thinks the order is Incomplete.

Shopper reaches the payment page Order written status_id 0 Gateway captures the payment callback dropped Stays Incomplete status_id 0 forever Hidden from fulfillment
The order record and the payment result are reconciled asynchronously. When the gateway's second call never lands, the order never leaves status_id 0.

Why it happens

The order and the payment result come from two different systems that only agree once the second write succeeds. A few common ways stores end up with a paid Incomplete order:

Because Incomplete orders are excluded from the normal fulfillment queue and from most order export and webhook flows, staff have no reason to look at them. The order is invisible until a customer emails asking where their package is. See the citations at the end for the exact support threads and the BigCommerce order status documentation.

The key insight

Incomplete does not mean unpaid. It means BigCommerce has not yet been told the result. The order resource and the transaction resource are two separate sources of truth, and only the transaction list on /v2/orders/{id}/transactions tells you what the gateway actually did. So the fix is never "move every Incomplete order forward." It is "check the transaction history first, and only advance the orders where that history is unambiguous." Anything with a conflict, like a capture followed by a void, goes to a human instead.

The fix, as a flow

We do not touch checkout or the gateway integration. We add a job that lists Incomplete orders in a lookback window, pulls each one's transactions, and runs a strict decision. If a successful purchase or capture exists with nothing that contradicts it, the order moves to Awaiting Fulfillment (status_id 11). If the transactions disagree with each other, the order is only flagged for manual review. Everything else is left exactly where it is.

Scheduled job runs on a timer List Incomplete orders status_id=0, lookback window Read transactions type, status, gateway id Success, no conflict? yes conflict, flag for review status_id 11 Awaiting Fulfillment
The script only advances the orders whose transaction history is unambiguous. A conflicting transaction, like a void after a capture, gets flagged for a human instead of being forced into fulfillment.

Build it step by step

1

Get a store hash and an access token

Create an API account in your BigCommerce control panel under Settings, API Accounts, and give it read and modify scopes on Orders. You will get a store hash and an X-Auth-Token access token. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="your_store_hash"
export BIGCOMMERCE_ACCESS_TOKEN="your_access_token"
export LOOKBACK_DAYS="7"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="your_store_hash"
export BIGCOMMERCE_ACCESS_TOKEN="your_access_token"
export LOOKBACK_DAYS="7"
export DRY_RUN="true"   // start safe, change to false to write
2

List the Incomplete orders in a lookback window

Call GET /v2/orders with status_id=0 and min_date_created set to the start of your lookback window, paging with limit and page. This is the candidate list. Nothing here has been confirmed paid yet, it is just every order still sitting at status_id 0.

step2.py
import os, requests
from datetime import datetime, timedelta, timezone

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE_URL = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "7"))

def headers():
    return {"X-Auth-Token": ACCESS_TOKEN, "Accept": "application/json"}

def list_incomplete_orders():
    since = (datetime.now(timezone.utc) - timedelta(days=LOOKBACK_DAYS)).strftime(
        "%a, %d %b %Y %H:%M:%S +0000"
    )
    page = 1
    while True:
        r = requests.get(
            BASE_URL + "v2/orders",
            headers=headers(),
            params={"status_id": 0, "min_date_created": since, "limit": 50, "page": page},
            timeout=30,
        )
        if r.status_code == 204:
            return
        r.raise_for_status()
        orders = r.json()
        if not orders:
            return
        for order in orders:
            yield order
        if len(orders) < 50:
            return
        page += 1
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE_URL = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);

function headers() {
  return { "X-Auth-Token": ACCESS_TOKEN, Accept: "application/json" };
}

async function* listIncompleteOrders() {
  const since = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000)
    .toUTCString()
    .replace("GMT", "+0000");
  let page = 1;
  while (true) {
    const url = new URL(BASE_URL + "v2/orders");
    url.searchParams.set("status_id", "0");
    url.searchParams.set("min_date_created", since);
    url.searchParams.set("limit", "50");
    url.searchParams.set("page", String(page));
    const res = await fetch(url, { headers: headers() });
    if (res.status === 204) return;
    if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
    const orders = await res.json();
    if (!orders || orders.length === 0) return;
    for (const order of orders) yield order;
    if (orders.length < 50) return;
    page++;
  }
}
3

Pull each order's transactions

For every candidate, call GET /v2/orders/{id}/transactions. Each entry has a type (purchase, capture, void, and so on), a status, and a gateway_transaction_id when the gateway actually processed it. This is the ground truth the order resource itself does not carry.

step3.py
def get_transactions(order_id):
    r = requests.get(
        BASE_URL + f"v2/orders/{order_id}/transactions",
        headers=headers(),
        timeout=30,
    )
    if r.status_code == 204:
        return []
    r.raise_for_status()
    return r.json()
step3.js
async function getTransactions(orderId) {
  const res = await fetch(BASE_URL + `v2/orders/${orderId}/transactions`, {
    headers: headers(),
  });
  if (res.status === 204) return [];
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order's status_id and its transaction list and returns one of three outcomes: no_action, advance_to_awaiting_fulfillment, or flag_for_review. Only status_id 0 is a candidate. Among the purchase or capture transactions, a successful one (status success or approved, with a gateway_transaction_id) that has no void or decline against it can advance. If a success and a conflict both exist on the same order, that is a signal for a human, not a script.

decide.py
CHARGE_TYPES = {"purchase", "capture"}
SUCCESS_STATUSES = {"success", "approved"}
CONFLICT_STATUSES = {"declined", "failed"}

def decide_order_repair(status_id, transactions):
    if status_id != 0:
        return "no_action"

    charges = [t for t in transactions if t.get("type") in CHARGE_TYPES]
    if not charges:
        return "no_action"

    def is_successful(t):
        return t.get("status") in SUCCESS_STATUSES and bool(t.get("gateway_transaction_id"))

    has_success = any(is_successful(t) for t in charges)
    has_conflict = any(
        t.get("type") == "void" or t.get("status") in CONFLICT_STATUSES
        for t in transactions
    )

    if has_success and has_conflict:
        return "flag_for_review"
    if has_success:
        return "advance_to_awaiting_fulfillment"
    return "no_action"
decide.js
const CHARGE_TYPES = new Set(["purchase", "capture"]);
const SUCCESS_STATUSES = new Set(["success", "approved"]);
const CONFLICT_STATUSES = new Set(["declined", "failed"]);

export function decideOrderRepair(statusId, transactions) {
  if (statusId !== 0) return "no_action";

  const charges = transactions.filter((t) => CHARGE_TYPES.has(t.type));
  if (charges.length === 0) return "no_action";

  const isSuccessful = (t) => SUCCESS_STATUSES.has(t.status) && Boolean(t.gateway_transaction_id);

  const hasSuccess = charges.some(isSuccessful);
  const hasConflict = transactions.some(
    (t) => t.type === "void" || CONFLICT_STATUSES.has(t.status)
  );

  if (hasSuccess && hasConflict) return "flag_for_review";
  if (hasSuccess) return "advance_to_awaiting_fulfillment";
  return "no_action";
}
5

Advance confirmed orders to Awaiting Fulfillment

When the decision is advance_to_awaiting_fulfillment, call PUT /v2/orders/{id} with {"status_id": 11}, BigCommerce's documented next state for a paid order. When the decision is flag_for_review, do not write anything. Just log it so a merchant can look at the gateway history and decide by hand.

apply.py
def advance_to_awaiting_fulfillment(order_id):
    r = requests.put(
        BASE_URL + f"v2/orders/{order_id}",
        headers={**headers(), "Content-Type": "application/json"},
        json={"status_id": 11},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
apply.js
async function advanceToAwaitingFulfillment(orderId) {
  const res = await fetch(BASE_URL + `v2/orders/${orderId}`, {
    method: "PUT",
    headers: { ...headers(), "Content-Type": "application/json" },
    body: JSON.stringify({ status_id: 11 }),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only reports which orders it would advance and which it would flag. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that fits how often your gateway loses callbacks, for example once an hour.

Run it safe

Always start with DRY_RUN=true. Never auto-advance an order where the transaction status is ambiguous, such as pending or partial capture, or where more than one transaction conflicts, such as a capture followed by a void. Those belong in front of a merchant, not in an automatic status change.

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 whose transaction history is unambiguous.

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

reconcile_incomplete_orders.py
"""Find BigCommerce orders stuck on Incomplete (status_id 0) that were actually paid.

BigCommerce writes an order the moment a shopper reaches the payment page, before
the gateway result is known. A second call from the gateway is supposed to flip the
order to Awaiting Fulfillment (status_id 11). When that callback is delayed, dropped,
or the gateway never notifies BigCommerce, the order is stuck on Incomplete even
though a real transaction and a capture exist on the gateway side. Incomplete orders
are excluded from the normal fulfillment queue, so these sit invisible until a
customer complains.

This job lists Incomplete orders in a lookback window, pulls each order's
transactions, and classifies it with a pure function. Confirmed paid-but-incomplete
orders are moved to Awaiting Fulfillment (status_id 11). Orders with conflicting
signals (a capture followed by a void, or a decline) are only logged for manual
review, never auto-repaired. Guarded by DRY_RUN. Safe to run again and again.
"""
import os
import logging
from datetime import datetime, timedelta, timezone

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE_URL = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "7"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

INCOMPLETE_STATUS_ID = 0
AWAITING_FULFILLMENT_STATUS_ID = 11

CHARGE_TYPES = {"purchase", "capture"}
SUCCESS_STATUSES = {"success", "approved"}
CONFLICT_STATUSES = {"declined", "failed"}


def _headers():
    return {
        "X-Auth-Token": ACCESS_TOKEN,
        "Content-Type": "application/json",
        "Accept": "application/json",
    }


def decide_order_repair(status_id, transactions):
    """Pure decision function. No network calls.

    Only Incomplete orders (status_id == 0) are candidates. Among their purchase or
    capture transactions: if a successful one coexists with a void or a declined or
    failed one, the signals conflict and the order needs a human. If at least one
    successful purchase or capture exists with no conflict, the order can advance to
    Awaiting Fulfillment. Otherwise (no charge transactions, or only pending or
    declined ones) there is nothing to do.
    """
    if status_id != INCOMPLETE_STATUS_ID:
        return "no_action"

    charges = [t for t in transactions if t.get("type") in CHARGE_TYPES]
    if not charges:
        return "no_action"

    def is_successful(t):
        return t.get("status") in SUCCESS_STATUSES and bool(t.get("gateway_transaction_id"))

    has_success = any(is_successful(t) for t in charges)
    has_conflict = any(
        t.get("type") == "void" or t.get("status") in CONFLICT_STATUSES
        for t in transactions
    )

    if has_success and has_conflict:
        return "flag_for_review"
    if has_success:
        return "advance_to_awaiting_fulfillment"
    return "no_action"


def list_incomplete_orders():
    """Yield candidate orders with status_id 0 created within the lookback window."""
    min_date_created = (datetime.now(timezone.utc) - timedelta(days=LOOKBACK_DAYS)).strftime(
        "%a, %d %b %Y %H:%M:%S +0000"
    )
    page = 1
    limit = 50
    while True:
        r = requests.get(
            BASE_URL + "v2/orders",
            headers=_headers(),
            params={
                "status_id": INCOMPLETE_STATUS_ID,
                "min_date_created": min_date_created,
                "limit": limit,
                "page": page,
            },
            timeout=30,
        )
        if r.status_code == 204:
            return
        r.raise_for_status()
        orders = r.json()
        if not orders:
            return
        for order in orders:
            yield order
        if len(orders) < limit:
            return
        page += 1


def get_transactions(order_id):
    r = requests.get(
        BASE_URL + f"v2/orders/{order_id}/transactions",
        headers=_headers(),
        timeout=30,
    )
    if r.status_code == 204:
        return []
    r.raise_for_status()
    return r.json()


def advance_to_awaiting_fulfillment(order_id):
    r = requests.put(
        BASE_URL + f"v2/orders/{order_id}",
        headers=_headers(),
        json={"status_id": AWAITING_FULFILLMENT_STATUS_ID},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    advanced = 0
    flagged = 0
    for order in list_incomplete_orders():
        order_id = order["id"]
        transactions = get_transactions(order_id)
        decision = decide_order_repair(order.get("status_id", INCOMPLETE_STATUS_ID), transactions)

        if decision == "no_action":
            continue

        if decision == "flag_for_review":
            log.warning("Order %s has conflicting transaction signals. Flagged for manual review.", order_id)
            flagged += 1
            continue

        log.info(
            "Order %s is paid but Incomplete. %s",
            order_id,
            "would advance to Awaiting Fulfillment" if DRY_RUN else "advancing to Awaiting Fulfillment",
        )
        if not DRY_RUN:
            advance_to_awaiting_fulfillment(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()
reconcile-incomplete-orders.js
/**
 * Find BigCommerce orders stuck on Incomplete (status_id 0) that were actually paid.
 *
 * BigCommerce writes an order the moment a shopper reaches the payment page, before
 * the gateway result is known. A second call from the gateway is supposed to flip the
 * order to Awaiting Fulfillment (status_id 11). When that callback is delayed, dropped,
 * or the gateway never notifies BigCommerce, the order is stuck on Incomplete even
 * though a real transaction and a capture exist on the gateway side. Incomplete orders
 * are excluded from the normal fulfillment queue, so these sit invisible until a
 * customer complains.
 *
 * This job lists Incomplete orders in a lookback window, pulls each order's
 * transactions, and classifies it with a pure function. Confirmed paid-but-incomplete
 * orders are moved to Awaiting Fulfillment (status_id 11). Orders with conflicting
 * signals (a capture followed by a void, or a decline) are only logged for manual
 * review, never auto-repaired. Guarded by DRY_RUN. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/paid-order-stuck-on-incomplete/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "store_dummy";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "token_dummy";
const BASE_URL = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const INCOMPLETE_STATUS_ID = 0;
const AWAITING_FULFILLMENT_STATUS_ID = 11;

const CHARGE_TYPES = new Set(["purchase", "capture"]);
const SUCCESS_STATUSES = new Set(["success", "approved"]);
const CONFLICT_STATUSES = new Set(["declined", "failed"]);

/**
 * Pure decision function. No network calls.
 *
 * Only Incomplete orders (status_id === 0) are candidates. Among their purchase or
 * capture transactions: if a successful one coexists with a void or a declined or
 * failed one, the signals conflict and the order needs a human. If at least one
 * successful purchase or capture exists with no conflict, the order can advance to
 * Awaiting Fulfillment. Otherwise (no charge transactions, or only pending or
 * declined ones) there is nothing to do.
 */
export function decideOrderRepair(statusId, transactions) {
  if (statusId !== INCOMPLETE_STATUS_ID) return "no_action";

  const charges = transactions.filter((t) => CHARGE_TYPES.has(t.type));
  if (charges.length === 0) return "no_action";

  const isSuccessful = (t) => SUCCESS_STATUSES.has(t.status) && Boolean(t.gateway_transaction_id);

  const hasSuccess = charges.some(isSuccessful);
  const hasConflict = transactions.some(
    (t) => t.type === "void" || CONFLICT_STATUSES.has(t.status)
  );

  if (hasSuccess && hasConflict) return "flag_for_review";
  if (hasSuccess) return "advance_to_awaiting_fulfillment";
  return "no_action";
}

function headers() {
  return {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    Accept: "application/json",
  };
}

function minDateCreated() {
  const d = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000);
  return d.toUTCString().replace("GMT", "+0000");
}

async function* listIncompleteOrders() {
  const limit = 50;
  let page = 1;
  const since = minDateCreated();
  while (true) {
    const url = new URL(BASE_URL + "v2/orders");
    url.searchParams.set("status_id", String(INCOMPLETE_STATUS_ID));
    url.searchParams.set("min_date_created", since);
    url.searchParams.set("limit", String(limit));
    url.searchParams.set("page", String(page));

    const res = await fetch(url, { headers: headers() });
    if (res.status === 204) return;
    if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
    const orders = await res.json();
    if (!orders || orders.length === 0) return;
    for (const order of orders) yield order;
    if (orders.length < limit) return;
    page++;
  }
}

async function getTransactions(orderId) {
  const res = await fetch(BASE_URL + `v2/orders/${orderId}/transactions`, { headers: headers() });
  if (res.status === 204) return [];
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function advanceToAwaitingFulfillment(orderId) {
  const res = await fetch(BASE_URL + `v2/orders/${orderId}`, {
    method: "PUT",
    headers: headers(),
    body: JSON.stringify({ status_id: AWAITING_FULFILLMENT_STATUS_ID }),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

export async function run() {
  let advanced = 0;
  let flagged = 0;

  for await (const order of listIncompleteOrders()) {
    const orderId = order.id;
    const transactions = await getTransactions(orderId);
    const decision = decideOrderRepair(order.status_id ?? INCOMPLETE_STATUS_ID, transactions);

    if (decision === "no_action") continue;

    if (decision === "flag_for_review") {
      console.warn(`Order ${orderId} has conflicting transaction signals. Flagged for manual review.`);
      flagged++;
      continue;
    }

    console.log(
      `Order ${orderId} is paid but Incomplete. ${DRY_RUN ? "would advance to Awaiting Fulfillment" : "advancing to Awaiting Fulfillment"}`
    );
    if (!DRY_RUN) await advanceToAwaitingFulfillment(orderId);
    advanced++;
  }

  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 an order gets pushed into fulfillment. Because we kept decide_order_repair pure, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_paid_orders_stuck.py
from reconcile_incomplete_orders import decide_order_repair


def txn(type="purchase", status="success", gateway_transaction_id="gw_123"):
    return {"type": type, "status": status, "gateway_transaction_id": gateway_transaction_id}


def test_no_action_when_status_is_not_incomplete():
    assert decide_order_repair(11, [txn()]) == "no_action"


def test_no_action_when_no_charge_transactions():
    assert decide_order_repair(0, []) == "no_action"
    assert decide_order_repair(0, [txn(type="void")]) == "no_action"


def test_no_action_when_only_pending_or_declined():
    pending = txn(status="pending", gateway_transaction_id=None)
    declined = txn(status="declined")
    assert decide_order_repair(0, [pending]) == "no_action"
    assert decide_order_repair(0, [declined]) == "no_action"


def test_advance_when_successful_capture_with_no_conflict():
    assert decide_order_repair(0, [txn()]) == "advance_to_awaiting_fulfillment"
    assert decide_order_repair(0, [txn(type="capture")]) == "advance_to_awaiting_fulfillment"


def test_flag_for_review_when_success_conflicts_with_void():
    success = txn()
    void = txn(type="void", status="success", gateway_transaction_id="gw_456")
    assert decide_order_repair(0, [success, void]) == "flag_for_review"


def test_flag_for_review_when_success_conflicts_with_declined():
    success = txn()
    declined = txn(status="declined")
    assert decide_order_repair(0, [success, declined]) == "flag_for_review"


def test_no_action_when_success_missing_gateway_transaction_id():
    incomplete_success = txn(gateway_transaction_id=None)
    assert decide_order_repair(0, [incomplete_success]) == "no_action"
reconcile-incomplete-orders.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideOrderRepair } from "./reconcile-incomplete-orders.js";

const txn = ({ type = "purchase", status = "success", gateway_transaction_id = "gw_123" } = {}) => ({
  type,
  status,
  gateway_transaction_id,
});

test("no action when status is not incomplete", () => {
  assert.equal(decideOrderRepair(11, [txn()]), "no_action");
});

test("no action when no charge transactions", () => {
  assert.equal(decideOrderRepair(0, []), "no_action");
  assert.equal(decideOrderRepair(0, [txn({ type: "void" })]), "no_action");
});

test("no action when only pending or declined", () => {
  const pending = txn({ status: "pending", gateway_transaction_id: null });
  const declined = txn({ status: "declined" });
  assert.equal(decideOrderRepair(0, [pending]), "no_action");
  assert.equal(decideOrderRepair(0, [declined]), "no_action");
});

test("advance when successful capture with no conflict", () => {
  assert.equal(decideOrderRepair(0, [txn()]), "advance_to_awaiting_fulfillment");
  assert.equal(decideOrderRepair(0, [txn({ type: "capture" })]), "advance_to_awaiting_fulfillment");
});

test("flag for review when success conflicts with void", () => {
  const success = txn();
  const voidTxn = txn({ type: "void", status: "success", gateway_transaction_id: "gw_456" });
  assert.equal(decideOrderRepair(0, [success, voidTxn]), "flag_for_review");
});

test("flag for review when success conflicts with declined", () => {
  const success = txn();
  const declined = txn({ status: "declined" });
  assert.equal(decideOrderRepair(0, [success, declined]), "flag_for_review");
});

test("no action when success missing gateway transaction id", () => {
  const incompleteSuccess = txn({ gateway_transaction_id: null });
  assert.equal(decideOrderRepair(0, [incompleteSuccess]), "no_action");
});

Case studies

Offsite gateway

PayPal captured the money, BigCommerce never heard back

A homeware store using an offsite PayPal flow saw a slow trickle of orders stuck on Incomplete every week. PayPal's own dashboard showed the charge as completed every time, but the return trip back to BigCommerce sometimes never landed, so the order sat invisible with no fulfillment task ever created.

Running the reconciler hourly with DRY_RUN=true first showed the exact list, all confirmed by a successful PayPal capture with a gateway_transaction_id. Once confirmed, the team let it write for real, and those orders started shipping the same day instead of surfacing only when a customer asked where their order was.

Webhook failure

A dropped webhook left a batch of Black Friday orders behind

During a traffic spike, a merchant's payment webhook integration missed several callbacks in a row. Dozens of paid orders piled up on status_id 0 overnight, and none of them showed up in the normal Awaiting Fulfillment queue or the order export the warehouse used each morning.

The script's transaction check caught every one of them, since each had a clean successful capture with nothing conflicting against it. None were flagged for review, because none had a void or a decline in the way, so the whole batch advanced to Awaiting Fulfillment in one run.

What good looks like

After this runs on a schedule, a captured payment does not sit invisible waiting for a customer complaint. Orders with a clean, unambiguous transaction history move to Awaiting Fulfillment automatically, and anything with a real conflict, a void or a decline sitting next to a capture, lands in front of a person instead of getting forced into a status that does not match what actually happened.

FAQ

Why does a BigCommerce order stay on Incomplete after the customer paid?

BigCommerce creates the order at status_id 0 Incomplete the moment a shopper reaches the payment page, before the gateway result is known. A second call from the gateway is supposed to flip the order to Awaiting Fulfillment. If that callback is delayed, dropped, or the gateway never notifies BigCommerce of a successful capture, the order stays on Incomplete even though the money was collected.

How do I know if an Incomplete order was actually paid?

Call GET /v2/orders/{id}/transactions for the order and look at each transaction's type and status. If the order is status_id 0 and at least one transaction has type purchase or capture with a success or approved status and a gateway_transaction_id, the gateway did collect the money even though BigCommerce still shows the order as Incomplete.

Is it safe to automatically move Incomplete orders to Awaiting Fulfillment?

Only when the transaction history is unambiguous. If a successful purchase or capture exists with no void or decline against it, moving the order to status_id 11 Awaiting Fulfillment matches what the gateway actually did. If a capture is followed by a void, or the transactions conflict, the script should flag the order for a human instead of forcing a status, since a genuinely reversed or fraudulent payment should never be pushed into fulfillment.

Related field notes

Citations

On the problem:

  1. BigCommerce Support Community: I've received a payment from an incomplete order. support.bigcommerce.com incomplete order payment
  2. BigCommerce Support Community: order created webhook callback and GET order API returning status_id 0 Incomplete for successful orders. support.bigcommerce.com status_id 0 for successful orders
  3. BigCommerce Developer Center: Order Status reference. developer.bigcommerce.com/docs/rest-management/orders/order-status

On the solution:

  1. BigCommerce Developer Center: Orders REST Management API reference. developer.bigcommerce.com/docs/rest-management/orders
  2. BigCommerce Developer Center: Order Transactions API reference. developer.bigcommerce.com/docs/rest-management/transactions
  3. BigCommerce Developer Center: Order Status reference. developer.bigcommerce.com/docs/rest-management/orders/order-status

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this clear your Incomplete orders?

If this saved you a pile of manual clicks or a customer complaint you never want to see again, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all BigCommerce field notes