Reconciler Orders and payments

Gateway refund not reflected on the order

Support refunded the customer straight in Stripe, or the processor pushed the money back on its own. The customer has the funds. But the BigCommerce order still says Completed, or Awaiting Fulfillment, as if nothing happened. Nobody notices until the customer emails asking why the order looks unrefunded, or a report shows revenue that was already given back. Here is why BigCommerce never hears about a refund issued outside its own Refund action, and a small script that finds the orders where that happened and repairs the status safely.

Python and Node.js BigCommerce REST API Safe by default (dry run, flags what it cannot reconcile)
The short answer

BigCommerce only updates an order's status_id and writes a refund transaction record when the refund runs through its own admin Refund action, or the v3 payment_actions/refunds endpoint, which calls the gateway and updates the order atomically. A refund issued directly in the gateway's own dashboard or API never calls back into BigCommerce, so the order keeps its old status. Run a small Python or Node.js script that pulls GET /v2/orders/{id}/transactions and GET /v3/orders/{order_id}/payment_actions/refunds for each order, compares that against the gateway's own refund history, and when the gateway shows more refunded than BigCommerce knows about, sets status_id to 4 (Refunded) or 14 (Partially Refunded) and writes an order note documenting the gateway refund id, amount, and timestamp. When the numbers do not reconcile cleanly, it flags the order for manual review instead of guessing. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce's admin has a Refund button on every order for a reason. Click it, and BigCommerce calls the gateway to move the money, then updates the order's status_id and writes a refund transaction record, all in one atomic step. The v3 payment_actions/refunds endpoint does the same thing when you refund through the API instead of clicking through the admin.

But a refund does not have to start in BigCommerce. A support agent with access to the Stripe or Braintree dashboard can refund a charge directly there. A payment processor can push a refund back on its own, for a dispute or a compliance reason. An integration can call the gateway's own refund API without ever telling BigCommerce. In every one of these cases, the money moves, but there is no callback path from the gateway into BigCommerce, so the order sits exactly where it was, at Completed or Awaiting Fulfillment, as if the refund never happened.

Refund issued in gateway dashboard Gateway sends money back to customer no callback to BigCommerce Order status_id stays Completed (10) GET .../transactions no refund row exists Customer refunded order looks untouched mismatch No polling exists to catch this on its own
The refund happened, but it happened outside the only path that updates the order, so BigCommerce never learns about it.

Why it happens

BigCommerce's order status only moves when its own refund flow runs, and nothing forces the gateway to report back when a refund bypasses that flow. A few common ways stores end up here:

This is a common source of confusion around chargebacks and support-desk refunds. Finance pulls a revenue report from BigCommerce that says an order is still fully paid, while the gateway's own dashboard shows the money already went back. BigCommerce documents the Refund action and the refund endpoints, but nothing in that documentation covers refunds issued outside it. See the citations at the end for the exact references.

The key insight

BigCommerce has no endpoint to retroactively import a gateway-executed refund as if it had gone through the Refund action. So the safe pattern is not "trust the gateway and overwrite the status." It is "confirm what the gateway shows against what BigCommerce already recorded, set the status only when the numbers reconcile cleanly, and write down exactly what happened." We do that by comparing the gateway's refunded amount against BigCommerce's own transaction and payment_actions records, and flagging anything that does not add up for a human instead of guessing.

The fix, as a flow

We do not touch orders that already show a refunded status. We add a job that walks the orders where money is likely to have moved, reads what BigCommerce already knows through its transactions and refunds endpoints, checks that against the gateway's own refund history for the same order, and only then decides: leave it alone, set the order to Refunded or Partially Refunded and note the gateway refund id, or flag it for a human when the amounts do not line up.

Scheduled job runs on a timer List orders not already 4 or 14 Read BC + gateway recorded vs refunded Amounts reconcile? yes no, flag for review Flag manual review amount did not add up Set status_id 4/14 + order note
The script only sets the status when the gateway's refund amount cleanly explains an unrecorded refund. Anything ambiguous is flagged, never guessed.

Build it step by step

1

Get a BigCommerce API access token

Create an API account in your BigCommerce control panel under Settings, API, and generate a token with the Orders and Transactions read scopes, plus Orders write so the script can set status_id and write the reconciliation note. Keep the store hash and the token in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export REFUND_ROUNDING_TOLERANCE="0.01"
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="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export REFUND_ROUNDING_TOLERANCE="0.01"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the BigCommerce REST API

Every call carries your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper sends the request, raises on a non-2xx response, and returns the parsed body. We reuse this helper to read orders, read BigCommerce's own recorded refunds, and later write the status and the note.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"

