Repair Payments / Refunds

Concurrent refund requests on one order corrupt payment status

A support agent double-clicks Refund while an automation script fires the same request. Both calls get accepted. Now the order's payment_status is stuck at Partially Refunded, or over-refunded, and nobody can tell from the admin screen alone what actually happened at the gateway. BigCommerce's refund flow has no per-order lock, so two requests racing on the same order_id can both read the same pre-refund quote and both go through. Here is why that gap exists and a small script that serializes future refunds and flags the orders already caught in the race.

Python and Node.js BigCommerce V3 Payment Actions API Safe by default (dry run)
Blue and white visa card on silver laptop computer
Photo by CardMapr.nl on Unsplash
The short answer

BigCommerce refunds an order in two sequential calls, POST /v3/orders/{id}/payment_actions/refund_quotes to compute the refundable amount and eligible payment methods, then POST /v3/orders/{id}/payment_actions/refund using that quote. Settlement against the gateway is asynchronous, so payment_status and status_id update after the API accepts the request, not atomically with it. BigCommerce's own documentation says plainly that processing multiple concurrent refunds on the same order is not yet supported, because there is no per-order idempotency lock. When two refund requests race for the same order_id, both can read the same quote and both get accepted, leaving the order mismatched. Run a small Python or Node.js script that acquires a per-order lock before every future refund call, and that scans existing orders for a mismatch between total_refunded and the sum of refund transactions so a human can reconcile anything already broken. Full code, tests, and a dry run guard are below.

The problem in plain words

A refund on a BigCommerce order is not one call, it is two. First you ask for a quote, POST /v3/orders/{id}/payment_actions/refund_quotes, which tells you the refundable amount and which payment methods are eligible to receive it. Then you spend that quote with POST /v3/orders/{id}/payment_actions/refund. Nothing about that second call is atomic with the order's own state. The gateway settles the refund out of band, and the order's payment_status (Refunded, status_id 4, or Partially Refunded, status_id 14) is only updated once BigCommerce hears back, not the instant the refund API accepts the request.

That gap is exactly where two requests can collide. A support agent double-clicks the Refund button in the admin while a Zapier automation, a retried webhook handler, or a second browser tab fires the same refund for the same order_id. Both requests call refund_quotes first, and because nothing has settled yet, both can read the same pre-refund refundable amount and the same eligible refund_methods. Both then get accepted by the refund endpoint. The order ends up stuck at Partially Refunded when it should read Refunded, over-refunded past the order total, or throwing "invalid split payment" 422 errors on the next legitimate refund attempt, because BigCommerce's API has no per-order lock to make the second request wait for the first.

Agent clicks Refund Automation fires refund Both read same refund_quotes result No per-order lock Both refund calls accepted async settlement payment_status Partially Refunded or over-refunded
Two refund requests for the same order_id both read the same pre-refund quote and both get accepted, because BigCommerce has no per-order refund lock at the API layer.

Why it happens

A few common paths lead to the same collision on the same order_id:

In every case, both requests call refund_quotes before either one settles, so both see the same refundable amount and the same eligible refund_methods. BigCommerce's own developer documentation states plainly that processing multiple concurrent refunds on the same order is not yet supported, and BigCommerce's support community has threads describing the resulting "invalid split payment" 422 error on subsequent refund attempts against an order already left in this state. See the citations at the end for the exact docs and support threads.

The key insight

The order's status_id and payment_status are not proof that only one refund happened. The transaction record is. Because BigCommerce accepts the request before settlement completes, the fix is not "retry until the status looks right." It is "never let two refund requests for the same order_id be in flight at the same time," which means acquiring a lock keyed by order_id before either refund_quotes or refund is called, and reconciling total_refunded against the sum of refund transactions for anything that already happened before the lock existed.

The fix, as a flow

We do not touch the refund UI or the gateway. We add a lock in front of the refund call so a second request for the same order_id waits instead of racing, and a separate read-only scan that flags any order already corrupted so a human can reconcile it against the gateway's dashboard.

Refund request for order_id Acquire lock order_refund_lock:{id} refund_quotes then refund serialized, one at a time Release lock after response or timeout second request waits here Reconcile scan total_refunded vs transactions Flag report human reconciles, never auto-fix
Future refunds serialize on a per-order lock so requests queue instead of racing. Orders already corrupted before the lock existed are only ever flagged, never auto-repaired.

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) scope so it can read transactions, read order totals, and call the payment_actions refund endpoints. 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 REFUND_LOCK_TIMEOUT_SECONDS="30"
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="..."
export REFUND_LOCK_TIMEOUT_SECONDS="30"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the V2 and V3 Orders APIs

