Diagnostic Tax, currency, and reporting

Test orders counted in sales reports

Someone on the team placed a checkout to test a new tax rule, a shipping zone, or a promotion, using the Test Payment Gateway or a real gateway left in sandbox mode. The order that resulted looks completely normal to BigCommerce. It has a revenue-counted status_id, it shows up in Store Overview, and it inflates the Sales report until someone notices the numbers do not match the bank. Here is why BigCommerce cannot tell a test checkout from a real one on its own, and a small script that finds the likely test orders and flags them without touching revenue.

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

BigCommerce order objects, in both /v2/orders and V3, have no native is_test field. Every store ships with a Test Payment Gateway enabled by default so staff can place real checkouts while validating tax, shipping, and promotions, and merchants often leave real gateways like a sandboxed processor active during setup too. Those checkouts create fully formed orders with normal, revenue-counted status_id values such as 10 Completed or 11 Awaiting Fulfillment, and BigCommerce's reports simply aggregate orders by status_id, so the test money flows straight into your totals. The only place the "test" signal lives is on the nested transaction record, so a script has to call GET /v2/orders/{id}/transactions for each order and inspect test and gateway, cross-checked against the billing email and a guest checkout with a nominal total. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce order records were built around one question: did the shopper pay, and what should the store do next. Nothing in the order schema was designed to answer a second question, was this a real customer or a staff member testing the store. So when a staff member opens a fresh checkout, picks the Test Payment Gateway (or a real gateway that is still in sandbox mode), and completes it to confirm tax or shipping math works, BigCommerce writes an order that looks exactly like a paying customer's order.

That order gets a normal status_id. It gets a normal subtotal and total. And because Store Overview and the Sales reports just group orders by status_id and sum their totals, the test checkout is counted as revenue and as an order, right alongside real sales, until a human spots something odd in the numbers.

Staff test checkout Test Payment Gateway Order written no is_test field Normal status_id 10 Completed, etc. no test signal on order Sales report aggregates by status_id Counted as real revenue
The test signal only lives on the nested transaction, never on the order itself, so a report that reads status_id cannot filter it out.

Why it happens

Every store owner has a legitimate reason to place a checkout that is not a real sale. A few common ways stores end up with test orders sitting in revenue:

Because the order resource never carries a test flag, the only place BigCommerce records what actually happened is the nested transaction, where test: true or a gateway name of Test Payment Gateway tells the real story. Reports that only read the order's status_id never see that. See the citations at the end for the exact BigCommerce Help Center pages that describe test orders and the analytics reports.

The key insight

A test order is not a broken order. Its status_id is entirely normal and BigCommerce is doing exactly what it was built to do, which is count orders by status_id. The mismatch is that revenue reporting and QA checkouts share one data model with no separator between them. The fix is never to auto-cancel or auto-delete, since a false positive there would hide a real sale. The fix is to flag, using signals that only exist on the transaction record and the billing details, and let a human decide what happens next.

The fix, as a flow

We do not touch checkout, the gateway, or the reports themselves. We add a job that lists orders in a reporting window whose status_id already counts as revenue, pulls each order's transactions, and runs a strict, testable decision. Anything that looks like a test checkout gets a non-destructive marker appended to the order's internal staff_notes field, which merchants can already see and filter on in the admin, and which is never shown to the customer. Nothing gets cancelled or deleted automatically.

Scheduled job runs on a timer List orders in window revenue counted status_id Read transactions test, gateway, billing Test signal found? yes no, leave it alone Append to staff_notes
The script only ever writes a marker to the internal staff_notes field. Cancelling or deleting an order stays a separate, human-confirmed step.

Build it step by step

1

Get a store hash and an access token

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

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="your_store_hash"
export BIGCOMMERCE_ACCESS_TOKEN="your_access_token"
export LOOKBACK_DAYS="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="your_store_hash"
export BIGCOMMERCE_ACCESS_TOKEN="your_access_token"
export LOOKBACK_DAYS="30"
export DRY_RUN="true"   // start safe, change to false to write
2

List the revenue-counted orders in the reporting window

Call GET /v2/orders with min_date_created and max_date_created set to the reporting window, paging with limit and page. Skip status_id 0 (Incomplete), 5 (Cancelled), and 6 (Declined), since reports already exclude those. Everything else, 2 Shipped, 3 Partially Shipped, 8 Awaiting Pickup, 9 Awaiting Shipment, 10 Completed, 11 Awaiting Fulfillment, and 14 Partially Refunded, is a candidate.

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

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