def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    return r.json() if r.content else None
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : null;
}
3

List candidate orders and read what BigCommerce already knows

Read orders in the statuses where money is likely to have moved and that are not already 4 (Refunded) or 14 (Partially Refunded), such as 2 (Shipped), 3 (Partially Shipped), 9 (Awaiting Shipment), 10 (Completed), or 11 (Awaiting Fulfillment). For each one, sum BigCommerce's own recorded refunds from GET /v2/orders/{id}/transactions and GET /v3/orders/{order_id}/payment_actions/refunds, the two places BigCommerce writes a refund it knows about.

step3.py
from decimal import Decimal

CHECK_STATUS_IDS = {2, 3, 9, 10, 11}
ALREADY_REFUNDED_STATUSES = {4, 14}

def orders_to_check():
    page = 1
    while True:
        rows = bc("GET", f"/v2/orders?page={page}&limit=50")
        if not rows:
            return
        for row in rows:
            sid = int(row["status_id"])
            if sid not in ALREADY_REFUNDED_STATUSES and sid in CHECK_STATUS_IDS:
                yield row
        page += 1

def bc_recorded_refund_amount_v2(order_id):
    rows = bc("GET", f"/v2/orders/{order_id}/transactions") or []
    return sum(Decimal(str(r["amount"])) for r in rows
               if r.get("type") == "refund" and r.get("success"))

def bc_recorded_refund_amount_v3(order_id):
    body = bc("GET", f"/v3/orders/{order_id}/payment_actions/refunds") or {}
    total = Decimal("0")
    for row in body.get("data", []):
        for detail in row.get("details", []):
            total += Decimal(str(detail.get("amount", "0")))
    return total
step3.js
const CHECK_STATUS_IDS = new Set([2, 3, 9, 10, 11]);
const ALREADY_REFUNDED_STATUSES = new Set([4, 14]);

async function* ordersToCheck() {
  let page = 1;
  while (true) {
    const rows = await bc("GET", `/v2/orders?page=${page}&limit=50`);
    if (!rows || rows.length === 0) return;
    for (const row of rows) {
      const sid = Number(row.status_id);
      if (!ALREADY_REFUNDED_STATUSES.has(sid) && CHECK_STATUS_IDS.has(sid)) yield row;
    }
    page++;
  }
}

async function bcRecordedRefundAmountV2(orderId) {
  const rows = (await bc("GET", `/v2/orders/${orderId}/transactions`)) || [];
  return rows
    .filter((r) => r.type === "refund" && r.success)
    .reduce((sum, r) => sum + Number(r.amount || 0), 0);
}