Order totals and the transaction list come from the V2 endpoints, GET /v2/orders/{id} and GET /v2/orders/{id}/transactions. The refund call itself is V3, POST /v3/orders/{id}/payment_actions/refund_quotes and POST /v3/orders/{id}/payment_actions/refund. A small helper handles GET and POST against either base 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"]
API_BASE_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_BASE_V3 = 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 API_BASE_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `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

Acquire a per-order lock before every refund call

Before calling refund_quotes or refund for a given order_id, acquire a lock keyed by that order_id. A Redis SETNX order_refund_lock:{order_id} with a TTL works across multiple processes. A single-process deployment can use a local mutex keyed by order_id instead. Either way, release the lock only after the refund response comes back or a timeout elapses, so a second request for the same order_id queues behind the first instead of racing it.

step3.py
import threading

_order_locks = {}
_registry_lock = threading.Lock()

def lock_for_order(order_id):
    with _registry_lock:
        if order_id not in _order_locks:
            _order_locks[order_id] = threading.Lock()
        return _order_locks[order_id]

def refund_order_serialized(order_id, refund_body, timeout_seconds=30):
    lock = lock_for_order(order_id)
    acquired = lock.acquire(timeout=timeout_seconds)
    if not acquired:
        raise TimeoutError(f"order {order_id} refund already in flight")
    try:
        quote = bc_post(API_BASE_V3, f"/orders/{order_id}/payment_actions/refund_quotes", {})
        return bc_post(API_BASE_V3, f"/orders/{order_id}/payment_actions/refund", refund_body)
    finally:
        lock.release()
step3.js
const orderLocks = new Map();

function lockForOrder(orderId) {
  if (!orderLocks.has(orderId)) orderLocks.set(orderId, Promise.resolve());
  return orderLocks;
}

async function refundOrderSerialized(orderId, refundBody) {
  const prior = orderLocks.get(orderId) || Promise.resolve();
  let release;
  const next = new Promise((resolve) => { release = resolve; });
  orderLocks.set(orderId, prior.then(() => next));
  await prior;
  try {
    await bcPost(API_BASE_V3, `/orders/${orderId}/payment_actions/refund_quotes`, {});
    return await bcPost(API_BASE_V3, `/orders/${orderId}/payment_actions/refund`, refundBody);
  } finally {
    release();
  }
}
4

Decide, with one pure function

Reconciliation is the part worth getting exactly right, because it decides whether an order is quietly fine or needs a human before anyone touches it again. Keep it as a pure function that takes the order's total_inc_tax, its reported total_refunded, and the list of refund transactions, and returns one of three outcomes. Group transactions by gateway_transaction_id, or by amount and date_created within a small window, to catch a duplicate submission. Compare the reported total_refunded against the actual sum of refund transaction amounts to catch a mismatch.

decide.py
from decimal import Decimal

MISMATCH_EPSILON = Decimal("0.01")
DUPLICATE_WINDOW_SECONDS = 1.0

def reconcile_refund_state(order_total_inc_tax, order_total_refunded, refund_transactions):
    total_refund_amount = sum((t["amount"] for t in refund_transactions), Decimal("0"))
    discrepancy = order_total_refunded - total_refund_amount

    duplicate_ids = find_duplicate_ids(refund_transactions)
    if duplicate_ids:
        return {"status": "flag_duplicate", "discrepancy": discrepancy, "duplicate_ids": duplicate_ids}

    if abs(discrepancy) > MISMATCH_EPSILON:
        return {"status": "flag_mismatch", "discrepancy": discrepancy, "duplicate_ids": []}

    return {"status": "ok", "discrepancy": discrepancy, "duplicate_ids": []}
decide.js
const MISMATCH_EPSILON = 0.01;
const DUPLICATE_WINDOW_SECONDS = 1.0;

export function reconcileRefundState(orderTotalIncTax, orderTotalRefunded, refundTransactions) {
  const totalRefundAmount = refundTransactions.reduce((sum, t) => sum + t.amount, 0);
  const discrepancy = orderTotalRefunded - totalRefundAmount;

  const duplicateIds = findDuplicateIds(refundTransactions);
  if (duplicateIds.length > 0) {
    return { status: "flag_duplicate", discrepancy, duplicateIds };
  }

  if (Math.abs(discrepancy) > MISMATCH_EPSILON) {
    return { status: "flag_mismatch", discrepancy, duplicateIds: [] };
  }

  return { status: "ok", discrepancy, duplicateIds: [] };
}
5

Read the real state before deciding anything

Call GET /v2/orders/{id} for total_inc_tax and total_refunded, and GET /v3/orders/{id}/payment_actions/transactions (the v2 /v2/orders/{id}/transactions list also works) to enumerate every transaction of type refund, capturing id, amount, gateway_transaction_id, and date_created for each. Feed all three into the pure function above.

apply.py
def order_refund_transactions(order_id):
    txns = bc_get(API_BASE_V2, f"/orders/{order_id}/transactions")
    return [t for t in txns if (t.get("type") or "").lower() == "refund"]

def order_summary(order_id):
    return bc_get(API_BASE_V2, f"/orders/{order_id}")
apply.js
async function orderRefundTransactions(orderId) {
  const txns = await bcGet(API_BASE_V2, `/orders/${orderId}/transactions`);
  return txns.filter((t) => (t.type || "").toLowerCase() === "refund");
}

async function orderSummary(orderId) {
  return bcGet(API_BASE_V2, `/orders/${orderId}`);
}
6

Wire it together with a dry run guard

The loop scans orders sitting at status_id 4 (Refunded) or 14 (Partially Refunded), pulls each one's summary and refund transactions, and runs the reconcile function. Anything that comes back ok is left alone. Anything flag_duplicate or flag_mismatch is only ever logged, with the order_id, expected total_refunded, actual sum, and discrepancy, for a human to check against the gateway's dashboard. DRY_RUN gates the refund-locking code path too, so on the first few runs nothing new is written, only logged.

Run it safe

Never write a compensating refund or credit automatically. BigCommerce has no undo-refund endpoint, and a second programmatic refund against an order that already double-refunded risks a real second charge reversal. Flag it, and let a human reconcile it against the gateway's own dashboard first.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, respects the dry run flag, serializes future refund calls per order_id, and only ever reports, never auto-repairs, orders that are already corrupted.

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

reconcile_refund_state.py
"""Serialize BigCommerce refunds per order and flag orders already corrupted by a race.

BigCommerce's refund workflow is two sequential calls per order,
POST /v3/orders/{id}/payment_actions/refund_quotes to compute the refundable
amount and eligible payment methods, then POST /v3/orders/{id}/payment_actions/refund
using that quote. Refund settlement against the gateway is asynchronous, so the
order's payment_status/status_id (Refunded=4, Partially Refunded=14) updates after
the API accepts the request, not atomically with it. BigCommerce's own docs state
that processing multiple concurrent refunds on the same order is not yet supported,
because there is no per-order idempotency lock at the API layer. When two refund
requests race for the same order_id, both can read the same pre-refund quote and
both get accepted, leaving the order mismatched.

This script does two things. First, it wraps future refund calls in a per-order
lock so a second request for the same order_id queues instead of racing. Second,
it scans orders already at status_id 4 or 14 and reconciles total_refunded against
the actual sum of refund transactions, flagging any duplicate or mismatch for a
human. It never writes a compensating refund or credit automatically, because
BigCommerce has no undo-refund endpoint and a second programmatic refund on an
already-corrupted order risks a real second charge reversal.

Guide: https://www.allanninal.dev/bigcommerce/concurrent-refunds-same-order/
"""
import os
import logging
import threading
from decimal import Decimal, InvalidOperation
from typing import TypedDict

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_BASE_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
REFUND_LOCK_TIMEOUT_SECONDS = float(os.environ.get("REFUND_LOCK_TIMEOUT_SECONDS", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

REFUNDED = 4
PARTIALLY_REFUNDED = 14
RECONCILE_STATUS_IDS = {REFUNDED, PARTIALLY_REFUNDED}

MISMATCH_EPSILON = Decimal("0.01")
DUPLICATE_WINDOW_SECONDS = 1.0

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()


class RefundTransaction(TypedDict):
    id: str
    amount: Decimal
    gateway_transaction_id: str
    date_created: str


def _parse_timestamp(date_created):
    """Best-effort parse of a date_created string to a comparable float. Returns None on failure."""
    try:
        from email.utils import parsedate_to_datetime
        dt = parsedate_to_datetime(date_created)
        return dt.timestamp() if dt else None
    except (TypeError, ValueError):
        return None


def find_duplicate_ids(refund_transactions):
    """Group transactions by gateway_transaction_id, or by amount plus a close
    date_created, and return the ids of any transaction that shares a group
    with another transaction (a likely double submission)."""
    duplicate_ids = []

    by_gateway_id = {}
    for txn in refund_transactions:
        gw_id = txn.get("gateway_transaction_id")
        if not gw_id:
            continue
        by_gateway_id.setdefault(gw_id, []).append(txn)
    for group in by_gateway_id.values():
        if len(group) > 1:
            duplicate_ids.extend(t["id"] for t in group)

    already_flagged = set(duplicate_ids)
    remaining = [t for t in refund_transactions if t["id"] not in already_flagged]
    for i, a in enumerate(remaining):
        a_ts = _parse_timestamp(a.get("date_created"))
        if a_ts is None:
            continue
        for b in remaining[i + 1:]:
            b_ts = _parse_timestamp(b.get("date_created"))
            if b_ts is None:
                continue
            same_amount = a["amount"] == b["amount"]
            close_in_time = abs(a_ts - b_ts) <= DUPLICATE_WINDOW_SECONDS
            if same_amount and close_in_time:
                duplicate_ids.extend([a["id"], b["id"]])

    return sorted(set(duplicate_ids))


def reconcile_refund_state(
    order_total_inc_tax: Decimal,
    order_total_refunded: Decimal,
    refund_transactions: list,
) -> dict:
    """Pure decision. No network, no side effects.

    Sums refund_transactions amounts, groups them by gateway_transaction_id or
    by amount plus a close date_created to detect a duplicate submission, and
    compares order_total_refunded against the sum to detect a mismatch.
    Returns one of "ok", "flag_duplicate", or "flag_mismatch".
    order_total_inc_tax is accepted for context and future use but is not
    required to make this decision.
    """
    total_refund_amount = sum((t["amount"] for t in refund_transactions), Decimal("0"))
    discrepancy = order_total_refunded - total_refund_amount

    duplicate_ids = find_duplicate_ids(refund_transactions)
    if duplicate_ids:
        return {"status": "flag_duplicate", "discrepancy": discrepancy, "duplicate_ids": duplicate_ids}

    if abs(discrepancy) > MISMATCH_EPSILON:
        return {"status": "flag_mismatch", "discrepancy": discrepancy, "duplicate_ids": []}

    return {"status": "ok", "discrepancy": discrepancy, "duplicate_ids": []}


_order_locks: dict = {}
_registry_lock = threading.Lock()


def lock_for_order(order_id):
    with _registry_lock:
        if order_id not in _order_locks:
            _order_locks[order_id] = threading.Lock()
        return _order_locks[order_id]


def refund_order_serialized(order_id, refund_body):
    """Acquire a per-order lock, then call refund_quotes and refund. Releases
    the lock after the response or the configured timeout, so a second
    concurrent call for the same order_id waits instead of racing."""
    lock = lock_for_order(order_id)
    acquired = lock.acquire(timeout=REFUND_LOCK_TIMEOUT_SECONDS)
    if not acquired:
        raise TimeoutError(f"order {order_id} refund already in flight")
    try:
        if DRY_RUN:
            log.info("DRY_RUN: would call refund_quotes and refund for order %s", order_id)
            return {"dry_run": True, "order_id": order_id}
        bc_post(API_BASE_V3, f"/orders/{order_id}/payment_actions/refund_quotes", {})
        return bc_post(API_BASE_V3, f"/orders/{order_id}/payment_actions/refund", refund_body)
    finally:
        lock.release()


def orders_to_reconcile():
    """Page through orders currently at status_id 4 (Refunded) or 14 (Partially Refunded)."""
    for status_id in RECONCILE_STATUS_IDS:
        page = 1
        while True:
            orders = bc_get(
                API_BASE_V2,
                "/orders",
                {"status_id": status_id, "page": page, "limit": 50},
            )
            if not orders:
                break
            for order in orders:
                yield order
            page += 1


def order_refund_transactions(order_id):
    txns = bc_get(API_BASE_V2, f"/orders/{order_id}/transactions")
    parsed = []
    for t in txns or []:
        if (t.get("type") or "").lower() != "refund":
            continue
        try:
            amount = Decimal(str(t.get("amount")))
        except (InvalidOperation, TypeError):
            continue
        parsed.append({
            "id": str(t.get("id")),
            "amount": amount,
            "gateway_transaction_id": t.get("gateway_transaction_id") or "",
            "date_created": t.get("date_created") or "",
        })
    return parsed


def run():
    checked = 0
    flagged = 0
    for order in orders_to_reconcile():
        checked += 1
        order_id = order["id"]
        try:
            total_inc_tax = Decimal(str(order.get("total_inc_tax") or "0"))
            total_refunded = Decimal(str(order.get("total_refunded") or order.get("refunded_amount") or "0"))
        except InvalidOperation:
            log.warning("order %s has an unparsable total, skipping", order_id)
            continue

        refund_transactions = order_refund_transactions(order_id)
        result = reconcile_refund_state(total_inc_tax, total_refunded, refund_transactions)

        if result["status"] == "ok":
            continue

        flagged += 1
        log.warning(
            "order_id=%s status=%s discrepancy=%s duplicate_ids=%s total_refunded=%s total_inc_tax=%s",
            order_id, result["status"], result["discrepancy"], result["duplicate_ids"],
            total_refunded, total_inc_tax,
        )

    log.info("Done. %d order(s) checked, %d order(s) flagged for manual reconciliation.", checked, flagged)


if __name__ == "__main__":
    run()
reconcile-refund-state.js
/**
 * Serialize BigCommerce refunds per order and flag orders already corrupted by a race.
 *
 * BigCommerce's refund workflow is two sequential calls per order,
 * POST /v3/orders/{id}/payment_actions/refund_quotes to compute the refundable
 * amount and eligible payment methods, then POST /v3/orders/{id}/payment_actions/refund
 * using that quote. Refund settlement against the gateway is asynchronous, so the
 * order's payment_status/status_id (Refunded=4, Partially Refunded=14) updates after
 * the API accepts the request, not atomically with it. BigCommerce's own docs state
 * that processing multiple concurrent refunds on the same order is not yet supported,
 * because there is no per-order idempotency lock at the API layer. When two refund
 * requests race for the same order_id, both can read the same pre-refund quote and
 * both get accepted, leaving the order mismatched.
 *
 * This script does two things. First, it wraps future refund calls in a per-order
 * lock (a promise chain keyed by order_id) so a second request for the same
 * order_id queues instead of racing. Second, it scans orders already at status_id
 * 4 or 14 and reconciles total_refunded against the actual sum of refund
 * transactions, flagging any duplicate or mismatch for a human. It never writes a
 * compensating refund or credit automatically, because BigCommerce has no
 * undo-refund endpoint and a second programmatic refund on an already-corrupted
 * order risks a real second charge reversal.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/concurrent-refunds-same-order/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const REFUNDED = 4;
const PARTIALLY_REFUNDED = 14;
const RECONCILE_STATUS_IDS = [REFUNDED, PARTIALLY_REFUNDED];

const MISMATCH_EPSILON = 0.01;
const DUPLICATE_WINDOW_SECONDS = 1.0;

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

function parseTimestamp(dateCreated) {
  const ms = Date.parse(dateCreated);
  return Number.isNaN(ms) ? null : ms / 1000;
}

/**
 * Group transactions by gateway_transaction_id, or by amount plus a close
 * date_created, and return the ids of any transaction that shares a group
 * with another transaction (a likely double submission).
 */
export function findDuplicateIds(refundTransactions) {
  const duplicateIds = new Set();

  const byGatewayId = new Map();
  for (const txn of refundTransactions) {
    const gwId = txn.gateway_transaction_id;
    if (!gwId) continue;
    if (!byGatewayId.has(gwId)) byGatewayId.set(gwId, []);
    byGatewayId.get(gwId).push(txn);
  }
  for (const group of byGatewayId.values()) {
    if (group.length > 1) group.forEach((t) => duplicateIds.add(t.id));
  }

  const remaining = refundTransactions.filter((t) => !duplicateIds.has(t.id));
  for (let i = 0; i < remaining.length; i += 1) {
    const a = remaining[i];
    const aTs = parseTimestamp(a.date_created);
    if (aTs === null) continue;
    for (let j = i + 1; j < remaining.length; j += 1) {
      const b = remaining[j];
      const bTs = parseTimestamp(b.date_created);
      if (bTs === null) continue;
      const sameAmount = a.amount === b.amount;
      const closeInTime = Math.abs(aTs - bTs) <= DUPLICATE_WINDOW_SECONDS;
      if (sameAmount && closeInTime) {
        duplicateIds.add(a.id);
        duplicateIds.add(b.id);
      }
    }
  }

  return [...duplicateIds].sort();
}

/**
 * Pure decision. No network, no side effects.
 *
 * Sums refundTransactions amounts, groups them by gateway_transaction_id or
 * by amount plus a close date_created to detect a duplicate submission, and
 * compares orderTotalRefunded against the sum to detect a mismatch.
 * Returns one of "ok", "flag_duplicate", or "flag_mismatch".
 * orderTotalIncTax is accepted for context and future use but is not
 * required to make this decision.
 */
export function reconcileRefundState(orderTotalIncTax, orderTotalRefunded, refundTransactions) {
  const totalRefundAmount = refundTransactions.reduce((sum, t) => sum + t.amount, 0);
  const discrepancy = orderTotalRefunded - totalRefundAmount;

  const duplicateIds = findDuplicateIds(refundTransactions);
  if (duplicateIds.length > 0) {
    return { status: "flag_duplicate", discrepancy, duplicateIds };
  }

  if (Math.abs(discrepancy) > MISMATCH_EPSILON) {
    return { status: "flag_mismatch", discrepancy, duplicateIds: [] };
  }

  return { status: "ok", discrepancy, duplicateIds: [] };
}

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) : {};
}

const orderLocks = new Map();

/**
 * Acquire a per-order lock (a promise chain keyed by order_id), then call
 * refund_quotes and refund. The lock is released after the response, so a
 * second concurrent call for the same order_id waits instead of racing.
 */
export async function refundOrderSerialized(orderId, refundBody) {
  const prior = orderLocks.get(orderId) || Promise.resolve();
  let release;
  const next = new Promise((resolve) => { release = resolve; });
  orderLocks.set(orderId, prior.then(() => next));
  await prior;
  try {
    if (DRY_RUN) {
      console.log(`DRY_RUN: would call refund_quotes and refund for order ${orderId}`);
      return { dryRun: true, orderId };
    }
    await bcPost(API_BASE_V3, `/orders/${orderId}/payment_actions/refund_quotes`, {});
    return await bcPost(API_BASE_V3, `/orders/${orderId}/payment_actions/refund`, refundBody);
  } finally {
    release();
  }
}

async function* ordersToReconcile() {
  for (const statusId of RECONCILE_STATUS_IDS) {
    let page = 1;
    while (true) {
      const orders = await bcGet(API_BASE_V2, "/orders", { status_id: statusId, page, limit: 50 });
      if (!orders.length) break;
      for (const order of orders) yield order;
      page += 1;
    }
  }
}

async function orderRefundTransactions(orderId) {
  const txns = await bcGet(API_BASE_V2, `/orders/${orderId}/transactions`);
  const parsed = [];
  for (const t of txns || []) {
    if ((t.type || "").toLowerCase() !== "refund") continue;
    const amount = Number.parseFloat(t.amount);
    if (!Number.isFinite(amount)) continue;
    parsed.push({
      id: String(t.id),
      amount,
      gateway_transaction_id: t.gateway_transaction_id || "",
      date_created: t.date_created || "",
    });
  }
  return parsed;
}

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

  for await (const order of ordersToReconcile()) {
    checked += 1;
    const orderId = order.id;
    const totalIncTax = Number.parseFloat(order.total_inc_tax || "0");
    const totalRefunded = Number.parseFloat(order.total_refunded ?? order.refunded_amount ?? "0");
    if (!Number.isFinite(totalIncTax) || !Number.isFinite(totalRefunded)) {
      console.warn(`order ${orderId} has an unparsable total, skipping`);
      continue;
    }

    const refundTransactions = await orderRefundTransactions(orderId);
    const result = reconcileRefundState(totalIncTax, totalRefunded, refundTransactions);

    if (result.status === "ok") continue;

    flagged += 1;
    console.warn(
      `order_id=${orderId} status=${result.status} discrepancy=${result.discrepancy} ` +
      `duplicate_ids=${JSON.stringify(result.duplicateIds)} total_refunded=${totalRefunded} total_inc_tax=${totalIncTax}`
    );
  }

  console.log(`Done. ${checked} order(s) checked, ${flagged} order(s) flagged for manual reconciliation.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The reconcile function is the part most worth testing, because it decides whether an order is quietly fine or gets flagged for a human. Because reconcile_refund_state takes only plain values and returns a plain object, the test needs no network and no BigCommerce store. It just feeds in synthetic transaction lists and checks the answer.

