Diagnostic Pricing / Price Lists
Order created via API bypasses customer group and price list pricing
A wholesale customer has a price list assigned to their customer group and gets the right discount at storefront checkout every time. Then an integration creates the same customer's order through the API, and the invoice comes back at full retail. POST /v2/orders is a back-office order-entry endpoint, not the checkout pricing engine, and when the caller hands it a line price it trusts that number completely. Here is why that gap opens up and a script that finds every order billed at the wrong price.
BigCommerce's V2 Create Order endpoint, POST /v2/orders, does not run a cart through the pricing service unless the caller omits price fields entirely. When an integration supplies price_inc_tax/price_ex_tax on each line, BigCommerce takes that number as authoritative and skips resolving it against the customer's assigned Price List or customer-group discount rules. There is no cart_id on the order, so BigCommerce has no signal the submitted price is stale or unresolved. Run a small Python or Node.js script that pulls each customer's assigned price list, compares the billed line price against the price-list record and the plain catalog price, and flags any order billed at catalog price when a price list should have applied. Full code, tests, and a dry run guard are below.
The problem in plain words
In BigCommerce, storefront checkout and POST /v2/orders are two different paths to the same table. Checkout always runs a cart through the pricing service, which looks up the shopper's customer group, checks for an assigned Price List, and resolves the correct price_ex_tax for every line before the order is ever written. The V2 orders API was built for a different job: back-office order entry, where a merchant or an integration is recreating an order that was already priced somewhere else, over the phone, in an ERP, in a spreadsheet.
BigCommerce's own docs warn that overriding one price field without the other breaks total calculation, so many integrations defensively supply both price_inc_tax and price_ex_tax on every line, having "pre-resolved" the price client-side. That is exactly the condition that makes the API skip pricing resolution entirely. The order has no cart_id tying it back to a priced cart, so there is nothing for BigCommerce to check the submitted number against. It trusts the integration's math. If that math used the plain catalog price instead of looking up the customer's price list, the order is wrong, and nothing about the order record itself tells you that happened.
Why it happens
BigCommerce resolves customer-group and price-list pricing at the cart and checkout layer, not as a rule the order-write path re-checks on every save. A few common ways stores end up with API-created orders billed at the wrong price:
- An ERP or order-management integration reads the plain product or variant price from
GET /v3/catalog/products/{id}/variantsand submits that asprice_ex_tax/price_inc_taxonPOST /v2/orders, never looking up the customer's price list at all. - The integration was written defensively against BigCommerce's own documented warning that overriding one price field without the other breaks total calculation, so it always supplies both fields, which is exactly the condition that causes the API to skip pricing resolution.
- The order's
customer_idbelongs to a customer group with a Price List assigned via/v3/pricelists/assignments, but the integration was built before that price list existed, or never re-checks assignments as they change. - The order has no
cart_id, because it was never built from a cart. With no priced cart to compare against, BigCommerce has no stale-price signal to catch, so it takes the submitted number as ground truth.
The classic symptom is a B2B or wholesale customer being billed retail catalog price on an API-created order, even though the exact same customer gets the correct discounted price at storefront checkout the very next day. See the citations at the end for the exact docs describing this order-entry versus checkout split.
The line price on an API-created order is not proof of anything. The customer's assigned price list is. So the safe pattern is not "trust price_ex_tax on the order." It is "look up the customer's customer_group_id, find their assigned price list with GET /v3/pricelists/assignments?customer_group_id=, pull the record price with GET /v3/pricelists/{id}/records, and compare that against what the order actually billed." Anything that matches the plain catalog price instead of the price-list record, when a price list should have applied, gets flagged, never silently rewritten.
The fix, as a flow
We do not touch the live order-write path or the integration's own logic. We add a scan that walks recent orders, resolves each customer's price list, and decides per order line whether the billed price matches the price list, matches plain catalog price instead, or matches neither.
Build it step by step
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 (read), Customers (read), and Pricing/Price Lists (read) scope. 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.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export LOOKBACK_DAYS="14"
export CHANNEL_ID="1"
export DRY_RUN="true" # start safe, this script only ever reports
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export LOOKBACK_DAYS="14"
export CHANNEL_ID="1"
export DRY_RUN="true" // start safe, this script only ever reports
Talk to the V2 Orders and V3 REST APIs
Orders and their line items live under https://api.bigcommerce.com/stores/{store_hash}/v2/. Customers, price lists, and the catalog live under the /v3/ base and wrap results in {data, meta.pagination}. Both use the same X-Auth-Token header. A small pair of helpers handles GET for each base and raises on a non-2xx response.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
V2_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
V3_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get_v2(path, params=None):
r = requests.get(f"{V2_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else []
def bc_get_v3(path, params=None):
r = requests.get(f"{V3_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else {"data": []}
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const V2_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const V3_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGetV2(path, params = {}) {
const url = new URL(`${V2_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 bcGetV3(path, params = {}) {
const url = new URL(`${V3_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) : { data: [] };
}
Resolve the customer's price list and record price
For each order's customer_id, call GET /v3/customers?id:in={customer_id} to read customer_group_id. Then call GET /v3/pricelists/assignments?customer_group_id={id}&channel_id={id} to get the assigned price_list_id. If there is no assignment, skip the order entirely, since there is no price list to bypass. Otherwise pull the price-list record for each variant with GET /v3/pricelists/{price_list_id}/records?variant_id:in={ids}, and the plain catalog price with GET /v3/catalog/products/{product_id}/variants/{variant_id}.
def customer_group_id(customer_id):
data = bc_get_v3("/customers", {"id:in": customer_id})
rows = data.get("data") or []
return rows[0]["customer_group_id"] if rows else None
def assigned_price_list_id(customer_group_id_, channel_id):
data = bc_get_v3("/pricelists/assignments", {
"customer_group_id": customer_group_id_,
"channel_id": channel_id,
})
rows = data.get("data") or []
return rows[0]["price_list_id"] if rows else None
def price_list_record_price(price_list_id, variant_id):
data = bc_get_v3(f"/pricelists/{price_list_id}/records", {"variant_id:in": variant_id})
rows = data.get("data") or []
return rows[0].get("price_ex_tax") if rows else None
def catalog_variant_price(product_id, variant_id):
data = bc_get_v3(f"/catalog/products/{product_id}/variants/{variant_id}")
row = data.get("data") or {}
return row.get("price")
async function customerGroupId(customerId) {
const data = await bcGetV3("/customers", { "id:in": customerId });
const rows = data.data || [];
return rows.length ? rows[0].customer_group_id : null;
}
async function assignedPriceListId(customerGroupIdValue, channelId) {
const data = await bcGetV3("/pricelists/assignments", {
customer_group_id: customerGroupIdValue,
channel_id: channelId,
});
const rows = data.data || [];
return rows.length ? rows[0].price_list_id : null;
}
async function priceListRecordPrice(priceListId, variantId) {
const data = await bcGetV3(`/pricelists/${priceListId}/records`, { "variant_id:in": variantId });
const rows = data.data || [];
return rows.length ? rows[0].price_ex_tax : null;
}
async function catalogVariantPrice(productId, variantId) {
const data = await bcGetV3(`/catalog/products/${productId}/variants/${variantId}`);
const row = data.data || {};
return row.price;
}
Decide, with one pure function
Keep the decision in its own function that takes only plain values: whether the customer has an assigned price list, what the price-list record says, what plain catalog price says, what the order actually billed, the order's status_id, and whether a transaction has already been captured. It returns a flag decision and, when flagged, a recommended action. All comparisons run on decimal strings via Decimal, never floats, because these are money.
from decimal import Decimal
UNPAID_STATUS_IDS = {0, 7, 11}
def diagnose_order_line_pricing(
customer_group_id,
assigned_price_list_id,
price_list_record_price_ex_tax,
catalog_price_ex_tax,
billed_price_ex_tax,
order_status_id,
has_captured_transaction,
):
if assigned_price_list_id is None or price_list_record_price_ex_tax is None:
return {"flagged": False, "reason": "no_price_list_assigned", "delta_ex_tax": "0", "recommended_action": "none"}
list_price = Decimal(price_list_record_price_ex_tax)
billed = Decimal(billed_price_ex_tax)
catalog = Decimal(catalog_price_ex_tax)
if billed == list_price:
return {"flagged": False, "reason": "correctly_priced", "delta_ex_tax": "0", "recommended_action": "none"}
unpaid = order_status_id in UNPAID_STATUS_IDS and not has_captured_transaction
action = "cancel_unpaid" if unpaid else "report_refund_delta"
if billed == catalog and list_price != catalog:
return {
"flagged": True,
"reason": "billed_at_catalog_price_ignoring_pricelist",
"delta_ex_tax": str(list_price - billed),
"recommended_action": action,
}
return {
"flagged": True,
"reason": "billed_price_mismatch_unknown_source",
"delta_ex_tax": str(list_price - billed),
"recommended_action": action,
}
const UNPAID_STATUS_IDS = new Set([0, 7, 11]);
export function diagnoseOrderLinePricing(
customerGroupId,
assignedPriceListId,
priceListRecordPriceExTax,
catalogPriceExTax,
billedPriceExTax,
orderStatusId,
hasCapturedTransaction
) {
if (assignedPriceListId == null || priceListRecordPriceExTax == null) {
return { flagged: false, reason: "no_price_list_assigned", deltaExTax: "0", recommendedAction: "none" };
}
const listPrice = Number(priceListRecordPriceExTax);
const billed = Number(billedPriceExTax);
const catalog = Number(catalogPriceExTax);
if (billed === listPrice) {
return { flagged: false, reason: "correctly_priced", deltaExTax: "0", recommendedAction: "none" };
}
const unpaid = UNPAID_STATUS_IDS.has(orderStatusId) && !hasCapturedTransaction;
const action = unpaid ? "cancel_unpaid" : "report_refund_delta";
if (billed === catalog && listPrice !== catalog) {
return {
flagged: true,
reason: "billed_at_catalog_price_ignoring_pricelist",
deltaExTax: (listPrice - billed).toFixed(2),
recommendedAction: action,
};
}
return {
flagged: true,
reason: "billed_price_mismatch_unknown_source",
deltaExTax: (listPrice - billed).toFixed(2),
recommendedAction: action,
};
}
Never rewrite a placed order, only report
BigCommerce's V2 orders API has no "reprice line item" PUT. Once an order is created, its products and totals are largely immutable financial records, especially after a payment has been captured. When a line is flagged and unpaid with no captured transaction (checked via GET /v2/orders/{id}/transactions), the safe action is to cancel it with PUT /v2/orders/{id} {"status_id": 5} and let the integration recreate the order correctly. When it is already paid or shipped, the script only emits a report line recommending a manual refund via POST /v2/orders/{id}/refunds or a store credit for the delta. It never silently mutates a historical order.
CANCELLED = 5
def cancel_unpaid_order(order_id):
return bc_put_v2(f"/orders/{order_id}", {"status_id": CANCELLED})
def order_has_captured_transaction(order_id):
transactions = bc_get_v2(f"/orders/{order_id}/transactions")
for txn in transactions or []:
kind = (txn.get("type") or txn.get("event") or "").lower()
status = (txn.get("status") or "").lower()
if kind in {"capture", "sale"} and status == "success":
return True
return False
const CANCELLED = 5;
async function cancelUnpaidOrder(orderId) {
return bcPutV2(`/orders/${orderId}`, { status_id: CANCELLED });
}
async function orderHasCapturedTransaction(orderId) {
const transactions = await bcGetV2(`/orders/${orderId}/transactions`);
for (const txn of transactions || []) {
const kind = (txn.type || txn.event || "").toLowerCase();
const status = (txn.status || "").toLowerCase();
if ((kind === "capture" || kind === "sale") && status === "success") return true;
}
return false;
}
Wire it together with a dry run guard
The scan restricts itself to orders with status_id in the API-creation window (0 Incomplete, 7 Awaiting Payment, 9 Awaiting Shipment, 11 Awaiting Fulfillment) and date_created after the integration went live, via GET /v2/orders?min_date_created=...&customer_id={id}. DRY_RUN defaults to true, which is what this scan should almost always run as, since it is a report, not an auto-fix. The one write path, cancelling an unpaid order, only runs when DRY_RUN=false and the flagged order has no captured transaction.
This script is a flag-and-report tool first. Never let it rewrite price_ex_tax or price_inc_tax on a placed order. The only automated write it ever makes is cancelling an order that is both unpaid and has no captured transaction, and only when DRY_RUN=false. Everything else becomes a report line for a human to act on with a refund or store credit.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and never mutates a paid or shipped order. It only ever cancels an order that is unpaid with no captured transaction, and reports everything else with the delta for a human to resolve.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Flag BigCommerce orders created via API that bypassed group and price list pricing.
POST /v2/orders is a back-office order-entry endpoint, not the storefront checkout
pricing engine. It only runs a cart through the pricing service if the caller omits
price fields entirely. When an integration supplies price_inc_tax/price_ex_tax on
each line, exactly what "pre-resolving" price client-side produces, BigCommerce
takes that number as authoritative and never resolves it against the customer's
assigned Price List or customer-group discount rules. Because the order has no
cart_id tying it back to a priced cart, there is no signal the submitted price is
stale or wrong. This job scans recent orders, resolves each customer's assigned
price list, and flags any line billed at plain catalog price (or any other price)
when the price list disagrees. It never rewrites a placed order's price fields; it
only cancels an unpaid order with no captured transaction, or reports a delta for a
human to refund or credit. Run on a schedule. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/api-order-bypasses-group-pricing/
"""
import os
import logging
from decimal import Decimal, InvalidOperation
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("diagnose_order_pricing")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
V2_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
V3_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "14"))
CHANNEL_ID = int(os.environ.get("CHANNEL_ID", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
UNPAID_STATUS_IDS = {0, 7, 11}
API_CREATION_WINDOW_STATUS_IDS = {0, 7, 9, 11}
CANCELLED = 5
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get_v2(path, params=None):
r = requests.get(f"{V2_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else []
def bc_put_v2(path, body):
r = requests.put(f"{V2_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def bc_get_v3(path, params=None):
r = requests.get(f"{V3_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else {"data": []}
def diagnose_order_line_pricing(
customer_group_id,
assigned_price_list_id,
price_list_record_price_ex_tax,
catalog_price_ex_tax,
billed_price_ex_tax,
order_status_id,
has_captured_transaction,
):
"""Pure decision logic, no I/O. All prices passed as decimal strings, compared via Decimal.
Returns {"flagged": bool, "reason": str, "delta_ex_tax": str, "recommended_action": str}.
If no assigned_price_list_id or no price_list_record_price_ex_tax: not flagged, the
customer has no price-list override, so plain catalog price is correct.
If billed_price_ex_tax == price_list_record_price_ex_tax: not flagged, correctly priced.
If billed_price_ex_tax == catalog_price_ex_tax and price list disagrees with catalog:
flagged, reason='billed_at_catalog_price_ignoring_pricelist'.
Otherwise: flagged, reason='billed_price_mismatch_unknown_source'.
recommended_action is 'cancel_unpaid' when order_status_id is in {0, 7, 11} and there
is no captured transaction, else 'report_refund_delta'.
"""
if assigned_price_list_id is None or price_list_record_price_ex_tax is None:
return {
"flagged": False,
"reason": "no_price_list_assigned",
"delta_ex_tax": "0",
"recommended_action": "none",
}
try:
list_price = Decimal(price_list_record_price_ex_tax)
billed = Decimal(billed_price_ex_tax)
catalog = Decimal(catalog_price_ex_tax)
except (InvalidOperation, TypeError):
return {
"flagged": True,
"reason": "billed_price_mismatch_unknown_source",
"delta_ex_tax": "0",
"recommended_action": "report_refund_delta",
}
if billed == list_price:
return {
"flagged": False,
"reason": "correctly_priced",
"delta_ex_tax": "0",
"recommended_action": "none",
}
unpaid = order_status_id in UNPAID_STATUS_IDS and not has_captured_transaction
action = "cancel_unpaid" if unpaid else "report_refund_delta"
if billed == catalog and list_price != catalog:
return {
"flagged": True,
"reason": "billed_at_catalog_price_ignoring_pricelist",
"delta_ex_tax": str(list_price - billed),
"recommended_action": action,
}
return {
"flagged": True,
"reason": "billed_price_mismatch_unknown_source",
"delta_ex_tax": str(list_price - billed),
"recommended_action": action,
}
def candidate_orders():
"""Page through orders in the API-creation status window within the lookback window."""
page = 1
while True:
orders = bc_get_v2(
"/orders",
{
"min_date_created": f"-{LOOKBACK_DAYS} days",
"page": page,
"limit": 50,
},
)
if not orders:
return
for order in orders:
if order.get("status_id") in API_CREATION_WINDOW_STATUS_IDS:
yield order
page += 1
def order_products(order_id):
return bc_get_v2(f"/orders/{order_id}/products")
def order_has_captured_transaction(order_id):
transactions = bc_get_v2(f"/orders/{order_id}/transactions")
for txn in transactions or []:
kind = (txn.get("type") or txn.get("event") or "").lower()
status = (txn.get("status") or "").lower()
if kind in {"capture", "sale"} and status == "success":
return True
return False
def customer_group_id(customer_id):
if not customer_id:
return None
data = bc_get_v3("/customers", {"id:in": customer_id})
rows = data.get("data") or []
return rows[0]["customer_group_id"] if rows else None
def assigned_price_list_id(customer_group_id_value):
if not customer_group_id_value:
return None
data = bc_get_v3(
"/pricelists/assignments",
{"customer_group_id": customer_group_id_value, "channel_id": CHANNEL_ID},
)
rows = data.get("data") or []
return rows[0]["price_list_id"] if rows else None
def price_list_record_price(price_list_id, variant_id):
if not price_list_id:
return None
data = bc_get_v3(f"/pricelists/{price_list_id}/records", {"variant_id:in": variant_id})
rows = data.get("data") or []
return rows[0].get("price_ex_tax") if rows else None
def catalog_variant_price(product_id, variant_id):
data = bc_get_v3(f"/catalog/products/{product_id}/variants/{variant_id}")
row = data.get("data") or {}
price = row.get("price")
return str(price) if price is not None else None
def run():
flagged_count = 0
cancelled_count = 0
for order in candidate_orders():
order_id = order["id"]
customer_id = order.get("customer_id")
status_id = order.get("status_id")
group_id = customer_group_id(customer_id)
price_list_id = assigned_price_list_id(group_id)
if price_list_id is None:
continue
has_captured = order_has_captured_transaction(order_id)
for line in order_products(order_id) or []:
product_id = line.get("product_id")
variant_id = line.get("variant_id")
billed = line.get("price_ex_tax")
list_price = price_list_record_price(price_list_id, variant_id)
catalog_price = catalog_variant_price(product_id, variant_id)
result = diagnose_order_line_pricing(
group_id, price_list_id, list_price, catalog_price, billed, status_id, has_captured
)
if not result["flagged"]:
continue
flagged_count += 1
log.warning(
"order_id=%s product_id=%s variant_id=%s billed=%s list_price=%s "
"catalog_price=%s reason=%s delta=%s action=%s",
order_id, product_id, variant_id, billed, list_price,
catalog_price, result["reason"], result["delta_ex_tax"], result["recommended_action"],
)
if result["recommended_action"] == "cancel_unpaid":
if not DRY_RUN:
bc_put_v2(f"/orders/{order_id}", {"status_id": CANCELLED})
cancelled_count += 1
log.info(
"Done. %d line(s) flagged, %d order(s) %s for cancellation.",
flagged_count, cancelled_count, "to cancel" if DRY_RUN else "cancelled",
)
if __name__ == "__main__":
run()
/**
* Flag BigCommerce orders created via API that bypassed group and price list pricing.
*
* POST /v2/orders is a back-office order-entry endpoint, not the storefront checkout
* pricing engine. It only runs a cart through the pricing service if the caller omits
* price fields entirely. When an integration supplies price_inc_tax/price_ex_tax on
* each line, exactly what "pre-resolving" price client-side produces, BigCommerce
* takes that number as authoritative and never resolves it against the customer's
* assigned Price List or customer-group discount rules. Because the order has no
* cart_id tying it back to a priced cart, there is no signal the submitted price is
* stale or wrong. This job scans recent orders, resolves each customer's assigned
* price list, and flags any line billed at plain catalog price (or any other price)
* when the price list disagrees. It never rewrites a placed order's price fields; it
* only cancels an unpaid order with no captured transaction, or reports a delta for a
* human to refund or credit. Run on a schedule.
*
* Guide: https://www.allanninal.dev/bigcommerce/api-order-bypasses-group-pricing/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const V2_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const V3_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);
const CHANNEL_ID = Number(process.env.CHANNEL_ID || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const UNPAID_STATUS_IDS = new Set([0, 7, 11]);
const API_CREATION_WINDOW_STATUS_IDS = new Set([0, 7, 9, 11]);
const CANCELLED = 5;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision logic, no I/O. All prices passed as decimal strings, compared as numbers.
*
* Returns { flagged, reason, deltaExTax, recommendedAction }.
* If assignedPriceListId or priceListRecordPriceExTax is null: not flagged, the customer
* has no price-list override, plain catalog price is correct.
* If billedPriceExTax === priceListRecordPriceExTax: not flagged, correctly priced.
* If billedPriceExTax === catalogPriceExTax and the price list disagrees with catalog:
* flagged, reason 'billed_at_catalog_price_ignoring_pricelist'.
* Otherwise: flagged, reason 'billed_price_mismatch_unknown_source'.
* recommendedAction is 'cancel_unpaid' when orderStatusId is in {0, 7, 11} and there is
* no captured transaction, else 'report_refund_delta'.
*/
export function diagnoseOrderLinePricing(
customerGroupId,
assignedPriceListId,
priceListRecordPriceExTax,
catalogPriceExTax,
billedPriceExTax,
orderStatusId,
hasCapturedTransaction
) {
if (assignedPriceListId == null || priceListRecordPriceExTax == null) {
return { flagged: false, reason: "no_price_list_assigned", deltaExTax: "0", recommendedAction: "none" };
}
const listPrice = Number.parseFloat(priceListRecordPriceExTax);
const billed = Number.parseFloat(billedPriceExTax);
const catalog = Number.parseFloat(catalogPriceExTax);
if (!Number.isFinite(listPrice) || !Number.isFinite(billed) || !Number.isFinite(catalog)) {
return {
flagged: true,
reason: "billed_price_mismatch_unknown_source",
deltaExTax: "0",
recommendedAction: "report_refund_delta",
};
}
if (billed === listPrice) {
return { flagged: false, reason: "correctly_priced", deltaExTax: "0", recommendedAction: "none" };
}
const unpaid = UNPAID_STATUS_IDS.has(orderStatusId) && !hasCapturedTransaction;
const action = unpaid ? "cancel_unpaid" : "report_refund_delta";
if (billed === catalog && listPrice !== catalog) {
return {
flagged: true,
reason: "billed_at_catalog_price_ignoring_pricelist",
deltaExTax: (listPrice - billed).toFixed(2),
recommendedAction: action,
};
}
return {
flagged: true,
reason: "billed_price_mismatch_unknown_source",
deltaExTax: (listPrice - billed).toFixed(2),
recommendedAction: action,
};
}
async function bcGetV2(path, params = {}) {
const url = new URL(`${V2_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 bcPutV2(path, body) {
const res = await fetch(`${V2_BASE}${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcGetV3(path, params = {}) {
const url = new URL(`${V3_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) : { data: [] };
}
async function* candidateOrders() {
let page = 1;
while (true) {
const orders = await bcGetV2("/orders", {
min_date_created: `-${LOOKBACK_DAYS} days`,
page,
limit: 50,
});
if (!orders.length) return;
for (const order of orders) {
if (API_CREATION_WINDOW_STATUS_IDS.has(order.status_id)) yield order;
}
page += 1;
}
}
async function orderProducts(orderId) {
return bcGetV2(`/orders/${orderId}/products`);
}
async function orderHasCapturedTransaction(orderId) {
const transactions = await bcGetV2(`/orders/${orderId}/transactions`);
for (const txn of transactions || []) {
const kind = (txn.type || txn.event || "").toLowerCase();
const status = (txn.status || "").toLowerCase();
if ((kind === "capture" || kind === "sale") && status === "success") return true;
}
return false;
}
async function customerGroupId(customerId) {
if (!customerId) return null;
const data = await bcGetV3("/customers", { "id:in": customerId });
const rows = data.data || [];
return rows.length ? rows[0].customer_group_id : null;
}
async function assignedPriceListId(customerGroupIdValue) {
if (!customerGroupIdValue) return null;
const data = await bcGetV3("/pricelists/assignments", {
customer_group_id: customerGroupIdValue,
channel_id: CHANNEL_ID,
});
const rows = data.data || [];
return rows.length ? rows[0].price_list_id : null;
}
async function priceListRecordPrice(priceListId, variantId) {
if (!priceListId) return null;
const data = await bcGetV3(`/pricelists/${priceListId}/records`, { "variant_id:in": variantId });
const rows = data.data || [];
return rows.length ? rows[0].price_ex_tax : null;
}
async function catalogVariantPrice(productId, variantId) {
const data = await bcGetV3(`/catalog/products/${productId}/variants/${variantId}`);
const row = data.data || {};
return row.price != null ? String(row.price) : null;
}
export async function run() {
let flaggedCount = 0;
let cancelledCount = 0;
for await (const order of candidateOrders()) {
const orderId = order.id;
const customerId = order.customer_id;
const statusId = order.status_id;
const groupId = await customerGroupId(customerId);
const priceListId = await assignedPriceListId(groupId);
if (priceListId == null) continue;
const hasCaptured = await orderHasCapturedTransaction(orderId);
const lines = await orderProducts(orderId);
for (const line of lines || []) {
const productId = line.product_id;
const variantId = line.variant_id;
const billed = line.price_ex_tax;
const listPrice = await priceListRecordPrice(priceListId, variantId);
const catalogPrice = await catalogVariantPrice(productId, variantId);
const result = diagnoseOrderLinePricing(
groupId, priceListId, listPrice, catalogPrice, billed, statusId, hasCaptured
);
if (!result.flagged) continue;
flaggedCount += 1;
console.warn(
`order_id=${orderId} product_id=${productId} variant_id=${variantId} billed=${billed} ` +
`list_price=${listPrice} catalog_price=${catalogPrice} reason=${result.reason} ` +
`delta=${result.deltaExTax} action=${result.recommendedAction}`
);
if (result.recommendedAction === "cancel_unpaid") {
if (!DRY_RUN) await bcPutV2(`/orders/${orderId}`, { status_id: CANCELLED });
cancelledCount += 1;
}
}
}
console.log(
`Done. ${flaggedCount} line(s) flagged, ${cancelledCount} order(s) ${DRY_RUN ? "to cancel" : "cancelled"} for cancellation.`
);
}
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 a flagged order gets cancelled or only reported. Because diagnose_order_line_pricing takes only plain values and returns a plain dict, the test needs no network and no BigCommerce store. It just feeds in decimal strings and checks the answer.
from diagnose_order_pricing import diagnose_order_line_pricing
def test_not_flagged_when_no_price_list_assigned():
result = diagnose_order_line_pricing(None, None, None, "50.00", "50.00", 11, False)
assert result["flagged"] is False
assert result["reason"] == "no_price_list_assigned"
def test_not_flagged_when_billed_matches_price_list():
result = diagnose_order_line_pricing(5, 9, "40.00", "50.00", "40.00", 11, False)
assert result["flagged"] is False
assert result["reason"] == "correctly_priced"
def test_flagged_when_billed_at_catalog_price_ignoring_pricelist():
result = diagnose_order_line_pricing(5, 9, "40.00", "50.00", "50.00", 11, False)
assert result["flagged"] is True
assert result["reason"] == "billed_at_catalog_price_ignoring_pricelist"
assert result["delta_ex_tax"] == "-10.00"
assert result["recommended_action"] == "cancel_unpaid"
def test_recommends_report_refund_delta_when_transaction_captured():
result = diagnose_order_line_pricing(5, 9, "40.00", "50.00", "50.00", 11, True)
assert result["flagged"] is True
assert result["recommended_action"] == "report_refund_delta"
def test_recommends_report_refund_delta_when_order_already_shipped():
result = diagnose_order_line_pricing(5, 9, "40.00", "50.00", "50.00", 2, False)
assert result["flagged"] is True
assert result["recommended_action"] == "report_refund_delta"
def test_flagged_billed_price_mismatch_unknown_source():
result = diagnose_order_line_pricing(5, 9, "40.00", "50.00", "45.00", 7, False)
assert result["flagged"] is True
assert result["reason"] == "billed_price_mismatch_unknown_source"
assert result["recommended_action"] == "cancel_unpaid"
import { test } from "node:test";
import assert from "node:assert/strict";
import { diagnoseOrderLinePricing } from "./diagnose-order-pricing.js";
test("not flagged when no price list assigned", () => {
const result = diagnoseOrderLinePricing(null, null, null, "50.00", "50.00", 11, false);
assert.equal(result.flagged, false);
assert.equal(result.reason, "no_price_list_assigned");
});
test("not flagged when billed matches price list", () => {
const result = diagnoseOrderLinePricing(5, 9, "40.00", "50.00", "40.00", 11, false);
assert.equal(result.flagged, false);
assert.equal(result.reason, "correctly_priced");
});
test("flagged when billed at catalog price ignoring pricelist", () => {
const result = diagnoseOrderLinePricing(5, 9, "40.00", "50.00", "50.00", 11, false);
assert.equal(result.flagged, true);
assert.equal(result.reason, "billed_at_catalog_price_ignoring_pricelist");
assert.equal(result.deltaExTax, "-10.00");
assert.equal(result.recommendedAction, "cancel_unpaid");
});
test("recommends report_refund_delta when transaction captured", () => {
const result = diagnoseOrderLinePricing(5, 9, "40.00", "50.00", "50.00", 11, true);
assert.equal(result.flagged, true);
assert.equal(result.recommendedAction, "report_refund_delta");
});
test("recommends report_refund_delta when order already shipped", () => {
const result = diagnoseOrderLinePricing(5, 9, "40.00", "50.00", "50.00", 2, false);
assert.equal(result.flagged, true);
assert.equal(result.recommendedAction, "report_refund_delta");
});
test("flagged billed price mismatch unknown source", () => {
const result = diagnoseOrderLinePricing(5, 9, "40.00", "50.00", "45.00", 7, false);
assert.equal(result.flagged, true);
assert.equal(result.reason, "billed_price_mismatch_unknown_source");
assert.equal(result.recommendedAction, "cancel_unpaid");
});
Case studies
The wholesale customer billed retail on every phone order
A distributor's sales team took phone orders and created them through the API by pulling each SKU's price from the product page and submitting it directly as price_ex_tax. Every one of those customers had a discounted price list assigned to their customer group, and every one of those orders billed full retail. The storefront checkout for the same customers was correct every time, because it always resolved the price list.
Running the scan against the last two weeks of orders surfaced dozens of flagged lines, each with a delta and a recommended action. The unpaid ones got cancelled and recreated correctly. The already-shipped ones went into a report for the accounts team to issue store credit.
The integration that supplied both price fields to avoid a total bug
A middleware team had read BigCommerce's warning that overriding one price field without the other breaks total calculation, so their fix was to always compute and submit both price_ex_tax and price_inc_tax from the catalog price. That silenced the total bug but introduced this one: submitting any price field at all skips pricing resolution, so the price list was never consulted.
The real fix, once the report made the pattern obvious, was to stop computing price client-side entirely and instead post through POST /v3/carts with customer_id set, letting BigCommerce resolve price list and group discount automatically before converting the cart to an order.
After this runs on a schedule, every order billed at plain catalog price when a price list should have applied gets a report line with the exact delta, whether it needs a cancel-and-recreate or a manual refund. No placed order's price fields are ever silently rewritten. The real fix, building orders from a priced cart or omitting price fields so BigCommerce resolves them server-side, closes the gap for good, and the scan keeps catching anything that slips through in the meantime.
FAQ
Why does an order created through the BigCommerce API bill the wrong price?
POST /v2/orders is a back-office order-entry endpoint, not the storefront checkout pricing engine. If the caller supplies price_inc_tax or price_ex_tax on a line item, BigCommerce takes that number as authoritative and never resolves it against the customer's assigned price list or customer-group discount rules. Because the order has no cart_id tying it back to a priced cart, there is no signal to BigCommerce that the submitted price might be stale or wrong.
Can I fix a wrongly priced order by editing its price_ex_tax after the fact?
Not safely. Order products and totals are largely immutable financial records once created, especially after a payment has been captured. Rewriting price fields on a placed order risks desyncing totals, tax, and any already-captured transaction. Cancel and recreate the order if it is unpaid, or report the delta for a manual refund or store credit if it has already been paid or shipped.
What is the real fix so this stops happening on every order?
Stop pre-resolving price client-side in the integration. Build the order through a priced cart with POST /v3/carts, set customer_id on the cart so BigCommerce resolves the price list and customer group automatically, then convert that cart to an order. Alternatively, omit price_ex_tax and price_inc_tax entirely on POST /v2/orders/{id}/products so BigCommerce's pricing service resolves them server-side.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: Orders Overview and the V2 order-entry model. developer.bigcommerce.com orders overview
- BigCommerce Developer Center: Order Products, price_ex_tax and price_inc_tax fields. developer.bigcommerce.com order products
- BigCommerce Orders API: Complete Integration Guide, V2 versus V3 order creation. ecommerce.folio3.com bigcommerce order apis v2 and v3
On the solution:
- BigCommerce Developer Center: Price Lists, assignments, and records. developer.bigcommerce.com price lists
- BigCommerce Developer Center: Price List API Overview. developer.bigcommerce.com price list api overview
- BigCommerce Developer Center: Price Order of Operations. developer.bigcommerce.com price order of operations
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, pricing, 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 catch a wrongly priced order?
If this saved a wholesale customer from being overbilled, or saved you the manual audit, 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