Reconciler Orders

Manual status change to Refunded does not move any money

An order sits at status_id 4, Refunded, or 14, Partially Refunded. The customer is asking where their money is. You check the transactions endpoint and there is nothing there, no refund entry, no amount, no gateway call. BigCommerce let someone set that status directly, with zero side effects, because order status and money movement are two completely separate systems. Here is why that gap opens up and a reconciler that flags every orphaned Refunded status instead of quietly trusting the label.

Python and Node.js BigCommerce V2 Orders and Payment Actions Report only, never auto-refunds
White and blue magnetic card
Photo by Avery Evans on Unsplash
The short answer

BigCommerce's status_id field is just a label stored on the order record. A plain PUT /v2/orders/{id} with {"status_id": 4} or 14 is accepted with no side effect, no gateway call, and no transaction written. Real refunds only happen through the Payment Actions workflow, POST .../payment_actions/refund_quotes then POST .../payment_actions/refunds, which calls the original gateway and, only on success, writes a transaction and updates status_id as a result. When staff use the admin's Edit status dropdown instead of the Refund action, or an integration PUTs status_id 4 directly to mirror an external refund, the order shows Refunded while GET /v2/orders/{id}/transactions stays empty. Run a small Python or Node.js script that lists orders at status_id 4 and 14, checks each one's transactions for an actual refund entry, and reports every mismatch, optionally fetching a refund quote to show an operator exactly what to submit, but never auto-submitting a refund itself. Full code, tests, and a dry run guard are below.

The problem in plain words

In BigCommerce, an order's status_id and the money that has actually moved through the payment gateway are not the same fact, and nothing forces them to agree. The status field is metadata. It is written the same way whether you type it into the Edit status dropdown in the admin, or send a script that calls PUT /v2/orders/{id} with a raw status_id. BigCommerce accepts either one without checking whether a refund actually happened.

Actually moving money back to a customer is a different, dedicated workflow: the Payment Actions API. It calls the original payment gateway, waits for a real result, and only on success writes a transaction record and updates the order's status as a side effect of that success. If a staff member instead reaches for the general status editor, or an external system mirrors a refund it processed somewhere else by writing status_id 4 straight onto the order, BigCommerce happily shows Refunded. The transactions endpoint for that order stays empty. The customer, and the next person looking at the order, both believe the money went back. It did not.

Edit status dropdown or PUT status_id=4 BigCommerce accepts no side effect fired No gateway call status_id 4 shows Refunded Transactions empty
The status label changes instantly. No gateway call happens, and the transactions endpoint for that order never shows a refund.

Why it happens

BigCommerce splits "what the order record says" from "what actually happened to the money" into two systems that only agree when the correct workflow is used. A few common ways stores end up with orphaned Refunded statuses:

This is a recurring point of confusion in BigCommerce's own support channels: merchants report refunds that "are not going through" or fail with an error, while the order itself may still end up marked Refunded through some other path, leaving staff with no reliable way to tell, just by looking at the order status, whether the customer actually got their money. See the citations at the end for the exact support threads and docs.

The key insight

An order's status_id is not proof that money moved. The transactions record is. So the safe pattern is not "trust any order marked Refunded." It is "treat status_id 4 or 14 as a claim, and verify that claim against GET /v2/orders/{id}/transactions for a refund-type entry with a non-zero amount, cross-checked against GET /v3/orders/{order_id}/payment_actions/refunds." Anything marked Refunded or Partially Refunded with no matching refund transaction is flagged, never silently repaired, because only a human can know whether the customer was actually paid back through some other channel.

The fix, as a flow

We do not touch the live Edit status flow or the Payment Actions endpoints in normal operation. We add a reconciler job that lists orders recently marked Refunded or Partially Refunded, checks each one's transaction history for real refund evidence, and reports every mismatch for a human to resolve, optionally preparing (but never submitting) the exact refund request an operator would need to confirm.

Scheduled job runs on a timer List status_id 4/14 Refunded orders Read transactions + payment_actions/refunds Refund txn found? yes, skip no, orphaned Report + optional refund_quotes preview no action taken
The reconciler only ever reports and, at most, prepares a refund quote for a human. It never calls the real refunds endpoint itself.