test_concurrent_refund_reconcile.py
from decimal import Decimal

from reconcile_refund_state import reconcile_refund_state


def refund_txn(id_="1", amount="25.00", gateway_transaction_id="gw_1", date_created="Wed, 01 Jul 2026 10:00:00 +0000"):
    return {
        "id": id_,
        "amount": Decimal(amount),
        "gateway_transaction_id": gateway_transaction_id,
        "date_created": date_created,
    }


def test_ok_when_totals_match_and_no_duplicates():
    txns = [refund_txn(id_="1", amount="25.00", gateway_transaction_id="gw_1")]
    result = reconcile_refund_state(Decimal("100.00"), Decimal("25.00"), txns)
    assert result["status"] == "ok"
    assert result["discrepancy"] == Decimal("0.00")
    assert result["duplicate_ids"] == []


def test_flag_duplicate_when_same_gateway_transaction_id_appears_twice():
    txns = [
        refund_txn(id_="1", amount="25.00", gateway_transaction_id="gw_1"),
        refund_txn(id_="2", amount="25.00", gateway_transaction_id="gw_1"),
    ]
    result = reconcile_refund_state(Decimal("100.00"), Decimal("50.00"), txns)
    assert result["status"] == "flag_duplicate"
    assert set(result["duplicate_ids"]) == {"1", "2"}