async function bcRecordedRefundAmountV3(orderId) {
  const body = (await bc("GET", `/v3/orders/${orderId}/payment_actions/refunds`)) || {};
  let total = 0;
  for (const row of body.data || []) {
    for (const detail of row.details || []) total += Number(detail.amount || 0);
  }
  return total;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order total, the current status_id, the gateway's refunded amount, and what BigCommerce has already recorded, then returns an action. If the gateway amount is already covered by what BigCommerce knows, do nothing. If there is an unrecorded amount, target status 4 when it covers the full order total, or 14 for a partial. If the order is already at that target, do nothing. If the gateway amount is inconsistent, negative or past the order total by more than a rounding tolerance, flag it for manual review instead of guessing.

decide.py
from decimal import Decimal

STATUS_REFUNDED = 4
STATUS_PARTIALLY_REFUNDED = 14
ROUNDING_TOLERANCE = Decimal("0.01")

def decide_refund_status(order_total, order_status_id, gateway_refunded_amount, bc_recorded_refund_amount):
    if gateway_refunded_amount < 0 or (gateway_refunded_amount - order_total) > ROUNDING_TOLERANCE:
        return {"action": "flag_manual_review", "target_status_id": None,
                "reason": "gateway_refunded_amount is inconsistent with order_total"}

    if gateway_refunded_amount <= bc_recorded_refund_amount:
        return {"action": "none", "target_status_id": None, "reason": "already reconciled"}

    unrecorded = gateway_refunded_amount - bc_recorded_refund_amount

    if gateway_refunded_amount >= order_total - ROUNDING_TOLERANCE:
        target_status_id = STATUS_REFUNDED
    elif unrecorded > 0:
        target_status_id = STATUS_PARTIALLY_REFUNDED
    else:
        return {"action": "none", "target_status_id": None, "reason": "no unrecorded amount"}

    if order_status_id == target_status_id:
        return {"action": "none", "target_status_id": None, "reason": "already at target status"}

    return {"action": "set_status", "target_status_id": target_status_id,
            "reason": f"unrecorded refund amount {unrecorded} found on the gateway"}
decide.js
const STATUS_REFUNDED = 4;
const STATUS_PARTIALLY_REFUNDED = 14;
const ROUNDING_TOLERANCE = 0.01;

export function decideRefundStatus(orderTotal, orderStatusId, gatewayRefundedAmount, bcRecordedRefundAmount) {
  if (gatewayRefundedAmount < 0 || gatewayRefundedAmount - orderTotal > ROUNDING_TOLERANCE) {
    return { action: "flag_manual_review", targetStatusId: null,
             reason: "gatewayRefundedAmount is inconsistent with orderTotal" };
  }

  if (gatewayRefundedAmount <= bcRecordedRefundAmount) {
    return { action: "none", targetStatusId: null, reason: "already reconciled" };
  }

  const unrecorded = gatewayRefundedAmount - bcRecordedRefundAmount;
  let targetStatusId;

  if (gatewayRefundedAmount >= orderTotal - ROUNDING_TOLERANCE) {
    targetStatusId = STATUS_REFUNDED;
  } else if (unrecorded > 0) {
    targetStatusId = STATUS_PARTIALLY_REFUNDED;
  } else {
    return { action: "none", targetStatusId: null, reason: "no unrecorded amount" };
  }

  if (orderStatusId === targetStatusId) {
    return { action: "none", targetStatusId: null, reason: "already at target status" };
  }

  return { action: "set_status", targetStatusId,
           reason: `unrecorded refund amount ${unrecorded} found on the gateway` };
}
5

Apply the status, or flag it, never both silently

When the decision is set_status, write an order note with the gateway refund id, amount, and timestamp through PUT /v2/orders/{id}, then write the new status_id in a second call. BigCommerce has no endpoint that lets you retroactively attach a transaction record as if the refund had gone through its own Refund action, so the note is the only trace of where the money actually moved. When the decision is flag_manual_review, write the reason to a note and stop, do not touch the status.

apply.py
def flag_for_manual_review(order_id, reason):
    note = f"GATEWAY_REFUND_REVIEW: {reason}"
    return bc("PUT", f"/v2/orders/{order_id}", json={"staff_notes": note})

def apply_status(order_id, target_status_id, gateway_refund_id, amount, timestamp):
    note = (f"GATEWAY_REFUND_SYNCED: gateway_refund_id={gateway_refund_id} amount={amount} "
            f"at={timestamp} synced_status_id={target_status_id}")
    bc("PUT", f"/v2/orders/{order_id}", json={"staff_notes": note})
    return bc("PUT", f"/v2/orders/{order_id}", json={"status_id": target_status_id})
apply.js
async function flagForManualReview(orderId, reason) {
  const note = `GATEWAY_REFUND_REVIEW: ${reason}`;
  return bc("PUT", `/v2/orders/${orderId}`, { staff_notes: note });
}

async function applyStatus(orderId, targetStatusId, gatewayRefundId, amount, timestamp) {
  const note = `GATEWAY_REFUND_SYNCED: gateway_refund_id=${gatewayRefundId} amount=${amount} at=${timestamp} synced_status_id=${targetStatusId}`;
  await bc("PUT", `/v2/orders/${orderId}`, { staff_notes: note });
  return bc("PUT", `/v2/orders/${orderId}`, { status_id: targetStatusId });
}
6

Wire it together with a dry run guard

The loop pulls each candidate order, reads what BigCommerce already recorded, looks up the gateway's own refunded amount for that order's transaction id, runs the pure decision function, and only writes when DRY_RUN is off. Leave DRY_RUN=true on the first runs so the script only logs what it would set or flag. Read the log, confirm it against a few orders you already know about, then switch it off. Run it on a schedule, for example every hour, so a gateway-side refund never sits unreflected for long.

Run it safe

Always start with DRY_RUN=true. Never let this script set status_id when the gateway amount does not cleanly reconcile against the order total, that path always flags for manual review instead. Wire your gateway's refund lookup in yourself; the script ships with that call as an explicit seam so nothing writes with a fabricated amount.

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 either sets the order's status with a documenting note or flags it for a human, never both, never a guess.

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

sync_gateway_refunds.py
"""Detect and repair BigCommerce orders where a gateway-side refund was never reflected.

BigCommerce only updates an order's status_id and writes a refund transaction record
when a refund is initiated through its own admin Refund action, or the v3
payment_actions/refunds endpoint, which calls the gateway and updates the order
atomically. If a merchant or the payment processor issues the refund directly in the
gateway's own dashboard or API, there is no callback path into BigCommerce, so the
order silently stays at its prior status_id even though the customer has already been
refunded. This reads each order's total, its status_id, BigCommerce's own recorded
refund amount (from v2 transactions and v3 payment_actions/refunds), and the gateway's
refunded amount, then decides whether to set status_id to 4 (Refunded) or 14
(Partially Refunded), or to flag the order for manual review when the amounts do not
reconcile cleanly. Because BigCommerce has no endpoint to retroactively import a
gateway-executed refund as if it went through the Refund action, every write is paired
with an order note documenting the gateway refund id, amount, and timestamp so the
discrepancy stays traceable. Run on a schedule. Safe to run again and again.
"""
import os
import logging
from decimal import Decimal, InvalidOperation

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
MIN_DATE_MODIFIED = os.environ.get("MIN_DATE_MODIFIED", "")
ROUNDING_TOLERANCE = Decimal(os.environ.get("REFUND_ROUNDING_TOLERANCE", "0.01"))
REVIEW_TAG = os.environ.get("REVIEW_TAG", "gateway-refund-needs-review")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

STATUS_REFUNDED = 4
STATUS_PARTIALLY_REFUNDED = 14
ALREADY_REFUNDED_STATUSES = {STATUS_REFUNDED, STATUS_PARTIALLY_REFUNDED}
CHECK_STATUS_IDS = {2, 3, 9, 10, 11}


def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    return r.json() if r.content else None


def decide_refund_status(order_total, order_status_id, gateway_refunded_amount, bc_recorded_refund_amount):
    """Pure decision function. No network calls.

    order_total, gateway_refunded_amount, bc_recorded_refund_amount: Decimal
    order_status_id: int
    Returns {"action": "none"|"set_status"|"flag_manual_review",
             "target_status_id": int|None, "reason": str}
    """
    if gateway_refunded_amount < 0 or (gateway_refunded_amount - order_total) > ROUNDING_TOLERANCE:
        return {
            "action": "flag_manual_review",
            "target_status_id": None,
            "reason": (
                f"gateway_refunded_amount {gateway_refunded_amount} is inconsistent with "
                f"order_total {order_total} (negative or exceeds total beyond tolerance)"
            ),
        }

    if gateway_refunded_amount <= bc_recorded_refund_amount:
        return {
            "action": "none",
            "target_status_id": None,
            "reason": "gateway_refunded_amount already reconciled with BigCommerce's recorded refund amount",
        }

    unrecorded = gateway_refunded_amount - bc_recorded_refund_amount

    if gateway_refunded_amount >= order_total - ROUNDING_TOLERANCE:
        target_status_id = STATUS_REFUNDED
        reason = f"gateway_refunded_amount {gateway_refunded_amount} covers the order total {order_total}"
    elif unrecorded > 0:
        target_status_id = STATUS_PARTIALLY_REFUNDED
        reason = (
            f"unrecorded refund amount {unrecorded} found on the gateway that BigCommerce "
            "has not recorded"
        )
    else:
        return {
            "action": "none",
            "target_status_id": None,
            "reason": "no unrecorded refund amount",
        }

    if order_status_id == target_status_id:
        return {
            "action": "none",
            "target_status_id": None,
            "reason": f"order_status_id already equals target status_id {target_status_id}",
        }

    return {"action": "set_status", "target_status_id": target_status_id, "reason": reason}


def to_decimal(amount):
    try:
        return Decimal(str(amount))
    except (InvalidOperation, TypeError):
        return Decimal("0")


def orders_to_check():
    page = 1
    while True:
        params = f"page={page}&limit=50"
        if MIN_DATE_MODIFIED:
            params += f"&min_date_modified={MIN_DATE_MODIFIED}"
        rows = bc("GET", f"/v2/orders?{params}")
        if not rows:
            return
        for row in rows:
            if int(row["status_id"]) not in ALREADY_REFUNDED_STATUSES and int(row["status_id"]) in CHECK_STATUS_IDS:
                yield row
        page += 1


def bc_recorded_refund_amount_v2(order_id):
    """Sum settled refund transactions from GET /v2/orders/{id}/transactions."""
    rows = bc("GET", f"/v2/orders/{order_id}/transactions") or []
    total = Decimal("0")
    for row in rows:
        if row.get("type") == "refund" and row.get("success"):
            total += to_decimal(row.get("amount", "0"))
    return total


def bc_recorded_refund_amount_v3(order_id):
    """Sum recorded refunds from GET /v3/orders/{order_id}/payment_actions/refunds."""
    body = bc("GET", f"/v3/orders/{order_id}/payment_actions/refunds") or {}
    total = Decimal("0")
    for row in body.get("data", []):
        for detail in row.get("details", []):
            total += to_decimal(detail.get("amount", "0"))
    return total


def gateway_refunded_amount(order_id):
    """Look up the gateway's own refund total for this order's transaction id.

    In production this calls the gateway's own API (Stripe, Braintree, Authorize.net,
    etc.) with the transaction id recorded on the order. This stub is a seam for that
    call; wire in your gateway client here.
    """
    raise NotImplementedError("wire in your payment gateway's refund lookup here")


def flag_for_manual_review(order_id, reason):
    note = f"GATEWAY_REFUND_REVIEW: {reason}"
    return bc("PUT", f"/v2/orders/{order_id}", json={"staff_notes": note})


def apply_status(order_id, target_status_id, gateway_refund_id, amount, timestamp):
    note = (
        f"GATEWAY_REFUND_SYNCED: gateway_refund_id={gateway_refund_id} amount={amount} "
        f"at={timestamp} synced_status_id={target_status_id}"
    )
    bc("PUT", f"/v2/orders/{order_id}", json={"staff_notes": note})
    return bc("PUT", f"/v2/orders/{order_id}", json={"status_id": target_status_id})


def run():
    changed = 0
    flagged = 0
    for row in orders_to_check():
        order_id = row["id"]
        order_total = to_decimal(row.get("total_inc_tax", "0"))
        order_status_id = int(row["status_id"])

        bc_recorded = bc_recorded_refund_amount_v2(order_id) + bc_recorded_refund_amount_v3(order_id)

        try:
            gw_refunded = gateway_refunded_amount(order_id)
        except NotImplementedError:
            log.debug("Order #%s: gateway lookup not wired in, skipping.", order_id)
            continue

        decision = decide_refund_status(order_total, order_status_id, gw_refunded, bc_recorded)

        if decision["action"] == "none":
            continue

        if decision["action"] == "flag_manual_review":
            log.warning("Order #%s flagged for manual review: %s. %s",
                        order_id, decision["reason"], "would flag" if DRY_RUN else "flagging")
            if not DRY_RUN:
                flag_for_manual_review(order_id, decision["reason"])
            flagged += 1
            continue

        log.info("Order #%s: %s. %s", order_id, decision["reason"],
                  "would set status_id=%s" % decision["target_status_id"] if DRY_RUN else
                  "setting status_id=%s" % decision["target_status_id"])
        if not DRY_RUN:
            apply_status(order_id, decision["target_status_id"], gateway_refund_id="unknown",
                         amount=gw_refunded, timestamp="unknown")
        changed += 1

    log.info("Done. %d order(s) %s status, %d order(s) %s for review.",
              changed, "to set" if DRY_RUN else "set",
              flagged, "to flag" if DRY_RUN else "flagged")


if __name__ == "__main__":
    run()
sync-gateway-refunds.js
/**
 * Detect and repair BigCommerce orders where a gateway-side refund was never reflected.
 *
 * BigCommerce only updates an order's status_id and writes a refund transaction record
 * when a refund is initiated through its own admin Refund action, or the v3
 * payment_actions/refunds endpoint, which calls the gateway and updates the order
 * atomically. If a merchant or the payment processor issues the refund directly in the
 * gateway's own dashboard or API, there is no callback path into BigCommerce, so the
 * order silently stays at its prior status_id even though the customer has already been
 * refunded. This reads each order's total, its status_id, BigCommerce's own recorded
 * refund amount (from v2 transactions and v3 payment_actions/refunds), and the gateway's
 * refunded amount, then decides whether to set status_id to 4 (Refunded) or 14
 * (Partially Refunded), or to flag the order for manual review when the amounts do not
 * reconcile cleanly. Because BigCommerce has no endpoint to retroactively import a
 * gateway-executed refund as if it went through the Refund action, every write is paired
 * with an order note documenting the gateway refund id, amount, and timestamp so the
 * discrepancy stays traceable. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/gateway-refund-not-reflected-on-the-order/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy_token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const MIN_DATE_MODIFIED = process.env.MIN_DATE_MODIFIED || "";
const ROUNDING_TOLERANCE = Number(process.env.REFUND_ROUNDING_TOLERANCE || 0.01);
const REVIEW_TAG = process.env.REVIEW_TAG || "gateway-refund-needs-review";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const STATUS_REFUNDED = 4;
const STATUS_PARTIALLY_REFUNDED = 14;
const ALREADY_REFUNDED_STATUSES = new Set([STATUS_REFUNDED, STATUS_PARTIALLY_REFUNDED]);
const CHECK_STATUS_IDS = new Set([2, 3, 9, 10, 11]);

/**
 * Pure decision function. No network calls.
 * orderTotal, gatewayRefundedAmount, bcRecordedRefundAmount: number (decimal currency units)
 * orderStatusId: number
 * Returns { action: "none"|"set_status"|"flag_manual_review", targetStatusId: number|null, reason: string }
 */
export function decideRefundStatus(orderTotal, orderStatusId, gatewayRefundedAmount, bcRecordedRefundAmount) {
  if (gatewayRefundedAmount < 0 || gatewayRefundedAmount - orderTotal > ROUNDING_TOLERANCE) {
    return {
      action: "flag_manual_review",
      targetStatusId: null,
      reason: `gatewayRefundedAmount ${gatewayRefundedAmount} is inconsistent with orderTotal ${orderTotal} (negative or exceeds total beyond tolerance)`,
    };
  }

  if (gatewayRefundedAmount <= bcRecordedRefundAmount) {
    return {
      action: "none",
      targetStatusId: null,
      reason: "gatewayRefundedAmount already reconciled with BigCommerce's recorded refund amount",
    };
  }

  const unrecorded = gatewayRefundedAmount - bcRecordedRefundAmount;
  let targetStatusId;
  let reason;

  if (gatewayRefundedAmount >= orderTotal - ROUNDING_TOLERANCE) {
    targetStatusId = STATUS_REFUNDED;
    reason = `gatewayRefundedAmount ${gatewayRefundedAmount} covers the order total ${orderTotal}`;
  } else if (unrecorded > 0) {
    targetStatusId = STATUS_PARTIALLY_REFUNDED;
    reason = `unrecorded refund amount ${unrecorded} found on the gateway that BigCommerce has not recorded`;
  } else {
    return { action: "none", targetStatusId: null, reason: "no unrecorded refund amount" };
  }

  if (orderStatusId === targetStatusId) {
    return {
      action: "none",
      targetStatusId: null,
      reason: `orderStatusId already equals target status_id ${targetStatusId}`,
    };
  }

  return { action: "set_status", targetStatusId, reason };
}

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : null;
}