Build it step by step

1

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) and Payments scope so it can read transactions, read payment_actions, and, later, fetch refund quotes. 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.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export LOOKBACK_DAYS="30"
export DRY_RUN="true"   # start safe, change to false only to preview a refund quote
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export LOOKBACK_DAYS="30"
export DRY_RUN="true"   // start safe, change to false only to preview a refund quote
2

Talk to the V2 Orders and V3 Payment Actions APIs

Order status and transactions live under https://api.bigcommerce.com/stores/{store_hash}/v2/. Payment Actions, refund_quotes and refunds, live under the V3 base, https://api.bigcommerce.com/stores/{store_hash}/v3/, and wrap responses in {data, meta}. Both use the same X-Auth-Token header. A small helper handles GET and POST and raises on a non-2xx response.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
V2_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
V3_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}

def bc_get(base, path, params=None):
    r = requests.get(f"{base}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else []

def bc_post(base, path, body):
    r = requests.post(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const V2_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const V3_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

async function bcGet(base, path, params = {}) {
  const url = new URL(`${base}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : [];
}

async function bcPost(base, path, body) {
  const res = await fetch(`${base}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

List the candidate orders and read their transactions

Call GET /v2/orders?status_id=4&min_date_modified=..., paginated, and repeat with status_id=14, to get every order recently marked Refunded or Partially Refunded within your lookback window. For each one, call GET /v2/orders/{id}/transactions for the raw transaction list, and GET /v3/orders/{order_id}/payment_actions/refunds to cross-check BigCommerce's own recorded refund history for that order.

step3.py
REFUNDED = 4
PARTIALLY_REFUNDED = 14

def candidate_orders(status_id, lookback_days):
    page = 1
    while True:
        orders = bc_get(V2_BASE, "/orders", {
            "status_id": status_id,
            "min_date_modified": 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(V2_BASE, f"/orders/{order_id}/transactions")

def order_payment_action_refunds(order_id):
    result = bc_get(V3_BASE, f"/orders/{order_id}/payment_actions/refunds")
    return result.get("data", []) if isinstance(result, dict) else []
step3.js
const REFUNDED = 4;
const PARTIALLY_REFUNDED = 14;

async function* candidateOrders(statusId, lookbackDays) {
  let page = 1;
  while (true) {
    const orders = await bcGet(V2_BASE, "/orders", {
      status_id: statusId,
      min_date_modified: `-${lookbackDays} days`,
      page,
      limit: 50,
    });
    if (!orders.length) return;
    for (const order of orders) yield order;
    page += 1;
  }
}

async function orderTransactions(orderId) {
  return bcGet(V2_BASE, `/orders/${orderId}/transactions`);
}

async function orderPaymentActionRefunds(orderId) {
  const result = await bcGet(V3_BASE, `/orders/${orderId}/payment_actions/refunds`);
  return (result && result.data) || [];
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order's status_id, its transaction list, its refunded_amount, and its total_inc_tax, and returns a plain true or false. The rule is deliberately narrow: only status_id 4 or 14 count, and the order is only orphaned when there is no refund-type transaction and the refunded_amount does not reflect any actual refund. Anything else is left alone.

decide.py
REFUND_STATUS_IDS = {4, 14}
AMOUNT_EPSILON = 0.01

def is_orphaned_refund_status(
    status_id: int, transactions: list, refunded_amount: str, total_inc_tax: str
) -> bool:
    if status_id not in REFUND_STATUS_IDS:
        return False

    has_refund_txn = False
    refund_txn_total = 0.0
    for txn in transactions or []:
        kind = (txn.get("type") or txn.get("event") or "").lower()
        if kind != "refund":
            continue
        try:
            amount = float(txn.get("amount"))
        except (TypeError, ValueError):
            amount = 0.0
        if amount > 0:
            has_refund_txn = True
            refund_txn_total += amount

    try:
        recorded_refund = float(refunded_amount)
    except (TypeError, ValueError):
        recorded_refund = 0.0

    no_recorded_refund = recorded_refund < AMOUNT_EPSILON

    return (not has_refund_txn) and no_recorded_refund and refund_txn_total < AMOUNT_EPSILON
decide.js
const REFUND_STATUS_IDS = new Set([4, 14]);
const AMOUNT_EPSILON = 0.01;

export function isOrphanedRefundStatus(statusId, transactions, refundedAmount, totalIncTax) {
  if (!REFUND_STATUS_IDS.has(statusId)) return false;

  let hasRefundTxn = false;
  let refundTxnTotal = 0;
  for (const txn of transactions || []) {
    const kind = (txn.type || txn.event || "").toLowerCase();
    if (kind !== "refund") continue;
    const amount = Number.parseFloat(txn.amount);
    if (Number.isFinite(amount) && amount > 0) {
      hasRefundTxn = true;
      refundTxnTotal += amount;
    }
  }

  const recordedRefund = Number.parseFloat(refundedAmount);
  const noRecordedRefund = !Number.isFinite(recordedRefund) || recordedRefund < AMOUNT_EPSILON;

  return !hasRefundTxn && noRecordedRefund && refundTxnTotal < AMOUNT_EPSILON;
}
5

Report the mismatch, never auto-refund

When an order is orphaned, write its order_id, current status_id, total_inc_tax, and a summary of its transactions to a report. There is no BigCommerce endpoint to retroactively attach a real refund to an order after the fact, and only a human can know whether the customer was already refunded through some other channel, like the processor's own dashboard. So the job stops there by default.

report.py
def build_report_row(order, transactions):
    return {
        "order_id": order["id"],
        "status_id": order.get("status_id"),
        "total_inc_tax": order.get("total_inc_tax"),
        "refunded_amount": order.get("refunded_amount"),
        "transaction_count": len(transactions or []),
    }
report.js
function buildReportRow(order, transactions) {
  return {
    order_id: order.id,
    status_id: order.status_id,
    total_inc_tax: order.total_inc_tax,
    refunded_amount: order.refunded_amount,
    transaction_count: (transactions || []).length,
  };
}
6

Wire it together with a dry run guard, optional refund quote preview

The loop ties every piece together. With DRY_RUN=true (the default), the job only writes the report. If an operator sets DRY_RUN=false, the job additionally calls POST /v3/orders/{order_id}/payment_actions/refund_quotes for each orphaned order to fetch a valid quote, then prints the exact POST /v3/orders/{order_id}/payment_actions/refunds body an operator would need to review and submit by hand. The job itself never calls the real refunds endpoint. That call always requires a human to run it.

Run it safe

This job never moves money. Even with DRY_RUN=false, it only fetches a refund quote and prints the exact request an operator would need to confirm. Submitting the real payment_actions/refunds call is a decision only a human should make, after checking whether the customer was already paid back some other way.

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 only ever reports or previews, because there is no safe way to auto-repair an order that claims to be refunded with no money behind it.

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

find_orphaned_refund_statuses.py
"""Find BigCommerce orders marked Refunded or Partially Refunded with no real refund behind them.

BigCommerce treats order status and money movement as two decoupled systems. The
status_id field is just a label on the order record, and PUT /v2/orders/{id} will
accept status_id 4 (Refunded) or 14 (Partially Refunded) with no side effect at all.
Real refunds only happen through the Payment Actions workflow, refund_quotes then
refunds, which calls the gateway and, only on success, writes a transaction and
updates status as a result. Staff using the Edit status dropdown instead of the
Refund action, or an integration that PUTs status_id 4 directly to mirror an
external refund, both leave the order showing Refunded with zero refund
transactions behind it. This job lists candidate orders at status_id 4 and 14,
reads each order's transactions, and reports every order where no refund-type
transaction exists and refunded_amount is still 0.00. There is no API to
retroactively attach a real refund to an order, so this never auto-repairs.
With DRY_RUN=false it additionally fetches a refund quote and prints the exact
refund request an operator would need to review and submit by hand. It never
calls the real refunds endpoint itself. Run on a schedule. Safe to run again
and again.

Guide: https://www.allanninal.dev/bigcommerce/manual-refunded-status-without-transaction/
"""
import os
import logging

import requests

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

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

REFUNDED = 4
PARTIALLY_REFUNDED = 14
REFUND_STATUS_IDS = {REFUNDED, PARTIALLY_REFUNDED}
AMOUNT_EPSILON = 0.01

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}


def bc_get(base, path, params=None):
    r = requests.get(f"{base}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    if not r.text:
        return []
    return r.json()


def bc_post(base, path, body):
    r = requests.post(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    if not r.text:
        return {}
    return r.json()


def is_orphaned_refund_status(
    status_id: int, transactions: list, refunded_amount: str, total_inc_tax: str
) -> bool:
    """Pure decision. No network, no side effects.

    status_id from the BigCommerce order (4=Refunded, 14=Partially Refunded).
    transactions is the list from GET /v2/orders/{id}/transactions, each a dict
    with a 'type' (or 'event') key. refunded_amount and total_inc_tax are decimal
    strings from the order resource. Returns True only when status_id is 4 or 14,
    there is no refund-type transaction with a positive amount, and refunded_amount
    is still effectively 0.00, meaning the status was changed with no money moved.
    """
    if status_id not in REFUND_STATUS_IDS:
        return False

    has_refund_txn = False
    refund_txn_total = 0.0
    for txn in transactions or []:
        kind = (txn.get("type") or txn.get("event") or "").lower()
        if kind != "refund":
            continue
        try:
            amount = float(txn.get("amount"))
        except (TypeError, ValueError):
            amount = 0.0
        if amount > 0:
            has_refund_txn = True
            refund_txn_total += amount

    try:
        recorded_refund = float(refunded_amount)
    except (TypeError, ValueError):
        recorded_refund = 0.0

    no_recorded_refund = recorded_refund < AMOUNT_EPSILON

    return (not has_refund_txn) and no_recorded_refund and refund_txn_total < AMOUNT_EPSILON


def candidate_orders(status_id):
    """Page through orders currently at the given refund-ish status_id."""
    page = 1
    while True:
        orders = bc_get(
            V2_BASE,
            "/orders",
            {
                "status_id": status_id,
                "min_date_modified": 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(V2_BASE, f"/orders/{order_id}/transactions")


def order_payment_action_refunds(order_id):
    result = bc_get(V3_BASE, f"/orders/{order_id}/payment_actions/refunds")
    return result.get("data", []) if isinstance(result, dict) else []


def fetch_refund_quote(order_id):
    """Fetch a refund quote from BigCommerce. Never submits the refund itself."""
    return bc_post(V3_BASE, f"/orders/{order_id}/payment_actions/refund_quotes", {})


def build_report_row(order, transactions):
    return {
        "order_id": order["id"],
        "status_id": order.get("status_id"),
        "total_inc_tax": order.get("total_inc_tax"),
        "refunded_amount": order.get("refunded_amount"),
        "transaction_count": len(transactions or []),
    }


def run():
    orphaned = 0
    for status_id in (REFUNDED, PARTIALLY_REFUNDED):
        for order in candidate_orders(status_id):
            order_id = order["id"]
            transactions = order_transactions(order_id)
            payment_action_refunds = order_payment_action_refunds(order_id)

            orphaned_flag = is_orphaned_refund_status(
                order.get("status_id"),
                transactions,
                order.get("refunded_amount"),
                order.get("total_inc_tax"),
            )
            if not orphaned_flag:
                continue
            if payment_action_refunds:
                # BigCommerce's own Payment Actions history disagrees with the
                # transactions read; still worth a human look, but log distinctly.
                log.warning(
                    "Order %s has payment_actions/refunds history but no matching "
                    "transaction entry, needs manual review.", order_id,
                )

            row = build_report_row(order, transactions)
            log.warning(
                "ORPHANED REFUND STATUS order_id=%s status_id=%s total_inc_tax=%s "
                "refunded_amount=%s transaction_count=%s",
                row["order_id"], row["status_id"], row["total_inc_tax"],
                row["refunded_amount"], row["transaction_count"],
            )
            orphaned += 1

            if not DRY_RUN:
                quote = fetch_refund_quote(order_id)
                log.info(
                    "Refund quote fetched for order_id=%s. To submit, an operator "
                    "must POST %s/orders/%s/payment_actions/refunds with body: %s",
                    order_id, V3_BASE, order_id, quote,
                )

    log.info("Done. %d order(s) flagged as orphaned Refunded status.", orphaned)


if __name__ == "__main__":
    run()
find-orphaned-refund-statuses.js
/**
 * Find BigCommerce orders marked Refunded or Partially Refunded with no real refund behind them.
 *
 * BigCommerce treats order status and money movement as two decoupled systems. The
 * status_id field is just a label on the order record, and PUT /v2/orders/{id} will
 * accept status_id 4 (Refunded) or 14 (Partially Refunded) with no side effect at all.
 * Real refunds only happen through the Payment Actions workflow, refund_quotes then
 * refunds, which calls the gateway and, only on success, writes a transaction and
 * updates status as a result. Staff using the Edit status dropdown instead of the
 * Refund action, or an integration that PUTs status_id 4 directly to mirror an
 * external refund, both leave the order showing Refunded with zero refund
 * transactions behind it. This job lists candidate orders at status_id 4 and 14,
 * reads each order's transactions, and reports every order where no refund-type
 * transaction exists and refunded_amount is still 0.00. There is no API to
 * retroactively attach a real refund to an order, so this never auto-repairs.
 * With DRY_RUN=false it additionally fetches a refund quote and prints the exact
 * refund request an operator would need to review and submit by hand. It never
 * calls the real refunds endpoint itself. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/manual-refunded-status-without-transaction/
 */
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 V2_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const V3_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const REFUNDED = 4;
const PARTIALLY_REFUNDED = 14;
const REFUND_STATUS_IDS = new Set([REFUNDED, PARTIALLY_REFUNDED]);
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.
 *
 * statusId from the BigCommerce order (4=Refunded, 14=Partially Refunded).
 * transactions is the list from GET /v2/orders/{id}/transactions, each an object
 * with a 'type' (or 'event') key. refundedAmount and totalIncTax are decimal
 * strings from the order resource. Returns true only when statusId is 4 or 14,
 * there is no refund-type transaction with a positive amount, and refundedAmount
 * is still effectively 0.00, meaning the status was changed with no money moved.
 */
export function isOrphanedRefundStatus(statusId, transactions, refundedAmount, totalIncTax) {
  if (!REFUND_STATUS_IDS.has(statusId)) return false;

  let hasRefundTxn = false;
  let refundTxnTotal = 0;
  for (const txn of transactions || []) {
    const kind = (txn.type || txn.event || "").toLowerCase();
    if (kind !== "refund") continue;
    const amount = Number.parseFloat(txn.amount);
    if (Number.isFinite(amount) && amount > 0) {
      hasRefundTxn = true;
      refundTxnTotal += amount;
    }
  }

  const recordedRefund = Number.parseFloat(refundedAmount);
  const noRecordedRefund = !Number.isFinite(recordedRefund) || recordedRefund < AMOUNT_EPSILON;

  return !hasRefundTxn && noRecordedRefund && refundTxnTotal < AMOUNT_EPSILON;
}

async function bcGet(base, path, params = {}) {
  const url = new URL(`${base}${path}`);
  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined && value !== null) url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : [];
}

async function bcPost(base, path, body) {
  const res = await fetch(`${base}${path}`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function* candidateOrders(statusId) {
  let page = 1;
  while (true) {
    const orders = await bcGet(V2_BASE, "/orders", {
      status_id: statusId,
      min_date_modified: `-${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(V2_BASE, `/orders/${orderId}/transactions`);
}

async function orderPaymentActionRefunds(orderId) {
  const result = await bcGet(V3_BASE, `/orders/${orderId}/payment_actions/refunds`);
  return (result && result.data) || [];
}

async function fetchRefundQuote(orderId) {
  return bcPost(V3_BASE, `/orders/${orderId}/payment_actions/refund_quotes`, {});
}

function buildReportRow(order, transactions) {
  return {
    order_id: order.id,
    status_id: order.status_id,
    total_inc_tax: order.total_inc_tax,
    refunded_amount: order.refunded_amount,
    transaction_count: (transactions || []).length,
  };
}

export async function run() {
  let orphaned = 0;

  for (const statusId of [REFUNDED, PARTIALLY_REFUNDED]) {
    for await (const order of candidateOrders(statusId)) {
      const orderId = order.id;
      const transactions = await orderTransactions(orderId);
      const paymentActionRefunds = await orderPaymentActionRefunds(orderId);

      const orphanedFlag = isOrphanedRefundStatus(
        order.status_id,
        transactions,
        order.refunded_amount,
        order.total_inc_tax,
      );
      if (!orphanedFlag) continue;

      if (paymentActionRefunds.length) {
        console.warn(
          `Order ${orderId} has payment_actions/refunds history but no matching transaction entry, needs manual review.`
        );
      }

      const row = buildReportRow(order, transactions);
      console.warn(
        `ORPHANED REFUND STATUS order_id=${row.order_id} status_id=${row.status_id} ` +
        `total_inc_tax=${row.total_inc_tax} refunded_amount=${row.refunded_amount} ` +
        `transaction_count=${row.transaction_count}`
      );
      orphaned += 1;

      if (!DRY_RUN) {
        const quote = await fetchRefundQuote(orderId);
        console.log(
          `Refund quote fetched for order_id=${orderId}. To submit, an operator must ` +
          `POST ${V3_BASE}/orders/${orderId}/payment_actions/refunds with body: ${JSON.stringify(quote)}`
        );
      }
    }
  }

  console.log(`Done. ${orphaned} order(s) flagged as orphaned Refunded status.`);
}

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 which orders get flagged as misleading. Because is_orphaned_refund_status takes only plain values and returns a plain boolean, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_manual_refund_repair.py
from find_orphaned_refund_statuses import is_orphaned_refund_status


def refund_txn(amount="50.00", type_="refund"):
    return {"type": type_, "amount": amount}


def test_false_when_status_is_not_refund_related():
    assert is_orphaned_refund_status(10, [], "0.00", "50.00") is False


def test_true_when_refunded_status_with_no_transactions_at_all():
    assert is_orphaned_refund_status(4, [], "0.00", "50.00") is True


def test_false_when_refunded_status_has_a_real_refund_transaction():
    txns = [refund_txn(amount="50.00")]
    assert is_orphaned_refund_status(4, txns, "50.00", "50.00") is False


def test_true_when_partially_refunded_with_only_non_refund_transactions():
    txns = [{"type": "capture", "amount": "50.00"}]
    assert is_orphaned_refund_status(14, txns, "0.00", "50.00") is True


def test_false_when_refunded_amount_is_recorded_even_without_a_txn_row():
    assert is_orphaned_refund_status(4, [], "50.00", "50.00") is False


def test_true_when_refund_transaction_amount_is_zero():
    txns = [refund_txn(amount="0.00")]
    assert is_orphaned_refund_status(4, txns, "0.00", "50.00") is True
find-orphaned-refund-statuses.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isOrphanedRefundStatus } from "./find-orphaned-refund-statuses.js";

const refundTxn = ({ amount = "50.00", type = "refund" } = {}) => ({ type, amount });

test("false when status is not refund related", () => {
  assert.equal(isOrphanedRefundStatus(10, [], "0.00", "50.00"), false);
});

test("true when refunded status with no transactions at all", () => {
  assert.equal(isOrphanedRefundStatus(4, [], "0.00", "50.00"), true);
});

test("false when refunded status has a real refund transaction", () => {
  const txns = [refundTxn({ amount: "50.00" })];
  assert.equal(isOrphanedRefundStatus(4, txns, "50.00", "50.00"), false);
});

test("true when partially refunded with only non-refund transactions", () => {
  const txns = [{ type: "capture", amount: "50.00" }];
  assert.equal(isOrphanedRefundStatus(14, txns, "0.00", "50.00"), true);
});

test("false when refunded amount is recorded even without a txn row", () => {
  assert.equal(isOrphanedRefundStatus(4, [], "50.00", "50.00"), false);
});

test("true when refund transaction amount is zero", () => {
  const txns = [refundTxn({ amount: "0.00" })];
  assert.equal(isOrphanedRefundStatus(4, txns, "0.00", "50.00"), true);
});

Case studies

Edit status habit

The support team that always used the wrong dropdown

A small support team had one veteran rep who had learned BigCommerce years ago through the general Edit status control, and never switched to the dedicated Refund action once it existed. Every refund that rep handled looked done from the order list, Refunded, right there in the status column, but the transactions endpoint for every one of those orders was empty.

The reconciler caught the whole backlog on its first run: dozens of orders marked Refunded going back months, refunded_amount still at 0.00. Once the team saw the report, they retrained on the Refund action and used the flagged list to work out, order by order, which customers still genuinely needed their money back.

Sync script mirroring an external refund

The integration that copied refund status from a legacy system

A store migrating off an older platform ran a sync script that mirrored refund decisions from the legacy system by writing status_id 4 straight onto the matching BigCommerce order, on the assumption the legacy system's refund had already happened wherever the real payment lived. For most orders that was true. For a batch where the legacy refund itself had silently failed, it was not.

Because the reconciler checks the transaction record instead of trusting the status label, it flagged exactly the batch where the legacy refund had failed, letting the team go back and process real refunds for the customers who were still owed money, without re-checking every order the sync had touched.

What good looks like

After this runs on a schedule, no order can sit marked Refunded or Partially Refunded for long without someone knowing whether real money actually moved. The report gives a human everything needed to act, the order id, the status, the total, and the transaction summary, and the optional refund quote preview saves them from re-deriving the exact request by hand. The one thing that never happens automatically is the refund itself, because that decision needs a person who can check whether the customer was already paid back some other way.

FAQ

Why does a BigCommerce order show Refunded when no money was actually returned?

BigCommerce treats order status and money movement as two separate systems. The status_id field is just a label on the order record, and PUT /v2/orders/{id} will accept status_id 4 (Refunded) or 14 (Partially Refunded) with no side effect at all. Only the dedicated Payment Actions endpoints, refund_quotes then refunds, actually call the gateway and move money, then write a transaction and update the status as a result. If staff use the Edit status dropdown instead of the Refund action, or a script PUTs status_id 4 directly, BigCommerce shows Refunded with zero refund transactions behind it.

Can I safely auto-fix an order that is stuck showing Refunded with no transaction?

No, not automatically. There is no BigCommerce API to retroactively attach a real gateway refund to an order after the fact, and whether the customer was already refunded through some other channel, like the processor's own dashboard, requires a human to check. The safe pattern is to flag the mismatch, and optionally fetch a refund quote to show an operator the exact request they would need to confirm, but never auto-submit the refund itself since that moves real money.

How do I detect these orphaned Refunded statuses across a whole store?

Query GET /v2/orders with status_id=4 and again with status_id=14 for your lookback window, then call GET /v2/orders/{id}/transactions for each order and look for a refund-type entry whose summed amount is greater than zero. Cross-check against GET /v3/orders/{order_id}/payment_actions/refunds. Any order marked Refunded or Partially Refunded with no matching refund transaction, or a refunded_amount that is still 0.00, is an orphaned status change with no money behind it.

Related field notes

Citations

On the problem:

  1. BigCommerce Help Center: Order Actions, manually changing status vs. using the Refund action. support.bigcommerce.com using order actions
  2. BigCommerce Support Community: refunds not working correctly. support.bigcommerce.com refunds not working correctly
  3. BigCommerce Support Community: refunds not going through, issue with all refund processing. bigcommerce.my.site.com refund processing issue

On the solution:

  1. BigCommerce Developer Center: Order Refunds overview and the Payment Actions workflow. developer.bigcommerce.com order refunds
  2. BigCommerce API Reference: Create Refund (payment_actions/refunds). docs.bigcommerce.com create order refund
  3. BigCommerce Developer Center: the order transactions endpoint. developer.bigcommerce.com transactions

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.

Contact me on LinkedIn

Did this clear up a mystery refund?

If this saved you from mistaking a status label for money in the bank, 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