def test_flag_duplicate_when_same_amount_and_overlapping_timestamp():
    txns = [
        refund_txn(id_="1", amount="25.00", gateway_transaction_id="gw_1", date_created="Wed, 01 Jul 2026 10:00:00 +0000"),
        refund_txn(id_="2", amount="25.00", gateway_transaction_id="gw_2", date_created="Wed, 01 Jul 2026 10:00:00 +0000"),
    ]
    result = reconcile_refund_state(Decimal("100.00"), Decimal("50.00"), txns)
    assert result["status"] == "flag_duplicate"
    assert set(result["duplicate_ids"]) == {"1", "2"}


def test_flag_mismatch_when_total_refunded_does_not_match_transaction_sum():
    txns = [refund_txn(id_="1", amount="25.00", gateway_transaction_id="gw_1")]
    result = reconcile_refund_state(Decimal("100.00"), Decimal("40.00"), txns)
    assert result["status"] == "flag_mismatch"
    assert result["discrepancy"] == Decimal("15.00")


def test_ok_when_two_distinct_partial_refunds_sum_correctly():
    txns = [
        refund_txn(id_="1", amount="20.00", gateway_transaction_id="gw_1", date_created="Wed, 01 Jul 2026 09:00:00 +0000"),
        refund_txn(id_="2", amount="30.00", gateway_transaction_id="gw_2", date_created="Wed, 01 Jul 2026 11:00:00 +0000"),
    ]
    result = reconcile_refund_state(Decimal("100.00"), Decimal("50.00"), txns)
    assert result["status"] == "ok"
    assert result["duplicate_ids"] == []