async function* ordersToCheck() {
  let page = 1;
  while (true) {
    let params = `page=${page}&limit=50`;
    if (MIN_DATE_MODIFIED) params += `&min_date_modified=${MIN_DATE_MODIFIED}`;
    const rows = await bc("GET", `/v2/orders?${params}`);
    if (!rows || rows.length === 0) return;
    for (const row of rows) {
      const statusId = Number(row.status_id);
      if (!ALREADY_REFUNDED_STATUSES.has(statusId) && CHECK_STATUS_IDS.has(statusId)) yield row;
    }
    page++;
  }
}

async function bcRecordedRefundAmountV2(orderId) {
  const rows = (await bc("GET", `/v2/orders/${orderId}/transactions`)) || [];
  return rows
    .filter((row) => row.type === "refund" && row.success)
    .reduce((sum, row) => sum + Number(row.amount || 0), 0);
}

async function bcRecordedRefundAmountV3(orderId) {
  const body = (await bc("GET", `/v3/orders/${orderId}/payment_actions/refunds`)) || {};
  let total = 0;
  for (const row of body.data || []) {
    for (const detail of row.details || []) total += Number(detail.amount || 0);
  }
  return total;
}

async function gatewayRefundedAmount(_orderId) {
  // In production this calls the gateway's own API (Stripe, Braintree, Authorize.net,
  // etc.) with the transaction id recorded on the order. This stub is a seam for that
  // call; wire in your gateway client here.
  throw new Error("NOT_WIRED_IN");
}