NON_REVENUE_STATUS_IDS = {0, 5, 6}  # Incomplete, Cancelled, Declined

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

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

const NON_REVENUE_STATUS_IDS = new Set([0, 5, 6]); // Incomplete, Cancelled, Declined

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

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

Pull each order's transactions

For every candidate, call GET /v2/orders/{id}/transactions. Each entry can carry a test boolean and a gateway name. A transaction with test: true or a gateway named Test Payment Gateway is the clearest signal that a checkout was staff testing, not a customer sale, since neither field exists anywhere on the order resource itself.

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

Decide, with one pure function

Keep the decision in its own function that takes the order and its transactions and returns { isTest, reasons }. It checks four independent signals: a transaction flagged test: true, a transaction whose gateway name matches Test Payment Gateway, a billing email that matches a known test pattern, and a guest checkout (customer_id === 0) with a nominal total of one dollar or less. A non-revenue status_id is recorded as a reason too, but on its own it never marks an order as a test, since reports already exclude those statuses.

decide.py
import re

TEST_EMAIL_PATTERNS = [
    re.compile(r"test@", re.IGNORECASE),
    re.compile(r"@example\.com$", re.IGNORECASE),
    re.compile(r"^qa[-_.]?", re.IGNORECASE),
]
NON_REVENUE_STATUS_IDS = {0, 5, 6}  # Incomplete, Cancelled, Declined

def classify_test_order(order, transactions, test_email_patterns=None):
    patterns = test_email_patterns if test_email_patterns is not None else TEST_EMAIL_PATTERNS
    reasons = []

    if any(t.get("test") is True for t in transactions):
        reasons.append("test_gateway_transaction")

    if any(re.search(r"test payment gateway", t.get("gateway") or "", re.IGNORECASE) for t in transactions):
        reasons.append("test_gateway_name")

    email = (order.get("billing_address") or {}).get("email") or ""
    if any(rx.search(email) for rx in patterns):
        reasons.append("test_email_pattern")

    if order.get("customer_id") == 0 and float(order.get("total_inc_tax", 0) or 0) <= 1.00:
        reasons.append("nominal_staff_test_amount")

    if order.get("status_id") in NON_REVENUE_STATUS_IDS:
        reasons.append("non_revenue_status")

    is_test = len(reasons) > 0 and any(r != "non_revenue_status" for r in reasons)
    return {"isTest": is_test, "reasons": reasons}
decide.js
const DEFAULT_TEST_EMAIL_PATTERNS = [/test@/i, /@example\.com$/i, /^qa[-_.]?/i];
const NON_REVENUE_STATUS_IDS = new Set([0, 5, 6]); // Incomplete, Cancelled, Declined

export function classifyTestOrder(order, transactions, testEmailPatterns = DEFAULT_TEST_EMAIL_PATTERNS) {
  const reasons = [];

  if (transactions.some((t) => t.test === true)) reasons.push("test_gateway_transaction");

  if (transactions.some((t) => /test payment gateway/i.test(t.gateway || ""))) {
    reasons.push("test_gateway_name");
  }

  const email = order.billing_address?.email || "";
  if (testEmailPatterns.some((rx) => rx.test(email))) reasons.push("test_email_pattern");

  if (order.customer_id === 0 && parseFloat(order.total_inc_tax || 0) <= 1.00) {
    reasons.push("nominal_staff_test_amount");
  }

  if (NON_REVENUE_STATUS_IDS.has(order.status_id)) reasons.push("non_revenue_status");

  const isTest = reasons.length > 0 && reasons.some((r) => r !== "non_revenue_status");
  return { isTest, reasons };
}
5

Flag the order without touching revenue

When an order classifies as a test order, call PUT /v2/orders/{id} with the reasons prepended to staff_notes, an internal-only field the customer never sees. Nothing about the status_id, the total, or the customer record changes, so the order stays exactly as revenue-counted or not as it already was. Cancelling the order is a separate, explicitly human step, never something this script does on its own.