reconcile-refund-state.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { reconcileRefundState } from "./reconcile-refund-state.js";

const refundTxn = ({
  id = "1", amount = 25.0, gateway_transaction_id = "gw_1",
  date_created = "2026-07-01T10:00:00Z",
} = {}) => ({ id, amount, gateway_transaction_id, date_created });

test("ok when totals match and no duplicates", () => {
  const txns = [refundTxn({ id: "1", amount: 25.0, gateway_transaction_id: "gw_1" })];
  const result = reconcileRefundState(100.0, 25.0, txns);
  assert.equal(result.status, "ok");
  assert.equal(result.discrepancy, 0);
  assert.deepEqual(result.duplicateIds, []);
});

test("flag_duplicate when same gateway_transaction_id appears twice", () => {
  const txns = [
    refundTxn({ id: "1", amount: 25.0, gateway_transaction_id: "gw_1" }),
    refundTxn({ id: "2", amount: 25.0, gateway_transaction_id: "gw_1" }),
  ];
  const result = reconcileRefundState(100.0, 50.0, txns);
  assert.equal(result.status, "flag_duplicate");
  assert.deepEqual(result.duplicateIds.sort(), ["1", "2"]);
});

test("flag_duplicate when same amount and overlapping timestamp", () => {
  const txns = [
    refundTxn({ id: "1", amount: 25.0, gateway_transaction_id: "gw_1", date_created: "2026-07-01T10:00:00Z" }),
    refundTxn({ id: "2", amount: 25.0, gateway_transaction_id: "gw_2", date_created: "2026-07-01T10:00:00Z" }),
  ];
  const result = reconcileRefundState(100.0, 50.0, txns);
  assert.equal(result.status, "flag_duplicate");
  assert.deepEqual(result.duplicateIds.sort(), ["1", "2"]);
});