async function flagForManualReview(orderId, reason) {
  const note = `GATEWAY_REFUND_REVIEW: ${reason}`;
  return bc("PUT", `/v2/orders/${orderId}`, { staff_notes: note });
}

async function applyStatus(orderId, targetStatusId, gatewayRefundId, amount, timestamp) {
  const note = `GATEWAY_REFUND_SYNCED: gateway_refund_id=${gatewayRefundId} amount=${amount} at=${timestamp} synced_status_id=${targetStatusId}`;
  await bc("PUT", `/v2/orders/${orderId}`, { staff_notes: note });
  return bc("PUT", `/v2/orders/${orderId}`, { status_id: targetStatusId });
}

export async function run() {
  let changed = 0;
  let flagged = 0;
  for await (const row of ordersToCheck()) {
    const orderId = row.id;
    const orderTotal = Number(row.total_inc_tax || 0);
    const orderStatusId = Number(row.status_id);

    const bcRecorded = (await bcRecordedRefundAmountV2(orderId)) + (await bcRecordedRefundAmountV3(orderId));

    let gwRefunded;
    try {
      gwRefunded = await gatewayRefundedAmount(orderId);
    } catch (err) {
      if (err.message === "NOT_WIRED_IN") {
        continue;
      }
      throw err;
    }

    const decision = decideRefundStatus(orderTotal, orderStatusId, gwRefunded, bcRecorded);

    if (decision.action === "none") continue;

    if (decision.action === "flag_manual_review") {
      console.warn(`Order #${orderId} flagged for manual review: ${decision.reason}. ${DRY_RUN ? "would flag" : "flagging"}`);
      if (!DRY_RUN) await flagForManualReview(orderId, decision.reason);
      flagged++;
      continue;
    }

    console.log(`Order #${orderId}: ${decision.reason}. ${DRY_RUN ? `would set status_id=${decision.targetStatusId}` : `setting status_id=${decision.targetStatusId}`}`);
    if (!DRY_RUN) await applyStatus(orderId, decision.targetStatusId, "unknown", gwRefunded, "unknown");
    changed++;
  }
  console.log(`Done. ${changed} order(s) ${DRY_RUN ? "to set" : "set"} status, ${flagged} order(s) ${DRY_RUN ? "to flag" : "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's status changes and whether the customer's already-completed refund finally shows up correctly. Because we kept decide_refund_status pure, arithmetic and comparisons only, the test needs no network and no BigCommerce store. It just feeds in plain numbers and checks the answer.

test_reflected_gateway_refunds.py
from decimal import Decimal

from sync_gateway_refunds import decide_refund_status


def test_already_reconciled_is_none():
    result = decide_refund_status(Decimal("100.00"), 10, Decimal("0.00"), Decimal("0.00"))
    assert result["action"] == "none"


def test_full_refund_not_yet_recorded_sets_status_4():
    result = decide_refund_status(Decimal("100.00"), 10, Decimal("100.00"), Decimal("0.00"))
    assert result["action"] == "set_status"
    assert result["target_status_id"] == 4


def test_partial_refund_not_yet_recorded_sets_status_14():
    result = decide_refund_status(Decimal("100.00"), 11, Decimal("40.00"), Decimal("0.00"))
    assert result["action"] == "set_status"
    assert result["target_status_id"] == 14


def test_already_matches_bc_recorded_amount_is_none():
    result = decide_refund_status(Decimal("100.00"), 10, Decimal("40.00"), Decimal("40.00"))
    assert result["action"] == "none"


def test_partial_topped_up_to_full_moves_to_status_4():
    # BigCommerce recorded a 40 partial refund already, gateway shows the full 100 refunded
    result = decide_refund_status(Decimal("100.00"), 14, Decimal("100.00"), Decimal("40.00"))
    assert result["action"] == "set_status"
    assert result["target_status_id"] == 4


def test_order_already_at_target_status_is_none():
    result = decide_refund_status(Decimal("100.00"), 4, Decimal("100.00"), Decimal("0.00"))
    assert result["action"] == "none"


def test_negative_gateway_amount_flags_manual_review():
    result = decide_refund_status(Decimal("100.00"), 10, Decimal("-5.00"), Decimal("0.00"))
    assert result["action"] == "flag_manual_review"


def test_gateway_amount_exceeding_total_flags_manual_review():
    result = decide_refund_status(Decimal("100.00"), 10, Decimal("150.00"), Decimal("0.00"))
    assert result["action"] == "flag_manual_review"


def test_rounding_tolerance_treats_near_total_as_full_refund():
    # within a cent of the total should count as a full refund, not a partial
    result = decide_refund_status(Decimal("100.00"), 10, Decimal("99.995"), Decimal("0.00"))
    assert result["action"] == "set_status"
    assert result["target_status_id"] == 4
sync-gateway-refunds.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideRefundStatus } from "./sync-gateway-refunds.js";

test("already reconciled is none", () => {
  const result = decideRefundStatus(100.00, 10, 0.00, 0.00);
  assert.equal(result.action, "none");
});

test("full refund not yet recorded sets status 4", () => {
  const result = decideRefundStatus(100.00, 10, 100.00, 0.00);
  assert.equal(result.action, "set_status");
  assert.equal(result.targetStatusId, 4);
});

test("partial refund not yet recorded sets status 14", () => {
  const result = decideRefundStatus(100.00, 11, 40.00, 0.00);
  assert.equal(result.action, "set_status");
  assert.equal(result.targetStatusId, 14);
});

test("already matches bc recorded amount is none", () => {
  const result = decideRefundStatus(100.00, 10, 40.00, 40.00);
  assert.equal(result.action, "none");
});

test("partial topped up to full moves to status 4", () => {
  const result = decideRefundStatus(100.00, 14, 100.00, 40.00);
  assert.equal(result.action, "set_status");
  assert.equal(result.targetStatusId, 4);
});

test("order already at target status is none", () => {
  const result = decideRefundStatus(100.00, 4, 100.00, 0.00);
  assert.equal(result.action, "none");
});

test("negative gateway amount flags manual review", () => {
  const result = decideRefundStatus(100.00, 10, -5.00, 0.00);
  assert.equal(result.action, "flag_manual_review");
});

test("gateway amount exceeding total flags manual review", () => {
  const result = decideRefundStatus(100.00, 10, 150.00, 0.00);
  assert.equal(result.action, "flag_manual_review");
});

test("rounding tolerance treats near total as full refund", () => {
  const result = decideRefundStatus(100.00, 10, 99.995, 0.00);
  assert.equal(result.action, "set_status");
  assert.equal(result.targetStatusId, 4);
});

Case studies

Support-desk refund

The agent who refunded straight in Stripe

A merchant's support team had Stripe dashboard access and got in the habit of clicking Refund there for a quick fix instead of walking through BigCommerce's own Refund action. Stripe moved the money without issue, but the BigCommerce order kept showing Completed, so anyone glancing at the order in the admin had no idea the customer had already been made whole.

Running the reconciliation job every hour caught these within the same shift they happened. Each eligible order moved to Refunded with a note carrying the Stripe refund id and amount, and support stopped hearing "why does the order still say Completed" from customers who had already gotten their money back.

Processor-initiated refund

The dispute that refunded itself

A payment processor auto-refunded a batch of orders tied to a compliance review, entirely outside the merchant's control. BigCommerce never heard about any of it, so weeks later finance found a stack of Completed orders that the processor's own statement showed as fully refunded.

Because the amounts matched the order totals exactly, the job set every one of them to Refunded automatically, with a note pointing at the processor's own refund reference. The handful where the processor had only partially refunded, for a restocking fee or a partial dispute, were flagged for manual review instead of being guessed at.

What good looks like

After this runs on a schedule, a refund issued outside BigCommerce's own Refund action gets reflected within one run instead of sitting invisible until a customer complains. Every synced order carries a note with the gateway refund id, amount, and timestamp, so anyone looking at the order history sees exactly where the money moved. Anything the script cannot cleanly reconcile is flagged for a human, never silently applied, so a genuine accounting question never gets buried under an automatic status change.

FAQ

Why does my BigCommerce order still show Completed after I refunded the customer?

BigCommerce only updates an order's status_id and records a refund transaction when the refund is issued through its own admin Refund action or the v3 payment_actions/refunds endpoint. If the refund happened directly in the gateway's own dashboard or API, there is no callback path into BigCommerce, so the order silently keeps its prior status, such as Completed or Awaiting Fulfillment, even though the money has already gone back to the customer.

Can I just set status_id to 4 for every order the gateway shows as refunded?

Not blindly. You should only set status_id to 4 (Refunded) or 14 (Partially Refunded) after confirming the gateway's refunded amount against what BigCommerce has already recorded through its own transactions and payment_actions/refunds endpoints, and writing an order note with the gateway refund id, amount, and timestamp. If the amount cannot be cleanly reconciled against the order total, the order should be flagged for manual review instead of guessing a status.

How do I detect a gateway refund that BigCommerce never recorded?

For each order not already in a refunded state, call GET /v2/orders/{id}/transactions and GET /v3/orders/{order_id}/payment_actions/refunds to see what BigCommerce already knows about, then compare that against the gateway's own refund or charge history for the same transaction id. If the gateway shows a settled refund that is not matched by a BigCommerce record, the order is out of sync.

Related field notes

Citations

On the problem:

  1. BigCommerce Help Center: Processing Refunds. support.bigcommerce.com/s/article/Processing-Refunds
  2. BigCommerce Developer Center: Order Refunds. developer.bigcommerce.com/docs/store-operations/orders/refunds
  3. BigCommerce Developer Center: Order Status. developer.bigcommerce.com/docs/rest-management/orders/order-status

On the solution:

  1. BigCommerce API Reference: List Refunds for Order. docs.bigcommerce.com/developer/api-reference get-order-refunds
  2. BigCommerce API Reference: Create Refund. docs.bigcommerce.com/developer/api-reference create-order-refund
  3. BigCommerce Developer Center: Transactions API. developer.bigcommerce.com/docs/store-operations/payments/transactions-api

Stuck on a tricky one?

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

Contact me on LinkedIn

Did this untangle a refund headache?

If this saved you a confusing support ticket or a wrong revenue report, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all BigCommerce field notes