Diagnostic Coupons / Promotions
Manually overridden order pricing is excluded from promotions
An integration creates an order with an explicit price on a line item, a server-to-server checkout, a custom quote, a marketplace import, and the order total comes back with zero promo discount even though an active, matching promotion is running. Nothing errors. The coupon field is just quietly empty. BigCommerce's pricing engine only evaluates promotions against prices its own pricing service computed, and a manually overridden price does not qualify by default. Here is why that gap exists and a small script that finds every order it happened to.
BigCommerce's promotion engine only evaluates automatic and coupon promotions against catalog or price-list-derived prices computed by its own pricing service. When a line item is created with an explicit price_ex_tax or price_inc_tax override, through the V2 Orders API's server-to-server order creation or the Cart/Checkout Server-to-Server APIs, that price is treated as a manually set custom price, not a catalog price. By default, promotions skip line items with custom pricing so the merchant's override is not discounted on top of itself. A store-level setting, Allow promotions to apply on products with custom price overrides under Settings, Promotions and coupons, must be turned on for the promotion engine to even consider those line items. Leave it off, the default, and every order built through a price-override integration silently gets $0 promo discount, even when an active, matching automatic promotion exists. Run a small Python or Node.js script that pulls each order, checks for a price override with no discount recorded, cross-references active automatic promotions, and flags the mismatch for manual review. Full code, tests, and the pure decision function are below.
The problem in plain words
Most BigCommerce orders get their line-item prices from the catalog or a price list, and BigCommerce's own pricing service computes those prices and then checks them against every running promotion. But some integrations do not let BigCommerce price the order at all. A server-to-server order creation through POST /v2/orders can set price_ex_tax and price_inc_tax directly on each entry in products[]. The Cart and Checkout Server-to-Server APIs have the same capability. Whoever built the integration, a custom quoting tool, a marketplace sync, a B2B sales rep flow, decided the price up front and told BigCommerce to just record it.
The trouble is that a manually set price like that is no longer something BigCommerce's pricing service produced, it is a custom price. And by design, BigCommerce's automatic and coupon promotions skip line items carrying a custom price, because the assumption is the merchant already decided that price on purpose and does not want a promotion layering another discount on top of it uninvited. That assumption is reasonable in isolation. It becomes a silent bug when the merchant actually does want the promotion applied, has an active, matching promotion running, and never gets told the order was excluded. The order simply completes with discount_amount and coupon_discount both at zero, no error, no warning, nothing in the response that says "a promotion would have matched here but did not run."
Why it happens
This behavior comes from how BigCommerce's pricing engine decides what counts as a discountable price in the first place. A few things line up to produce it:
- Any line item created with an explicit
price_ex_taxorprice_inc_taxvalue, throughPOST /v2/ordersserver-to-server order creation or the Cart/Checkout Server-to-Server APIs, is recorded as a manually set custom price rather than something BigCommerce's own pricing service computed from the catalog or a price list. - Automatic and coupon promotions are, by default, scoped to skip line items carrying a custom price. The intent is to protect a merchant's deliberate override from being discounted again on top of itself.
- The control that changes this, Allow promotions to apply on products with custom price overrides, lives under Settings, Promotions and coupons in the control panel, and it defaults to off. Nothing in the order creation response or the promotions API tells the caller that this setting is why the discount did not apply.
- Because the order still completes successfully, with a valid
id, a normalstatus_id, and a subtotal that matches the override price, there is no error path for anyone to notice. The only symptom is a discount field that stayed at zero.
See the citations at the end for BigCommerce's own documentation on order creation, price order of operations, and the promotions API.
You cannot tell from the order alone whether a missing discount is correct or a bug. You have to cross-reference. Pull the order's line items and look for a price_ex_tax that differs from the catalog's base price, that is the override signal. Then check GET /v2/orders/{id}/coupons and the order's own discount fields for any recorded discount. If there is an override and no discount, and GET /v3/promotions?status=ENABLED turns up an active AUTOMATIC promotion that should have matched, that combination is the "promo-expected-but-absent" signature. Anything less specific than that will produce false positives on orders that legitimately had no matching promotion.
The fix, as a flow
We do not touch the live order creation or checkout path. We add a scan that walks recent orders, checks each one's line items and coupons against the currently active automatic promotions, and reports the ones that look like they were silently excluded. Writing a corrected discount back onto a settled order is out of scope for an automated script, so the default action is flag and report, with a narrow, opt-in guarded repair only for orders that have not been captured yet.
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) and the Promotions scope so it can read orders, line items, coupons, and active promotions. 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 DRY_RUN="true" # start safe, change to false only for the guarded pre-capture repair
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export LOOKBACK_DAYS="14"
export DRY_RUN="true" // start safe, change to false only for the guarded pre-capture repair
Talk to the V2 Orders and V3 Promotions REST APIs
Orders live under https://api.bigcommerce.com/stores/{store_hash}/v2/. Promotions live under the V3 base, https://api.bigcommerce.com/stores/{store_hash}/v3/, and wrap results in {data, meta.pagination}. Both use the same X-Auth-Token header. A small helper handles GET for both 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(base, path, params=None):
r = requests.get(f"{base}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else []
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 bcGet(base, path, params = {}) {
const url = new URL(`${base}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : [];
}
List recent orders and pull the pieces the decision needs
Call GET /v2/orders?min_date_created=..., paginated, for orders within your lookback window. For each one, call GET /v2/orders/{id}/products for line items, GET /v2/orders/{id}/coupons for the order coupons sub-resource, and GET /v3/promotions?status=ENABLED once per run for the active promotion list.
def candidate_orders(lookback_days):
page = 1
while True:
orders = bc_get(V2_BASE, "/orders", {
"min_date_created": f"-{lookback_days} days",
"page": page,
"limit": 50,
})
if not orders:
return
for order in orders:
yield order
page += 1
def order_line_items(order_id):
return bc_get(V2_BASE, f"/orders/{order_id}/products")
def order_coupons(order_id):
return bc_get(V2_BASE, f"/orders/{order_id}/coupons")
def active_automatic_promotions():
resp = bc_get(V3_BASE, "/promotions", {"status": "ENABLED"})
return resp.get("data", []) if isinstance(resp, dict) else resp
async function* candidateOrders(lookbackDays) {
let page = 1;
while (true) {
const orders = await bcGet(V2_BASE, "/orders", {
min_date_created: `-${lookbackDays} days`,
page,
limit: 50,
});
if (!orders.length) return;
for (const order of orders) yield order;
page += 1;
}
}
async function orderLineItems(orderId) {
return bcGet(V2_BASE, `/orders/${orderId}/products`);
}
async function orderCoupons(orderId) {
return bcGet(V2_BASE, `/orders/${orderId}/coupons`);
}
async function activeAutomaticPromotions() {
const resp = await bcGet(V3_BASE, "/promotions", { status: "ENABLED" });
return Array.isArray(resp) ? resp : resp.data || [];
}
Decide, with one pure function
Keep the decision in its own function that takes the order, its line items, its order coupons, and the active promotion list, and returns either None or a flag dict. It only fires when both conditions hold at once: at least one line item carries a price override, and no discount was recorded anywhere on the order. It also requires at least one currently active AUTOMATIC promotion to exist, otherwise there is nothing the order could have missed.
def flag_missing_promotion(order, line_items, order_coupons, active_promotions):
has_price_override = any(
li.get("price_ex_tax") is not None and li.get("base_price") is not None
and li["price_ex_tax"] != li["base_price"]
for li in line_items
)
no_discount_recorded = (
float(order.get("discount_amount", "0") or 0) == 0
and float(order.get("coupon_discount", "0") or 0) == 0
and not order_coupons
and not any(li.get("applied_discounts") for li in line_items)
)
if not (has_price_override and no_discount_recorded):
return None
eligible_promo_ids = [
p["id"] for p in active_promotions
if p.get("redemption_type") == "AUTOMATIC"
]
if not eligible_promo_ids:
return None
return {
"order_id": order["id"],
"reason": "price_override_excluded_from_active_automatic_promotion",
"has_price_override": has_price_override,
"expected_promo_ids": eligible_promo_ids,
}
export function flagMissingPromotion(order, lineItems, orderCoupons, activePromotions) {
const hasPriceOverride = (lineItems || []).some(
(li) => li.price_ex_tax !== undefined && li.price_ex_tax !== null
&& li.base_price !== undefined && li.base_price !== null
&& li.price_ex_tax !== li.base_price
);
const noDiscountRecorded = (
Number.parseFloat(order.discount_amount || "0") === 0
&& Number.parseFloat(order.coupon_discount || "0") === 0
&& (orderCoupons || []).length === 0
&& !(lineItems || []).some((li) => li.applied_discounts && li.applied_discounts.length)
);
if (!(hasPriceOverride && noDiscountRecorded)) return null;
const eligiblePromoIds = (activePromotions || [])
.filter((p) => p.redemption_type === "AUTOMATIC")
.map((p) => p.id);
if (!eligiblePromoIds.length) return null;
return {
order_id: order.id,
reason: "price_override_excluded_from_active_automatic_promotion",
has_price_override: hasPriceOverride,
expected_promo_ids: eligiblePromoIds,
};
}
Report every flag, write nothing by default
The default action for a flagged order is a JSON or CSV row: {order_id, expected_promo_ids, override_amount, recommended_action}. Recalculating and re-applying a discount retroactively risks double-charging refunds and taxes and can conflict with a payment already captured, so this is not something a script should do blindly. Orders in shipped, completed, refunded, or partially refunded status (status_id 2, 3, 4, 10, 14) are always report-only, never rewritten.
ALWAYS_SKIP_STATUS_IDS = {2, 3, 4, 10, 14} # Shipped, Partially Shipped, Refunded, Completed, Partially Refunded
PRE_CAPTURE_STATUS_IDS = {0, 7} # Incomplete, Awaiting Payment
def recommended_action(order_status_id):
if order_status_id in ALWAYS_SKIP_STATUS_IDS:
return "flag_only_settled_order"
if order_status_id in PRE_CAPTURE_STATUS_IDS:
return "flag_or_guarded_repair_pre_capture"
return "flag_only"
const ALWAYS_SKIP_STATUS_IDS = new Set([2, 3, 4, 10, 14]); // Shipped, Partially Shipped, Refunded, Completed, Partially Refunded
const PRE_CAPTURE_STATUS_IDS = new Set([0, 7]); // Incomplete, Awaiting Payment
function recommendedAction(orderStatusId) {
if (ALWAYS_SKIP_STATUS_IDS.has(orderStatusId)) return "flag_only_settled_order";
if (PRE_CAPTURE_STATUS_IDS.has(orderStatusId)) return "flag_or_guarded_repair_pre_capture";
return "flag_only";
}
Wire it together with a dry run guard
The loop ties every piece together, and it defaults to report-only. On pre-capture orders, if an operator explicitly sets DRY_RUN=false, the guarded repair does not PUT a corrected discount_amount or line-item price_ex_tax directly, because a PUT on /v2/orders/{id}/products/{product_id} clears any existing line-item discounts. Instead the safer route is to void and re-create the order through POST /v3/carts converted to checkout, letting BigCommerce's own pricing service compute and store the discount. That path is intentionally left as a manual, reviewed step in this script, never an automatic write.
Always start with DRY_RUN=true. The script's job is to find the orders that need a human look, not to guess a discount and write it back. Never PUT a recomputed discount_amount directly onto a settled order, and never touch anything at status_id 2, 3, 4, 10, or 14. Those are always flag-only.
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 defaults to producing a report rather than writing anything, because a retroactive discount write against a settled order carries real financial risk.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Flag BigCommerce orders where a manually overridden line-item price
silently excluded the order from an active, matching automatic promotion.
BigCommerce's pricing engine only evaluates promotions against catalog or
price-list-derived prices computed by its own pricing service. When a line
item is created with an explicit price_ex_tax or price_inc_tax override,
through the V2 Orders API's server-to-server order creation or the
Cart/Checkout Server-to-Server APIs, that price is a manually set custom
price, not a catalog price. By default, automatic and coupon promotions
skip line items with custom pricing. A store-level setting, "Allow
promotions to apply on products with custom price overrides" under
Settings, Promotions and coupons, has to be turned on before the promotion
engine will consider those line items. Leave it off, the default, and any
order built through a price-override integration silently gets $0 promo
discount even when an active, matching automatic promotion exists.
This is not safely auto-fixable as a write against a settled order, so the
default action is flag and report. A JSON/CSV report of
{order_id, expected_promo_ids, override_amount, recommended_action} is
produced for every match. Orders in shipped, partially shipped, refunded,
completed, or partially refunded status (status_id 2, 3, 4, 10, 14) are
always report-only. Orders at Incomplete or Awaiting Payment (status_id 0
or 7) are eligible for a guarded, opt-in repair, but this script only
recommends it, it never PUTs a recomputed discount_amount directly onto an
order, because that risks double-charging refunds and taxes.
Guide: https://www.allanninal.dev/bigcommerce/overridden-order-pricing-excludes-promotions/
"""
import csv
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_overridden_pricing_promotions")
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"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
REPORT_PATH = os.environ.get("REPORT_PATH", "promo_override_report.json")
ALWAYS_SKIP_STATUS_IDS = {2, 3, 4, 10, 14}
PRE_CAPTURE_STATUS_IDS = {0, 7}
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(base, path, params=None):
r = requests.get(f"{base}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
if not r.text:
return []
return r.json()
def flag_missing_promotion(order: dict, line_items: list, order_coupons: list, active_promotions: list):
"""Pure decision logic. No network, no side effects.
order: {"id", "discount_amount", "coupon_discount", "subtotal_ex_tax",
"base_total_ex_tax", "customer_group_id", "date_created"}
line_items: [{"product_id", "price_ex_tax", "base_price", "applied_discounts": [...]}]
order_coupons: [] or [{"code", "amount", "type"}]
active_promotions: [{"id", "redemption_type", "rules": [...], "current_days_and_times": {...}}]
Returns None if no discrepancy, else a flag dict:
{"order_id", "reason", "has_price_override", "expected_promo_ids"}
"""
has_price_override = any(
li.get("price_ex_tax") is not None and li.get("base_price") is not None
and li["price_ex_tax"] != li["base_price"]
for li in line_items
)
no_discount_recorded = (
float(order.get("discount_amount", "0") or 0) == 0
and float(order.get("coupon_discount", "0") or 0) == 0
and not order_coupons
and not any(li.get("applied_discounts") for li in line_items)
)
if not (has_price_override and no_discount_recorded):
return None
eligible_promo_ids = [
p["id"] for p in active_promotions
if p.get("redemption_type") == "AUTOMATIC"
]
if not eligible_promo_ids:
return None
return {
"order_id": order["id"],
"reason": "price_override_excluded_from_active_automatic_promotion",
"has_price_override": has_price_override,
"expected_promo_ids": eligible_promo_ids,
}
def recommended_action(order_status_id):
if order_status_id in ALWAYS_SKIP_STATUS_IDS:
return "flag_only_settled_order"
if order_status_id in PRE_CAPTURE_STATUS_IDS:
return "flag_or_guarded_repair_pre_capture"
return "flag_only"
def override_amount(order, line_items):
total = 0.0
for li in line_items:
price_override = li.get("price_ex_tax")
base_price = li.get("base_price")
if price_override is not None and base_price is not None and price_override != base_price:
try:
total += abs(float(price_override) - float(base_price))
except (TypeError, ValueError):
continue
return round(total, 2)
def candidate_orders():
page = 1
while True:
orders = bc_get(
V2_BASE,
"/orders",
{"min_date_created": f"-{LOOKBACK_DAYS} days", "page": page, "limit": 50},
)
if not orders:
return
for order in orders:
yield order
page += 1
def order_line_items(order_id):
return bc_get(V2_BASE, f"/orders/{order_id}/products")
def order_coupons(order_id):
return bc_get(V2_BASE, f"/orders/{order_id}/coupons")
def active_automatic_promotions():
resp = bc_get(V3_BASE, "/promotions", {"status": "ENABLED"})
return resp.get("data", []) if isinstance(resp, dict) else resp
def write_report(rows, path):
with open(path, "w", encoding="utf-8") as f:
json.dump(rows, f, indent=2)
if path.endswith(".json"):
csv_path = path[: -len(".json")] + ".csv"
with open(csv_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(
f, fieldnames=["order_id", "expected_promo_ids", "override_amount", "recommended_action"]
)
writer.writeheader()
for row in rows:
writer.writerow({**row, "expected_promo_ids": ";".join(str(p) for p in row["expected_promo_ids"])})
def run():
active_promotions = active_automatic_promotions()
report_rows = []
for order in candidate_orders():
order_id = order["id"]
line_items = order_line_items(order_id)
coupons = order_coupons(order_id)
flag = flag_missing_promotion(order, line_items, coupons, active_promotions)
if not flag:
continue
status_id = order.get("status_id")
action = recommended_action(status_id)
row = {
"order_id": order_id,
"expected_promo_ids": flag["expected_promo_ids"],
"override_amount": override_amount(order, line_items),
"recommended_action": action,
}
report_rows.append(row)
log.warning(
"order_id=%s status_id=%s override_amount=%s expected_promo_ids=%s action=%s (%s)",
order_id, status_id, row["override_amount"], flag["expected_promo_ids"], action,
"dry run, report only" if DRY_RUN else "reported, no write performed",
)
write_report(report_rows, REPORT_PATH)
log.info("Done. %d order(s) flagged. Report written to %s", len(report_rows), REPORT_PATH)
if __name__ == "__main__":
run()
/**
* Flag BigCommerce orders where a manually overridden line-item price
* silently excluded the order from an active, matching automatic promotion.
*
* BigCommerce's pricing engine only evaluates promotions against catalog or
* price-list-derived prices computed by its own pricing service. When a line
* item is created with an explicit price_ex_tax or price_inc_tax override,
* through the V2 Orders API's server-to-server order creation or the
* Cart/Checkout Server-to-Server APIs, that price is a manually set custom
* price, not a catalog price. By default, automatic and coupon promotions
* skip line items with custom pricing. A store-level setting, "Allow
* promotions to apply on products with custom price overrides" under
* Settings, Promotions and coupons, has to be turned on before the promotion
* engine will consider those line items. Leave it off, the default, and any
* order built through a price-override integration silently gets $0 promo
* discount even when an active, matching automatic promotion exists.
*
* This is not safely auto-fixable as a write against a settled order, so the
* default action is flag and report, never a direct discount rewrite.
*
* Guide: https://www.allanninal.dev/bigcommerce/overridden-order-pricing-excludes-promotions/
*/
import { writeFileSync } from "node:fs";
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REPORT_PATH = process.env.REPORT_PATH || "promo_override_report.json";
const ALWAYS_SKIP_STATUS_IDS = new Set([2, 3, 4, 10, 14]);
const PRE_CAPTURE_STATUS_IDS = new Set([0, 7]);
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision logic. No network, no side effects.
*
* order: {id, discount_amount, coupon_discount, subtotal_ex_tax,
* base_total_ex_tax, customer_group_id, date_created, status_id}
* lineItems: [{product_id, price_ex_tax, base_price, applied_discounts: [...]}]
* orderCoupons: [] or [{code, amount, type}]
* activePromotions: [{id, redemption_type, rules: [...], current_days_and_times: {...}}]
*
* Returns null if no discrepancy, else a flag object:
* {order_id, reason, has_price_override, expected_promo_ids}
*/
export function flagMissingPromotion(order, lineItems, orderCoupons, activePromotions) {
const hasPriceOverride = (lineItems || []).some(
(li) =>
li.price_ex_tax !== undefined && li.price_ex_tax !== null
&& li.base_price !== undefined && li.base_price !== null
&& li.price_ex_tax !== li.base_price
);
const noDiscountRecorded = (
Number.parseFloat(order.discount_amount || "0") === 0
&& Number.parseFloat(order.coupon_discount || "0") === 0
&& (orderCoupons || []).length === 0
&& !(lineItems || []).some((li) => li.applied_discounts && li.applied_discounts.length)
);
if (!(hasPriceOverride && noDiscountRecorded)) return null;
const eligiblePromoIds = (activePromotions || [])
.filter((p) => p.redemption_type === "AUTOMATIC")
.map((p) => p.id);
if (!eligiblePromoIds.length) return null;
return {
order_id: order.id,
reason: "price_override_excluded_from_active_automatic_promotion",
has_price_override: hasPriceOverride,
expected_promo_ids: eligiblePromoIds,
};
}
function recommendedAction(orderStatusId) {
if (ALWAYS_SKIP_STATUS_IDS.has(orderStatusId)) return "flag_only_settled_order";
if (PRE_CAPTURE_STATUS_IDS.has(orderStatusId)) return "flag_or_guarded_repair_pre_capture";
return "flag_only";
}
function overrideAmount(order, lineItems) {
let total = 0;
for (const li of lineItems || []) {
const priceOverride = li.price_ex_tax;
const basePrice = li.base_price;
if (priceOverride !== undefined && priceOverride !== null && basePrice !== undefined && basePrice !== null && priceOverride !== basePrice) {
const diff = Math.abs(Number.parseFloat(priceOverride) - Number.parseFloat(basePrice));
if (Number.isFinite(diff)) total += diff;
}
}
return Math.round(total * 100) / 100;
}
async function bcGet(base, path, params = {}) {
const url = new URL(`${base}${path}`);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : [];
}
async function* candidateOrders() {
let page = 1;
while (true) {
const orders = await bcGet(V2_BASE, "/orders", {
min_date_created: `-${LOOKBACK_DAYS} days`,
page,
limit: 50,
});
if (!orders.length) return;
for (const order of orders) yield order;
page += 1;
}
}
async function orderLineItems(orderId) {
return bcGet(V2_BASE, `/orders/${orderId}/products`);
}
async function orderCoupons(orderId) {
return bcGet(V2_BASE, `/orders/${orderId}/coupons`);
}
async function activeAutomaticPromotions() {
const resp = await bcGet(V3_BASE, "/promotions", { status: "ENABLED" });
return Array.isArray(resp) ? resp : resp.data || [];
}
function writeReport(rows, path) {
writeFileSync(path, JSON.stringify(rows, null, 2));
if (path.endsWith(".json")) {
const csvPath = path.slice(0, -".json".length) + ".csv";
const header = "order_id,expected_promo_ids,override_amount,recommended_action\n";
const lines = rows.map(
(r) => `${r.order_id},"${r.expected_promo_ids.join(";")}",${r.override_amount},${r.recommended_action}`
);
writeFileSync(csvPath, header + lines.join("\n") + (lines.length ? "\n" : ""));
}
}
export async function run() {
const activePromotions = await activeAutomaticPromotions();
const reportRows = [];
for await (const order of candidateOrders()) {
const orderId = order.id;
const lineItems = await orderLineItems(orderId);
const coupons = await orderCoupons(orderId);
const flag = flagMissingPromotion(order, lineItems, coupons, activePromotions);
if (!flag) continue;
const statusId = order.status_id;
const action = recommendedAction(statusId);
const row = {
order_id: orderId,
expected_promo_ids: flag.expected_promo_ids,
override_amount: overrideAmount(order, lineItems),
recommended_action: action,
};
reportRows.push(row);
console.warn(
`order_id=${orderId} status_id=${statusId} override_amount=${row.override_amount} ` +
`expected_promo_ids=${flag.expected_promo_ids.join(",")} action=${action} ` +
`(${DRY_RUN ? "dry run, report only" : "reported, no write performed"})`
);
}
writeReport(reportRows, REPORT_PATH);
console.log(`Done. ${reportRows.length} order(s) flagged. Report written to ${REPORT_PATH}`);
}
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 which orders get surfaced for a human to look at. Because flag_missing_promotion takes only plain values and returns a plain dict or None, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.
from flag_overridden_pricing_promotions import flag_missing_promotion
def base_order(**overrides):
order = {
"id": 501,
"discount_amount": "0.00",
"coupon_discount": "0.00",
"subtotal_ex_tax": "80.00",
"base_total_ex_tax": "100.00",
"customer_group_id": 0,
"date_created": "2026-07-01",
}
order.update(overrides)
return order
def override_line_item(price_ex_tax="80.00", base_price="100.00", applied_discounts=None):
return {
"product_id": 42,
"price_ex_tax": price_ex_tax,
"base_price": base_price,
"applied_discounts": applied_discounts or [],
}
def automatic_promo(promo_id=9001):
return {"id": promo_id, "redemption_type": "AUTOMATIC", "rules": [], "current_days_and_times": {}}
def test_flags_override_with_no_discount_and_active_automatic_promo():
flag = flag_missing_promotion(
base_order(), [override_line_item()], [], [automatic_promo()]
)
assert flag == {
"order_id": 501,
"reason": "price_override_excluded_from_active_automatic_promotion",
"has_price_override": True,
"expected_promo_ids": [9001],
}
def test_no_flag_when_price_matches_base_price():
line_items = [override_line_item(price_ex_tax="100.00", base_price="100.00")]
assert flag_missing_promotion(base_order(), line_items, [], [automatic_promo()]) is None
def test_no_flag_when_discount_amount_is_recorded():
order = base_order(discount_amount="10.00")
assert flag_missing_promotion(order, [override_line_item()], [], [automatic_promo()]) is None
def test_no_flag_when_order_coupons_present():
coupons = [{"code": "SAVE10", "amount": "10.00", "type": "percentage_discount"}]
assert flag_missing_promotion(base_order(), [override_line_item()], coupons, [automatic_promo()]) is None
def test_no_flag_when_line_item_has_applied_discounts():
line_items = [override_line_item(applied_discounts=[{"amount": "5.00"}])]
assert flag_missing_promotion(base_order(), line_items, [], [automatic_promo()]) is None
def test_no_flag_when_no_active_automatic_promotion_exists():
coupon_only_promo = {"id": 1, "redemption_type": "COUPON", "rules": [], "current_days_and_times": {}}
assert flag_missing_promotion(base_order(), [override_line_item()], [], [coupon_only_promo]) is None
def test_no_flag_when_no_price_override_present():
line_items = [{"product_id": 42, "price_ex_tax": None, "base_price": "100.00", "applied_discounts": []}]
assert flag_missing_promotion(base_order(), line_items, [], [automatic_promo()]) is None
import { test } from "node:test";
import assert from "node:assert/strict";
import { flagMissingPromotion } from "./flag-overridden-pricing-promotions.js";
const baseOrder = (overrides = {}) => ({
id: 501,
discount_amount: "0.00",
coupon_discount: "0.00",
subtotal_ex_tax: "80.00",
base_total_ex_tax: "100.00",
customer_group_id: 0,
date_created: "2026-07-01",
...overrides,
});
const overrideLineItem = ({ price_ex_tax = "80.00", base_price = "100.00", applied_discounts = [] } = {}) => ({
product_id: 42,
price_ex_tax,
base_price,
applied_discounts,
});
const automaticPromo = (id = 9001) => ({ id, redemption_type: "AUTOMATIC", rules: [], current_days_and_times: {} });
test("flags override with no discount and active automatic promo", () => {
const flag = flagMissingPromotion(baseOrder(), [overrideLineItem()], [], [automaticPromo()]);
assert.deepEqual(flag, {
order_id: 501,
reason: "price_override_excluded_from_active_automatic_promotion",
has_price_override: true,
expected_promo_ids: [9001],
});
});
test("no flag when price matches base price", () => {
const lineItems = [overrideLineItem({ price_ex_tax: "100.00", base_price: "100.00" })];
assert.equal(flagMissingPromotion(baseOrder(), lineItems, [], [automaticPromo()]), null);
});
test("no flag when discount_amount is recorded", () => {
const order = baseOrder({ discount_amount: "10.00" });
assert.equal(flagMissingPromotion(order, [overrideLineItem()], [], [automaticPromo()]), null);
});
test("no flag when order coupons present", () => {
const coupons = [{ code: "SAVE10", amount: "10.00", type: "percentage_discount" }];
assert.equal(flagMissingPromotion(baseOrder(), [overrideLineItem()], coupons, [automaticPromo()]), null);
});
test("no flag when line item has applied_discounts", () => {
const lineItems = [overrideLineItem({ applied_discounts: [{ amount: "5.00" }] })];
assert.equal(flagMissingPromotion(baseOrder(), lineItems, [], [automaticPromo()]), null);
});
test("no flag when no active automatic promotion exists", () => {
const couponOnlyPromo = { id: 1, redemption_type: "COUPON", rules: [], current_days_and_times: {} };
assert.equal(flagMissingPromotion(baseOrder(), [overrideLineItem()], [], [couponOnlyPromo]), null);
});
test("no flag when no price override present", () => {
const lineItems = [{ product_id: 42, price_ex_tax: null, base_price: "100.00", applied_discounts: [] }];
assert.equal(flagMissingPromotion(baseOrder(), lineItems, [], [automaticPromo()]), null);
});
Case studies
The custom quote integration that quietly starved every order of its promo
A B2B sales team used a quoting tool that created BigCommerce orders server-to-server, setting a negotiated price_ex_tax on every line item rather than letting the catalog price it. A storewide automatic promotion had been running for a month for a seasonal push. Every single quote-tool order came through with discount_amount at zero, and nobody noticed because the negotiated price already looked like a good deal.
The scan flagged every one of those orders in the first run: price override present, no discount recorded, an active automatic promotion matching. The store had never enabled Allow promotions to apply on products with custom price overrides, so the fix was a one-click setting change plus a manual credit for the orders that had already gone out.
The marketplace sync that set prices it had already discounted upstream
A marketplace integration imported orders with prices that had already been discounted on the marketplace's own side, so it deliberately set an explicit override to avoid a double discount. That was correct for most of the catalog. But one line of new-arrival products was also covered by a fresh in-store automatic promotion the marketplace price predated.
Because the scan only flags orders with both an override and zero recorded discount against a currently active automatic promotion, it caught just that narrow slice, not the whole marketplace feed, which let the team fix the specific promotion's product scope without touching the intentional override behavior everywhere else.
After this runs on a schedule, no override-priced order sits invisibly excluded from a promotion that should have applied to it. Every mismatch shows up in a report with the order id, the promotion ids it should have matched, the size of the override, and a clear recommended action, settled orders get flagged for a human, pre-capture orders get flagged with the option of a guarded, reviewed repair, and nothing gets a blind discount rewrite.
FAQ
Why does my overridden order price not get a promotion discount?
When a line item is created with an explicit price_ex_tax or price_inc_tax override, through the V2 Orders API or the Cart/Checkout Server-to-Server APIs, BigCommerce treats that price as a manually set custom price rather than a catalog price. By default, automatic and coupon promotions skip line items with custom pricing so the merchant's override is not unexpectedly discounted further. A store-level setting has to be turned on before the promotion engine will even look at those line items.
Is there a setting that makes promotions apply to custom-priced line items?
Yes. Under Settings, Promotions and coupons, there is a control called Allow promotions to apply on products with custom price overrides. It is off by default. Until a merchant explicitly enables it, every order built through a price-override integration silently receives zero promo discount even when an active, matching automatic promotion exists.
Can I safely rewrite the discount on an order that already shipped this way?
No, not as a direct write against a settled order. Recalculating and applying a discount after the fact risks double-charging refunds and taxes and can conflict with a payment that is already captured. The safe default is to flag the order for manual review. The only guarded repair is for pre-capture orders still at Awaiting Payment or Incomplete, and even then the supported route is to void and re-create the order through the Cart or Checkout API so BigCommerce's own pricing service computes the discount, not a direct PUT of the discount amount.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: the V2 Orders API, order creation, and price fields on line items. developer.bigcommerce.com orders
- BigCommerce Developer Center: Price Order of Operations, how prices are computed and where overrides fit. developer.bigcommerce.com pricing order of operations
- BigCommerce Support: overriding cart-level discounts alongside a coupon. support.bigcommerce.com override cart-level discount with coupon
On the solution:
- BigCommerce Developer Center: the Promotions API, redemption_type, and rule scope. developer.bigcommerce.com promotions
- BigCommerce Developer Center: the V2 Orders API, again, for the order and line-item fields the decision reads. developer.bigcommerce.com orders
- BigCommerce Developer Center: the order coupons sub-resource used to detect an applied coupon. developer.bigcommerce.com order coupons
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, promotions, webhooks, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this catch a silently unapplied promotion?
If this saved you from writing off a customer complaint as "not eligible" or caught a promo gap you would have otherwise missed, 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