test("flag_mismatch when total_refunded does not match transaction sum", () => {
  const txns = [refundTxn({ id: "1", amount: 25.0, gateway_transaction_id: "gw_1" })];
  const result = reconcileRefundState(100.0, 40.0, txns);
  assert.equal(result.status, "flag_mismatch");
  assert.equal(result.discrepancy, 15.0);
});

test("ok when two distinct partial refunds sum correctly", () => {
  const txns = [
    refundTxn({ id: "1", amount: 20.0, gateway_transaction_id: "gw_1", date_created: "2026-07-01T09:00:00Z" }),
    refundTxn({ id: "2", amount: 30.0, gateway_transaction_id: "gw_2", date_created: "2026-07-01T11:00:00Z" }),
  ];
  const result = reconcileRefundState(100.0, 50.0, txns);
  assert.equal(result.status, "ok");
  assert.deepEqual(result.duplicateIds, []);
});

Case studies

Double-click in the admin

The store where a slow admin page trained agents to click twice

A mid-size store's admin refund button took a couple of seconds to respond on a slow connection, and support agents got into the habit of clicking Refund a second time when nothing seemed to happen. Most of the time the first click had already gone through, and the second click landed on the same order_id while the first refund was still settling.

The result was a slow drip of orders stuck at Partially Refunded with a total_refunded that did not match a single transaction. Once refunds were serialized behind a per-order lock, the second click either waited for the first to finish and saw the order already refunded, or failed fast with a clear error, instead of quietly duplicating the refund.

