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.
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.
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:
- Staff use the built-in Test Payment Gateway, which every store has enabled by default, to confirm a tax rule, a shipping zone, or a new promotion actually applies at checkout.
- A real gateway like a card processor is left in sandbox or test mode while a merchant is still configuring the store, and staff run through checkout to make sure it works end to end.
- A developer or agency building the storefront places several checkouts during a build or a migration, using guest checkout with a placeholder email such as
test@test.comor a company-internal address. - Nobody circles back to cancel or delete the order once the test is done, because BigCommerce gives no obvious signal on the order itself that it needs cleanup.
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.
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.
Build it step by step
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.
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
// 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
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.
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
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++;
}
}
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.
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()
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();
}
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.
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}
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 };
}
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.
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()
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();
}
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.
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.
"""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()
/**
* 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.
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"]
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
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.
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.
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:
- BigCommerce Help Center: Test Orders. support.bigcommerce.com test orders
- BigCommerce Help Center: Testing Shipping, Tax and Payment Settings. support.bigcommerce.com testing shipping tax and payment settings
- BigCommerce Help Center: Getting Started with Ecommerce Analytics Reports. support.bigcommerce.com getting started with analytics reports
On the solution:
- BigCommerce Developer Center: Orders REST Management API reference. developer.bigcommerce.com/docs/rest-management/orders
- BigCommerce Developer Center: Orders Overview. developer.bigcommerce.com/docs/store-operations/orders
- 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.
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