apply.py
def flag_as_test_order(order, reasons):
    marker = f"[TEST ORDER: {', '.join(reasons)}] "
    existing_notes = order.get("staff_notes") or ""
    if existing_notes.startswith("[TEST ORDER:"):
        return None  # already flagged, do not stack markers
    r = requests.put(
        BASE_URL + f"v2/orders/{order['id']}",
        headers={**headers(), "Content-Type": "application/json"},
        json={"staff_notes": marker + existing_notes},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
apply.js
async function flagAsTestOrder(order, reasons) {
  const marker = `[TEST ORDER: ${reasons.join(", ")}] `;
  const existingNotes = order.staff_notes || "";
  if (existingNotes.startsWith("[TEST ORDER:")) return null; // already flagged
  const res = await fetch(BASE_URL + `v2/orders/${order.id}`, {
    method: "PUT",
    headers: { ...headers(), "Content-Type": "application/json" },
    body: JSON.stringify({ staff_notes: marker + existingNotes }),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs the {id, reasons} pairs it would flag. Read the output, agree with it, then switch it off to let it write the marker. Run it on whatever cadence matches your reporting cycle, for example once a day before month-end close.

Run it safe

Always start with DRY_RUN=true. This script only ever appends to staff_notes, an internal field never shown to the customer. It never cancels or deletes an order automatically, because a false positive would hide real revenue. If you want to cancel confirmed pure QA orders, do it by hand with PUT /v2/orders/{id} {"status_id": 5}, order by order, since a status change carries inventory and notification side effects that deserve a human look.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever writes a marker to staff_notes and skips orders already flagged.

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

flag_test_orders.py
"""Find BigCommerce orders that look like staff test checkouts counted as revenue.

BigCommerce order objects, in both /v2/orders and V3, have no is_test flag. Every
store ships with a Test Payment Gateway enabled by default so staff can validate
tax, shipping, and promotion configuration by placing real checkouts, and merchants
often leave real gateways in sandbox mode during setup too. Those checkouts create
fully formed orders with normal, revenue-counted status_id values, and Store Overview
and Sales reports simply aggregate by status_id, so the test order counts as revenue.

This job lists revenue-counted orders in a reporting window, pulls each order's
transactions, and classifies it with a pure function against four signals: a test
transaction flag, a Test Payment Gateway name, a test-looking billing email, and a
nominal guest checkout total. Anything that classifies as a test order gets a
non-destructive marker appended to the internal staff_notes field. Nothing is ever
cancelled or deleted automatically. Guarded by DRY_RUN. Safe to run again and again.
"""
import os
import re
import logging
from datetime import datetime, timedelta, timezone

import requests

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

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

NON_REVENUE_STATUS_IDS = {0, 5, 6}  # Incomplete, Cancelled, Declined

TEST_EMAIL_PATTERNS = [
    re.compile(r"test@", re.IGNORECASE),
    re.compile(r"@example\.com$", re.IGNORECASE),
    re.compile(r"^qa[-_.]?", re.IGNORECASE),
]


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


def classify_test_order(order, transactions, test_email_patterns=None):
    """Pure decision function. No network calls.

    Flags an order as a likely test order when any independent signal points to a
    staff or QA checkout: a transaction marked test true, a transaction whose gateway
    name matches Test Payment Gateway, a billing email matching a known test pattern,
    or a guest checkout with a nominal total of one dollar or less. A non-revenue
    status_id (Incomplete, Cancelled, Declined) is recorded as a reason for visibility
    but never on its own marks the order as a test, since reports already exclude it.
    """
    patterns = test_email_patterns if test_email_patterns is not None else TEST_EMAIL_PATTERNS
    reasons = []

    if any(t.get("test") is True for t in transactions):
        reasons.append("test_gateway_transaction")

    if any(re.search(r"test payment gateway", t.get("gateway") or "", re.IGNORECASE) for t in transactions):
        reasons.append("test_gateway_name")

    email = (order.get("billing_address") or {}).get("email") or ""
    if any(rx.search(email) for rx in patterns):
        reasons.append("test_email_pattern")

    if order.get("customer_id") == 0 and float(order.get("total_inc_tax", 0) or 0) <= 1.00:
        reasons.append("nominal_staff_test_amount")

    if order.get("status_id") in NON_REVENUE_STATUS_IDS:
        reasons.append("non_revenue_status")

    is_test = len(reasons) > 0 and any(r != "non_revenue_status" for r in reasons)
    return {"isTest": is_test, "reasons": reasons}


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


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


def flag_as_test_order(order, reasons):
    marker = f"[TEST ORDER: {', '.join(reasons)}] "
    existing_notes = order.get("staff_notes") or ""
    if existing_notes.startswith("[TEST ORDER:"):
        return None  # already flagged, do not stack markers
    r = requests.put(
        BASE_URL + f"v2/orders/{order['id']}",
        headers=_headers(),
        json={"staff_notes": marker + existing_notes},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    flagged = 0
    for order in list_revenue_orders():
        order_id = order["id"]
        transactions = get_transactions(order_id)
        result = classify_test_order(order, transactions)

        if not result["isTest"]:
            continue

        existing_notes = order.get("staff_notes") or ""
        if existing_notes.startswith("[TEST ORDER:"):
            continue

        log.info(
            "Order %s looks like a test order (%s). %s",
            order_id,
            ", ".join(result["reasons"]),
            "would flag" if DRY_RUN else "flagging",
        )
        if not DRY_RUN:
            flag_as_test_order(order, result["reasons"])
        flagged += 1

    log.info("Done. %d order(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")


if __name__ == "__main__":
    run()
flag-test-orders.js
/**
 * Find BigCommerce orders that look like staff test checkouts counted as revenue.
 *
 * BigCommerce order objects, in both /v2/orders and V3, have no is_test flag. Every
 * store ships with a Test Payment Gateway enabled by default so staff can validate
 * tax, shipping, and promotion configuration by placing real checkouts, and merchants
 * often leave real gateways in sandbox mode during setup too. Those checkouts create
 * fully formed orders with normal, revenue-counted status_id values, and Store Overview
 * and Sales reports simply aggregate by status_id, so the test order counts as revenue.
 *
 * This job lists revenue-counted orders in a reporting window, pulls each order's
 * transactions, and classifies it with a pure function against four signals: a test
 * transaction flag, a Test Payment Gateway name, a test-looking billing email, and a
 * nominal guest checkout total. Anything that classifies as a test order gets a
 * non-destructive marker appended to the internal staff_notes field. Nothing is ever
 * cancelled or deleted automatically. Guarded by DRY_RUN. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/test-orders-counted-in-sales-reports/
 */
import { pathToFileURL } from "node:url";

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

const NON_REVENUE_STATUS_IDS = new Set([0, 5, 6]); // Incomplete, Cancelled, Declined

const DEFAULT_TEST_EMAIL_PATTERNS = [/test@/i, /@example\.com$/i, /^qa[-_.]?/i];

/**
 * Pure decision function. No network calls.
 *
 * Flags an order as a likely test order when any independent signal points to a
 * staff or QA checkout: a transaction marked test true, a transaction whose gateway
 * name matches Test Payment Gateway, a billing email matching a known test pattern,
 * or a guest checkout with a nominal total of one dollar or less. A non-revenue
 * status_id (Incomplete, Cancelled, Declined) is recorded as a reason for visibility
 * but never on its own marks the order as a test, since reports already exclude it.
 */
export function classifyTestOrder(order, transactions, testEmailPatterns = DEFAULT_TEST_EMAIL_PATTERNS) {
  const reasons = [];

  if (transactions.some((t) => t.test === true)) reasons.push("test_gateway_transaction");

  if (transactions.some((t) => /test payment gateway/i.test(t.gateway || ""))) {
    reasons.push("test_gateway_name");
  }

  const email = order.billing_address?.email || "";
  if (testEmailPatterns.some((rx) => rx.test(email))) reasons.push("test_email_pattern");

  if (order.customer_id === 0 && parseFloat(order.total_inc_tax || 0) <= 1.00) {
    reasons.push("nominal_staff_test_amount");
  }

  if (NON_REVENUE_STATUS_IDS.has(order.status_id)) reasons.push("non_revenue_status");

  const isTest = reasons.length > 0 && reasons.some((r) => r !== "non_revenue_status");
  return { isTest, reasons };
}

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

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

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

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

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

async function flagAsTestOrder(order, reasons) {
  const marker = `[TEST ORDER: ${reasons.join(", ")}] `;
  const existingNotes = order.staff_notes || "";
  if (existingNotes.startsWith("[TEST ORDER:")) return null; // already flagged
  const res = await fetch(BASE_URL + `v2/orders/${order.id}`, {
    method: "PUT",
    headers: headers(),
    body: JSON.stringify({ staff_notes: marker + existingNotes }),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

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

  for await (const order of listRevenueOrders()) {
    const orderId = order.id;
    const transactions = await getTransactions(orderId);
    const result = classifyTestOrder(order, transactions);

    if (!result.isTest) continue;

    const existingNotes = order.staff_notes || "";
    if (existingNotes.startsWith("[TEST ORDER:")) continue;

    console.log(
      `Order ${orderId} looks like a test order (${result.reasons.join(", ")}). ${DRY_RUN ? "would flag" : "flagging"}`
    );
    if (!DRY_RUN) await flagAsTestOrder(order, result.reasons);
    flagged++;
  }

  console.log(`Done. ${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides whether an order gets flagged as a test and whether it ever gets written to. Because we kept classify_test_order pure, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_orders_classify.py
from flag_test_orders import classify_test_order


def order(**over):
    base = {
        "status_id": 10,
        "customer_id": 42,
        "total_inc_tax": "89.00",
        "billing_address": {"email": "shopper@realcustomer.com"},
    }
    base.update(over)
    return base


def txn(**over):
    base = {"test": False, "gateway": "Authorize.net"}
    base.update(over)
    return base


def test_not_test_order_for_ordinary_paid_order():
    result = classify_test_order(order(), [txn()])
    assert result["isTest"] is False
    assert result["reasons"] == []


def test_flagged_when_transaction_test_flag_is_true():
    result = classify_test_order(order(), [txn(test=True)])
    assert result["isTest"] is True
    assert "test_gateway_transaction" in result["reasons"]


def test_flagged_when_gateway_name_is_test_payment_gateway():
    result = classify_test_order(order(), [txn(gateway="Test Payment Gateway")])
    assert result["isTest"] is True
    assert "test_gateway_name" in result["reasons"]


def test_flagged_when_billing_email_matches_test_pattern():
    o = order(billing_address={"email": "qa-checkout@company.com"})
    result = classify_test_order(o, [txn()])
    assert result["isTest"] is True
    assert "test_email_pattern" in result["reasons"]


def test_flagged_when_guest_checkout_with_nominal_total():
    o = order(customer_id=0, total_inc_tax="0.50")
    result = classify_test_order(o, [txn()])
    assert result["isTest"] is True
    assert "nominal_staff_test_amount" in result["reasons"]


def test_guest_checkout_with_real_total_is_not_flagged_alone():
    o = order(customer_id=0, total_inc_tax="89.00")
    result = classify_test_order(o, [txn()])
    assert result["isTest"] is False


def test_non_revenue_status_alone_does_not_mark_as_test():
    o = order(status_id=5)  # Cancelled
    result = classify_test_order(o, [txn()])
    assert result["isTest"] is False
    assert "non_revenue_status" in result["reasons"]


def test_non_revenue_status_combined_with_test_signal_still_flags():
    o = order(status_id=0)  # Incomplete
    result = classify_test_order(o, [txn(test=True)])
    assert result["isTest"] is True
    assert "non_revenue_status" in result["reasons"]
    assert "test_gateway_transaction" in result["reasons"]
flag-test-orders.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyTestOrder } from "./flag-test-orders.js";

const order = (over = {}) => ({
  status_id: 10,
  customer_id: 42,
  total_inc_tax: "89.00",
  billing_address: { email: "shopper@realcustomer.com" },
  ...over,
});

const txn = (over = {}) => ({ test: false, gateway: "Authorize.net", ...over });

test("not a test order for an ordinary paid order", () => {
  const result = classifyTestOrder(order(), [txn()]);
  assert.equal(result.isTest, false);
  assert.deepEqual(result.reasons, []);
});

test("flagged when transaction test flag is true", () => {
  const result = classifyTestOrder(order(), [txn({ test: true })]);
  assert.equal(result.isTest, true);
  assert.ok(result.reasons.includes("test_gateway_transaction"));
});

test("flagged when gateway name is Test Payment Gateway", () => {
  const result = classifyTestOrder(order(), [txn({ gateway: "Test Payment Gateway" })]);
  assert.equal(result.isTest, true);
  assert.ok(result.reasons.includes("test_gateway_name"));
});

test("flagged when billing email matches a test pattern", () => {
  const o = order({ billing_address: { email: "qa-checkout@company.com" } });
  const result = classifyTestOrder(o, [txn()]);
  assert.equal(result.isTest, true);
  assert.ok(result.reasons.includes("test_email_pattern"));
});

test("flagged when guest checkout carries a nominal total", () => {
  const o = order({ customer_id: 0, total_inc_tax: "0.50" });
  const result = classifyTestOrder(o, [txn()]);
  assert.equal(result.isTest, true);
  assert.ok(result.reasons.includes("nominal_staff_test_amount"));
});

test("guest checkout with a real total is not flagged alone", () => {
  const o = order({ customer_id: 0, total_inc_tax: "89.00" });
  const result = classifyTestOrder(o, [txn()]);
  assert.equal(result.isTest, false);
});

test("non revenue status alone does not mark as test", () => {
  const o = order({ status_id: 5 }); // Cancelled
  const result = classifyTestOrder(o, [txn()]);
  assert.equal(result.isTest, false);
  assert.ok(result.reasons.includes("non_revenue_status"));
});

test("non revenue status combined with a test signal still flags", () => {
  const o = order({ status_id: 0 }); // Incomplete
  const result = classifyTestOrder(o, [txn({ test: true })]);
  assert.equal(result.isTest, true);
  assert.ok(result.reasons.includes("non_revenue_status"));
  assert.ok(result.reasons.includes("test_gateway_transaction"));
});

Case studies

Tax setup

A new tax rule was validated with real checkouts

A homeware store rolled out tax collection for a new state and had staff place several checkouts through the Test Payment Gateway to confirm the rate applied correctly at every step. Nobody deleted the orders afterward, and the next month's Sales report came in noticeably higher than the bank deposits, with no obvious reason why.

Running the flagging job in dry run first showed every one of those orders, each carrying a transaction with gateway: "Test Payment Gateway". Once confirmed, the team let it write for real, and the finance lead now filters exports on the [TEST ORDER] marker in staff_notes before closing the books.

Agency build

A storefront migration left behind a trail of QA orders

An agency rebuilding a merchant's storefront ran dozens of guest checkouts with a placeholder qa-team@agency.com email while testing shipping zones and promotions, using a real card processor still in sandbox mode. Every one of those orders sat in Awaiting Fulfillment, inflating the order count the merchant saw on handover day.

The classifier caught them all on the billing email pattern and the sandboxed gateway's test transaction flag, without touching a single order's status. The merchant's team reviewed the flagged list, confirmed none were real customers, and cancelled them by hand one at a time.

What good looks like

After this runs on a schedule, a staff checkout through the Test Payment Gateway or a sandboxed processor no longer hides quietly inside your revenue totals. Likely test orders carry a clear, internal-only marker in staff_notes that anyone reviewing exports or closing the books can filter on, real sales are never touched by an automatic status change, and cancelling a confirmed QA order stays a deliberate, human decision made order by order.

FAQ

Why do test orders show up in my BigCommerce sales reports?

BigCommerce order objects have no is_test flag. Every store ships with a Test Payment Gateway enabled, and staff often leave real gateways in sandbox mode while setting up tax and shipping. A checkout through either one creates a fully formed order with a normal, revenue-counted status_id, and Store Overview and Sales reports simply aggregate orders by status_id, so the test checkout counts as a real sale until someone notices.

How do I tell a test order apart from a real one in the BigCommerce API?

Call GET /v2/orders/{id}/transactions for each order and look at the test boolean and the gateway name on each transaction. A transaction with test true, or a gateway named Test Payment Gateway, marks the checkout as staff testing rather than a customer sale. You can also cross-check the billing email for internal test address patterns and a guest checkout paired with a nominal total.

Is it safe to automatically cancel or delete orders flagged as test orders?

No. A script should only flag suspected test orders, for example by appending a marker to the internal-only staff_notes field, never cancel or delete them automatically. A false positive on a real customer order would hide genuine revenue from your reports, and Cancelled orders still appear in some historical exports and trigger inventory and notification side effects, so a status change should always be a separate, human-confirmed action.

Related field notes

Citations

On the problem:

  1. BigCommerce Help Center: Test Orders. support.bigcommerce.com test orders
  2. BigCommerce Help Center: Testing Shipping, Tax and Payment Settings. support.bigcommerce.com testing shipping tax and payment settings
  3. BigCommerce Help Center: Getting Started with Ecommerce Analytics Reports. support.bigcommerce.com getting started with analytics reports

On the solution:

  1. BigCommerce Developer Center: Orders REST Management API reference. developer.bigcommerce.com/docs/rest-management/orders
  2. BigCommerce Developer Center: Orders Overview. developer.bigcommerce.com/docs/store-operations/orders
  3. BigCommerce Developer Center: Get Order reference. developer.bigcommerce.com/docs/rest-management/orders/order

Stuck on a tricky one?

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

Contact me on LinkedIn

Did this clean up your sales reports?

If this saved you a confusing reconciliation call or a report that never matched 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