Automation plus manual override

The store running a Zapier refund automation alongside manual support

A store used a Zapier workflow to auto-refund orders matching a return-approved tag, while support staff also processed refunds manually for edge cases. On days with a backlog, the automation and a support agent occasionally picked up the same order within seconds of each other, both unaware of the other's in-flight request.

The reconcile scan caught the resulting orders by comparing total_refunded against the sum of refund transactions and by grouping transactions with the same gateway_transaction_id. Every flagged order was a genuine double-submission, confirmed against the gateway dashboard, and none needed an automatic write, only a manual credit adjustment where the gateway confirmed a real overpayment refund.

What good looks like

After the lock is in place, a second refund request for the same order_id waits behind the first instead of reading the same stale quote, so payment_status only ever reflects one settled refund action at a time. The reconcile scan runs separately and only ever produces a report, order_id, expected total_refunded, actual sum of transactions, discrepancy, for a human to check against the gateway dashboard. Nothing gets auto-corrected, because BigCommerce has no undo-refund endpoint and a wrong guess here is real money.

FAQ

Why does BigCommerce let two refund requests corrupt the same order?

BigCommerce's refund workflow is two sequential calls per order, a refund_quotes call to compute the refundable amount and then a refund call that spends that quote, and refund settlement against the gateway is asynchronous. BigCommerce's own documentation states that processing multiple concurrent refunds on the same order is not yet supported, because there is no per-order idempotency lock at the API layer. When two refund requests race, both can read the same pre-refund quote and both get accepted, leaving payment_status mismatched against the real sum of refund transactions.

Can I safely auto-fix an order once I detect a corrupted refund state?

No. Refunds that were double-submitted to the gateway may already be irreversible money movement, so the safe action is to flag and report the discrepancy, not to write a blind correction. BigCommerce has no undo-refund endpoint, and issuing another programmatic refund on a corrupted order risks a second real charge reversal. A human should reconcile the flagged order against the payment gateway's own dashboard before any compensating action is taken.

How do I stop this from happening to new refunds going forward?

Serialize refunds per order_id with a lock, for example a Redis SETNX keyed as order_refund_lock:{order_id} or an in-process mutex keyed by order_id, acquired before calling refund_quotes and refund, and released only after the refund response or a timeout. That forces a second concurrent request for the same order to wait or fail fast instead of racing against the first one for the same quote.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: Order Refunds, including the note that concurrent refunds on the same order are not yet supported. developer.bigcommerce.com order refunds
  2. BigCommerce Support Community: "The requested refund had invalid split payment" error when refunding via the API. support.bigcommerce.com invalid split payment
  3. BigCommerce Support Community: API endpoints to retrieve refund information on an order. support.bigcommerce.com refund information endpoints

On the solution:

  1. BigCommerce API Reference: Create Refund, POST /v3/orders/{id}/payment_actions/refund. docs.bigcommerce.com create refund
  2. BigCommerce API Reference: Create Refund Quote, POST /v3/orders/{id}/payment_actions/refund_quotes. docs.bigcommerce.com create refund quote
  3. BigCommerce Developer Center: the Transactions API for enumerating refund transactions on an order. developer.bigcommerce.com transactions API

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 untangle a corrupted refund?

If this saved you from a messy reconciliation or stopped the next race before it happened, 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