Diagnostic Orders
Order update call recalculates and overwrites a coupon adjusted total
A customer checks out with a coupon applied. Weeks later, someone updates the order's staff notes, or a shipping cost, through PUT /v2/orders/{id}. The unrelated field saves fine, but the coupon discount is gone and the total has quietly grown back toward the pre-discount price. BigCommerce has no writable coupon_discount field, so a PUT that touches any total-affecting property recalculates the order from its line items and cost fields and clears the discounts that were riding on top. Here is why that gap opens up and a script that detects and reports it, without guessing at an unsafe auto-fix.
BigCommerce's V2 Orders API treats coupon_discount as a read-only, server-derived value, calculated from the /v2/orders/{id}/coupons sub-resource and each line item's applied_discounts, not stored as an independently editable field on the order record. When a PUT /v2/orders/{id} changes any total-affecting property, line items, subtotal_ex_tax/subtotal_inc_tax, total_ex_tax/total_inc_tax, shipping, handling, wrapping, or fees, BigCommerce recalculates the subtotal and total fields from the current line items and cost fields, and per BigCommerce's own documentation the PUT request clears all discounts and promotions applied to the changed order line items. Because there is no writable coupon_discount field to resend, a PUT aimed at something as unrelated as staff_notes can silently zero out a previously applied coupon discount. Run a small Python or Node.js script that snapshots each order's coupon state right after checkout, then on a schedule diffs the live order against that snapshot and the /v2/orders/{id}/coupons sub-resource to flag exactly which orders lost their discount and by how much. Full code, tests, and a dry run guard are below.
The problem in plain words
When an order is placed with a coupon, BigCommerce records the coupon's effect in two places: the /v2/orders/{id}/coupons sub-resource, which lists the coupon code, its amount, and its discount type, and the order's own coupon_discount field, which reflects that discount in the order total. The order's coupon_discount looks like a normal field when you read it back from GET /v2/orders/{id}, so it is easy to assume it behaves like any other stored value that survives an update to some other part of the order.
It does not. coupon_discount is computed on the way out, the same way a read-only label is. There is no matching writable field on the PUT payload for it. So when an integration or a staff member sends a PUT to change something that has nothing to do with the discount, a shipping cost adjustment, a customer message, an internal note, BigCommerce still has to recompute the order's totals because that PUT touched a total-affecting property. It rebuilds subtotal_ex_tax, subtotal_inc_tax, total_ex_tax, and total_inc_tax from the current line items and cost fields, and BigCommerce's own documentation says plainly that the PUT request clears all discounts and promotions applied to the changed order line items. The coupon sub-resource may still list the code as active, but the live order's coupon_discount has dropped to zero or shrunk, and the total has crept back up without anyone touching pricing on purpose.
Why it happens
This is not a bug so much as a consequence of how BigCommerce models order totals. A few common ways stores end up with a coupon silently stripped from a completed order:
- A support agent or internal tool sends
PUT /v2/orders/{id}with only{"staff_notes": "..."}or{"customer_message": "..."}, not realizing that other total-affecting fields on the same PUT call trigger a full recalculation from line items. - An integration adjusts
shipping_cost_ex_tax,handling_cost_ex_tax, or a fee on the order after checkout, for example to reflect an actual carrier rate, without also resending matchingtotal_ex_tax/total_inc_taxvalues that account for the coupon. - A line item edit, quantity change, price override, or add/remove, is made through the API, which BigCommerce's own docs confirm clears discounts and promotions on the changed line items.
- There is no writable
coupon_discountfield at all, so even a well-intentioned caller trying to "preserve" the discount has no field to resend it in; the only lever that works is resending the total pair.
Because the order's status and date_modified both look normal after this kind of PUT, and the coupon sub-resource can still list the coupon as technically "on file," nothing about the order record shouts that the discount silently disappeared from the customer-facing total. See the citations at the end for the exact API reference and the community thread describing this behavior.
The order's coupon_discount field is a snapshot of a calculation, not a stored fact. The /v2/orders/{id}/coupons sub-resource is the source of truth for what discount should still be applied. So the safe pattern is not "try to write coupon_discount back," there is no such field, it is "keep a snapshot of the known-good state right after checkout, then periodically diff the live order and the coupons sub-resource against that snapshot to catch the moment a PUT wiped the discount out." We report every affected order with the expected versus observed discount delta and the coupon codes involved, and we never auto-rewrite totals unless an operator explicitly opts in.
The fix, as a flow
We do not touch checkout or the order-update flow. We add a reconciliation job that lists recently modified orders, reads each one's current totals and its coupons sub-resource, compares that against a stored snapshot from right after the order was created, and flags any order where the coupon discount has gone missing without a matching drop in the total.
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 (modify) scope so it can read coupons and, if you later opt in to writes, update totals. 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, report only by default
// 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, report only by default
Talk to the V2 Orders REST API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v2/ with the token in the X-Auth-Token header. A small helper handles GET and PUT and raises on a non-2xx response. We reuse it to list orders, read the coupons sub-resource, and, only under an explicit opt-in, write the corrected total pair.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else []
def bc_put(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGet(path, params = {}) {
const url = new URL(`${API_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 bcPut(path, body) {
const res = await fetch(`${API_BASE}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
List modified orders and read each one's coupons
Call GET /v2/orders?min_date_modified={since}&limit=250, paginated via page, to get orders touched since the last run. For each one, call GET /v2/orders/{id} for coupon_discount, discount_amount, subtotal_ex_tax, total_ex_tax, total_inc_tax, and date_modified, then call GET /v2/orders/{id}/coupons for the coupon sub-resource, the source of truth for what discount should still be applied.
def modified_orders(since_iso):
page = 1
while True:
orders = bc_get("/orders", {
"min_date_modified": since_iso,
"page": page,
"limit": 250,
})
if not orders:
return
for order in orders:
yield order
page += 1
def order_coupons(order_id):
return bc_get(f"/orders/{order_id}/coupons")
async function* modifiedOrders(sinceIso) {
let page = 1;
while (true) {
const orders = await bcGet("/orders", {
min_date_modified: sinceIso,
page,
limit: 250,
});
if (!orders.length) return;
for (const order of orders) yield order;
page += 1;
}
}
async function orderCoupons(orderId) {
return bcGet(`/orders/${orderId}/coupons`);
}
Decide, with one pure function
Keep the decision in its own function that takes a stored snapshot, the live order, and the active coupons, and returns whether the order is corrupted. It compares the expected discount, the sum of every active coupon's discount, against what actually happened to coupon_discount and the total. A drop in coupon_discount paired with a total that did not fall by roughly the same amount means the discount was recalculated away, not legitimately superseded by an unrelated line-item change.
from decimal import Decimal
RECONCILE_TOLERANCE = Decimal("0.99")
def detect_coupon_overwrite(snapshot, live, active_coupons):
expected_discount = sum((c["discount"] for c in active_coupons), Decimal("0"))
if expected_discount <= 0:
return {"order_id": live["order_id"], "is_corrupted": False,
"expected_discount": expected_discount, "observed_discount": live["coupon_discount"],
"delta_missing": Decimal("0")}
if live["coupon_discount"] >= snapshot["coupon_discount"]:
return {"order_id": live["order_id"], "is_corrupted": False,
"expected_discount": expected_discount, "observed_discount": live["coupon_discount"],
"delta_missing": Decimal("0")}
if live["date_modified"] == snapshot["date_modified"]:
return {"order_id": live["order_id"], "is_corrupted": False,
"expected_discount": expected_discount, "observed_discount": live["coupon_discount"],
"delta_missing": Decimal("0")}
delta = snapshot["total_inc_tax"] - live["total_inc_tax"]
if delta < expected_discount * RECONCILE_TOLERANCE:
delta_missing = expected_discount - (snapshot["coupon_discount"] - live["coupon_discount"])
return {"order_id": live["order_id"], "is_corrupted": True,
"expected_discount": expected_discount, "observed_discount": live["coupon_discount"],
"delta_missing": delta_missing}
return {"order_id": live["order_id"], "is_corrupted": False,
"expected_discount": expected_discount, "observed_discount": live["coupon_discount"],
"delta_missing": Decimal("0")}
const RECONCILE_TOLERANCE = 0.99;
export function detectCouponOverwrite(snapshot, live, activeCoupons) {
const expectedDiscount = activeCoupons.reduce((sum, c) => sum + c.discount, 0);
if (expectedDiscount <= 0) {
return { orderId: live.orderId, isCorrupted: false, expectedDiscount, observedDiscount: live.couponDiscount, deltaMissing: 0 };
}
if (live.couponDiscount >= snapshot.couponDiscount) {
return { orderId: live.orderId, isCorrupted: false, expectedDiscount, observedDiscount: live.couponDiscount, deltaMissing: 0 };
}
if (live.dateModified === snapshot.dateModified) {
return { orderId: live.orderId, isCorrupted: false, expectedDiscount, observedDiscount: live.couponDiscount, deltaMissing: 0 };
}
const delta = snapshot.totalIncTax - live.totalIncTax;
if (delta < expectedDiscount * RECONCILE_TOLERANCE) {
const deltaMissing = expectedDiscount - (snapshot.couponDiscount - live.couponDiscount);
return { orderId: live.orderId, isCorrupted: true, expectedDiscount, observedDiscount: live.couponDiscount, deltaMissing };
}
return { orderId: live.orderId, isCorrupted: false, expectedDiscount, observedDiscount: live.couponDiscount, deltaMissing: 0 };
}
Report, never auto-fix, unless explicitly opted in
By default this job only logs the affected order id, the expected versus observed discount, and the coupon codes involved. There is no writable coupon_discount field, so forcing total_ex_tax/total_inc_tax back down risks double-adjusting tax or shipping math, or conflicting with payment and refund records already captured under /v2/orders/{id}/transactions. The one sanctioned corrective action, gated behind an explicit --allow-write flag, is a single PUT that resends the last known-good total_ex_tax and total_inc_tax together, never a partial update, followed by a GET to confirm the fix before marking the order reconciled.
def reapply_known_good_totals(order_id, total_ex_tax, total_inc_tax):
# Only ever called under an explicit --allow-write flag, never by default.
return bc_put(f"/orders/{order_id}", {
"total_ex_tax": str(total_ex_tax),
"total_inc_tax": str(total_inc_tax),
})
async function reapplyKnownGoodTotals(orderId, totalExTax, totalIncTax) {
// Only ever called under an explicit --allow-write flag, never by default.
return bcPut(`/orders/${orderId}`, {
total_ex_tax: String(totalExTax),
total_inc_tax: String(totalIncTax),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. With DRY_RUN=true, the default, the script only logs the {order_id, expected_discount, observed_discount, delta_missing, coupon codes} tuple for every order it flags. Read the output, verify it against a few orders in the admin, and only pass --allow-write once you trust the reconciliation. Run it on a schedule that matches how often staff or integrations touch existing orders, for example once a day.
Always start with DRY_RUN=true, and never resend a partial total update. If you do opt into writes, only send total_ex_tax and total_inc_tax together, computed from the untouched line items plus the original coupon discount, and always re-fetch the order afterward to confirm coupon_discount and the totals match the snapshot before calling it reconciled.
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 by default only reports the orders where a coupon discount was silently recalculated away, leaving the actual write for an explicit opt-in.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Detect BigCommerce orders whose coupon discount was silently recalculated away.
BigCommerce's V2 Orders API treats coupon_discount as a read-only, server-derived
value, calculated from the /v2/orders/{id}/coupons sub-resource and each line
item's applied_discounts, not stored as an independently editable field on the
order record. When a PUT to /v2/orders/{id} changes any total-affecting property,
line items, subtotal_ex_tax/subtotal_inc_tax, total_ex_tax/total_inc_tax, shipping,
handling, wrapping, or fees, BigCommerce recalculates the subtotal and total
fields from the current line items and cost fields, and per BigCommerce's own
documentation the PUT request clears all discounts and promotions applied to the
changed order line items. Because there is no writable coupon_discount field to
resend, a PUT aimed at an unrelated field can silently zero out or shrink a
previously applied coupon discount. This job diffs each modified order against a
stored known-good snapshot and the live coupons sub-resource, and reports the
orders where the discount no longer reconciles. Report only by default. Run on a
schedule. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/order-update-overwrites-coupon-total/
"""
import os
import logging
from decimal import Decimal
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_coupon_overwrite")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "14"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
RECONCILE_TOLERANCE = Decimal("0.99")
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
if not r.text:
return []
return r.json()
def bc_put(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def detect_coupon_overwrite(snapshot: dict, live: dict, active_coupons: list) -> dict:
"""Pure decision. No network, no side effects.
snapshot/live = {order_id, coupon_discount, total_inc_tax, total_ex_tax, date_modified}
active_coupons = list of {code, discount, type}
expected_discount = sum of every active coupon's discount. If there is no
active discount expected, or the live coupon_discount did not drop below
the snapshot, or nothing has actually changed (same date_modified), the
order is not corrupted. Otherwise, compare how much the total actually
fell against how much the coupon discount alone should account for. If
the total fell by meaningfully less than the expected discount, the
discount was recalculated away rather than legitimately superseded by an
unrelated line-item change, so the order is flagged as corrupted with the
missing delta.
"""
expected_discount = sum((c["discount"] for c in active_coupons), Decimal("0"))
result = {
"order_id": live["order_id"],
"is_corrupted": False,
"expected_discount": expected_discount,
"observed_discount": live["coupon_discount"],
"delta_missing": Decimal("0"),
}
if expected_discount <= 0:
return result
if live["coupon_discount"] >= snapshot["coupon_discount"]:
return result
if live["date_modified"] == snapshot["date_modified"]:
return result
delta = snapshot["total_inc_tax"] - live["total_inc_tax"]
if delta < expected_discount * RECONCILE_TOLERANCE:
result["is_corrupted"] = True
result["delta_missing"] = expected_discount - (
snapshot["coupon_discount"] - live["coupon_discount"]
)
return result
def modified_orders():
"""Page through orders modified within the lookback window."""
page = 1
while True:
orders = bc_get(
"/orders",
{
"min_date_modified": f"-{LOOKBACK_DAYS} days",
"page": page,
"limit": 250,
},
)
if not orders:
return
for order in orders:
yield order
page += 1
def order_coupons(order_id):
return bc_get(f"/orders/{order_id}/coupons")
def load_snapshot(order_id):
"""Placeholder for your local snapshot store, keyed by order id, recorded
at the last known-good state (e.g. right after the store/order/created
webhook). Replace with a real database or file lookup."""
return None
def reapply_known_good_totals(order_id, total_ex_tax, total_inc_tax):
# Only ever called under an explicit --allow-write flag, never by default.
return bc_put(
f"/orders/{order_id}",
{"total_ex_tax": str(total_ex_tax), "total_inc_tax": str(total_inc_tax)},
)
def run(allow_write=False):
flagged = 0
checked = 0
for order in modified_orders():
order_id = order["id"]
snapshot = load_snapshot(order_id)
if snapshot is None:
continue
checked += 1
coupons = order_coupons(order_id)
active_coupons = [
{"code": c.get("code"), "discount": Decimal(str(c.get("discount", "0"))), "type": c.get("type")}
for c in coupons or []
]
live = {
"order_id": order_id,
"coupon_discount": Decimal(str(order.get("coupon_discount", "0"))),
"total_inc_tax": Decimal(str(order.get("total_inc_tax", "0"))),
"total_ex_tax": Decimal(str(order.get("total_ex_tax", "0"))),
"date_modified": order.get("date_modified"),
}
result = detect_coupon_overwrite(snapshot, live, active_coupons)
if not result["is_corrupted"]:
continue
codes = ", ".join(c["code"] for c in active_coupons if c.get("code"))
log.warning(
"order_id=%s coupon overwrite detected. expected_discount=%s observed_discount=%s "
"delta_missing=%s coupons=%s",
order_id, result["expected_discount"], result["observed_discount"],
result["delta_missing"], codes,
)
flagged += 1
if allow_write and not DRY_RUN:
reapply_known_good_totals(
order_id, snapshot["total_ex_tax"], snapshot["total_inc_tax"]
)
confirm = bc_get(f"/orders/{order_id}")
confirmed = Decimal(str(confirm.get("coupon_discount", "0"))) >= snapshot["coupon_discount"]
log.info("order_id=%s reconciled=%s", order_id, confirmed)
log.info("Done. %d order(s) checked, %d order(s) flagged for a wiped coupon discount.", checked, flagged)
if __name__ == "__main__":
run(allow_write=os.environ.get("ALLOW_WRITE", "false").lower() == "true")
/**
* Detect BigCommerce orders whose coupon discount was silently recalculated away.
*
* BigCommerce's V2 Orders API treats coupon_discount as a read-only, server-derived
* value, calculated from the /v2/orders/{id}/coupons sub-resource and each line
* item's applied_discounts, not stored as an independently editable field on the
* order record. When a PUT to /v2/orders/{id} changes any total-affecting property,
* line items, subtotal_ex_tax/subtotal_inc_tax, total_ex_tax/total_inc_tax, shipping,
* handling, wrapping, or fees, BigCommerce recalculates the subtotal and total
* fields from the current line items and cost fields, and per BigCommerce's own
* documentation the PUT request clears all discounts and promotions applied to the
* changed order line items. Because there is no writable coupon_discount field to
* resend, a PUT aimed at an unrelated field can silently zero out or shrink a
* previously applied coupon discount. This job diffs each modified order against a
* stored known-good snapshot and the live coupons sub-resource, and reports the
* orders where the discount no longer reconciles. Report only by default.
*
* Guide: https://www.allanninal.dev/bigcommerce/order-update-overwrites-coupon-total/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ALLOW_WRITE = (process.env.ALLOW_WRITE || "false").toLowerCase() === "true";
const RECONCILE_TOLERANCE = 0.99;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* snapshot/live = {orderId, couponDiscount, totalIncTax, totalExTax, dateModified}
* activeCoupons = list of {code, discount, type}
*
* expectedDiscount = sum of every active coupon's discount. If there is no
* active discount expected, or the live couponDiscount did not drop below the
* snapshot, or nothing has actually changed (same dateModified), the order is
* not corrupted. Otherwise, compare how much the total actually fell against
* how much the coupon discount alone should account for. If the total fell by
* meaningfully less than the expected discount, the discount was recalculated
* away rather than legitimately superseded by an unrelated line-item change,
* so the order is flagged as corrupted with the missing delta.
*/
export function detectCouponOverwrite(snapshot, live, activeCoupons) {
const expectedDiscount = activeCoupons.reduce((sum, c) => sum + c.discount, 0);
const result = {
orderId: live.orderId,
isCorrupted: false,
expectedDiscount,
observedDiscount: live.couponDiscount,
deltaMissing: 0,
};
if (expectedDiscount <= 0) return result;
if (live.couponDiscount >= snapshot.couponDiscount) return result;
if (live.dateModified === snapshot.dateModified) return result;
const delta = snapshot.totalIncTax - live.totalIncTax;
if (delta < expectedDiscount * RECONCILE_TOLERANCE) {
result.isCorrupted = true;
result.deltaMissing = expectedDiscount - (snapshot.couponDiscount - live.couponDiscount);
}
return result;
}
async function bcGet(path, params = {}) {
const url = new URL(`${API_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 bcPut(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function* modifiedOrders() {
let page = 1;
while (true) {
const orders = await bcGet("/orders", {
min_date_modified: `-${LOOKBACK_DAYS} days`,
page,
limit: 250,
});
if (!orders.length) return;
for (const order of orders) yield order;
page += 1;
}
}
async function orderCoupons(orderId) {
return bcGet(`/orders/${orderId}/coupons`);
}
function loadSnapshot(_orderId) {
// Placeholder for your local snapshot store, keyed by order id, recorded at
// the last known-good state (e.g. right after the store/order/created
// webhook). Replace with a real database or file lookup.
return null;
}
async function reapplyKnownGoodTotals(orderId, totalExTax, totalIncTax) {
// Only ever called under an explicit --allow-write flag, never by default.
return bcPut(`/orders/${orderId}`, {
total_ex_tax: String(totalExTax),
total_inc_tax: String(totalIncTax),
});
}
export async function run(allowWrite = ALLOW_WRITE) {
let flagged = 0;
let checked = 0;
for await (const order of modifiedOrders()) {
const orderId = order.id;
const snapshot = loadSnapshot(orderId);
if (!snapshot) continue;
checked += 1;
const coupons = await orderCoupons(orderId);
const activeCoupons = (coupons || []).map((c) => ({
code: c.code,
discount: Number.parseFloat(c.discount || "0"),
type: c.type,
}));
const live = {
orderId,
couponDiscount: Number.parseFloat(order.coupon_discount || "0"),
totalIncTax: Number.parseFloat(order.total_inc_tax || "0"),
totalExTax: Number.parseFloat(order.total_ex_tax || "0"),
dateModified: order.date_modified,
};
const result = detectCouponOverwrite(snapshot, live, activeCoupons);
if (!result.isCorrupted) continue;
const codes = activeCoupons.map((c) => c.code).filter(Boolean).join(", ");
console.warn(
`order_id=${orderId} coupon overwrite detected. expected_discount=${result.expectedDiscount} ` +
`observed_discount=${result.observedDiscount} delta_missing=${result.deltaMissing} coupons=${codes}`
);
flagged += 1;
if (allowWrite && !DRY_RUN) {
await reapplyKnownGoodTotals(orderId, snapshot.totalExTax, snapshot.totalIncTax);
const confirm = await bcGet(`/orders/${orderId}`);
const confirmed = Number.parseFloat(confirm.coupon_discount || "0") >= snapshot.couponDiscount;
console.log(`order_id=${orderId} reconciled=${confirmed}`);
}
}
console.log(`Done. ${checked} order(s) checked, ${flagged} order(s) flagged for a wiped coupon discount.`);
}
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 reported as corrupted. Because detect_coupon_overwrite takes only plain snapshot and coupon values and returns a plain result, the test needs no network and no BigCommerce store. It just feeds in fixture snapshots and checks the answer.
from decimal import Decimal
from detect_coupon_overwrite import detect_coupon_overwrite
def snapshot(coupon_discount="10.00", total_inc_tax="90.00", date_modified="2026-07-01T10:00:00Z"):
return {
"order_id": 501,
"coupon_discount": Decimal(coupon_discount),
"total_inc_tax": Decimal(total_inc_tax),
"total_ex_tax": Decimal(total_inc_tax),
"date_modified": date_modified,
}
def live(coupon_discount="10.00", total_inc_tax="90.00", date_modified="2026-07-01T10:00:00Z"):
return {
"order_id": 501,
"coupon_discount": Decimal(coupon_discount),
"total_inc_tax": Decimal(total_inc_tax),
"total_ex_tax": Decimal(total_inc_tax),
"date_modified": date_modified,
}
def coupon(discount="10.00", code="SAVE10"):
return {"code": code, "discount": Decimal(discount), "type": 1}
def test_not_corrupted_when_nothing_changed():
result = detect_coupon_overwrite(snapshot(), live(), [coupon()])
assert result["is_corrupted"] is False
def test_not_corrupted_when_no_active_coupon():
result = detect_coupon_overwrite(snapshot(coupon_discount="0.00"), live(coupon_discount="0.00"), [])
assert result["is_corrupted"] is False
def test_corrupted_when_discount_wiped_but_total_unchanged():
live_order = live(coupon_discount="0.00", total_inc_tax="90.00", date_modified="2026-07-05T10:00:00Z")
result = detect_coupon_overwrite(snapshot(), live_order, [coupon()])
assert result["is_corrupted"] is True
assert result["delta_missing"] == Decimal("0.00")
def test_not_corrupted_when_total_dropped_by_the_expected_discount():
live_order = live(coupon_discount="0.00", total_inc_tax="80.00", date_modified="2026-07-05T10:00:00Z")
result = detect_coupon_overwrite(snapshot(), live_order, [coupon()])
assert result["is_corrupted"] is False
def test_not_corrupted_when_discount_increased():
live_order = live(coupon_discount="15.00", total_inc_tax="85.00", date_modified="2026-07-05T10:00:00Z")
result = detect_coupon_overwrite(snapshot(), live_order, [coupon(discount="15.00")])
assert result["is_corrupted"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectCouponOverwrite } from "./detect-coupon-overwrite.js";
const snapshot = ({ couponDiscount = 10, totalIncTax = 90, dateModified = "2026-07-01T10:00:00Z" } = {}) => ({
orderId: 501, couponDiscount, totalIncTax, totalExTax: totalIncTax, dateModified,
});
const live = ({ couponDiscount = 10, totalIncTax = 90, dateModified = "2026-07-01T10:00:00Z" } = {}) => ({
orderId: 501, couponDiscount, totalIncTax, totalExTax: totalIncTax, dateModified,
});
const coupon = ({ discount = 10, code = "SAVE10" } = {}) => ({ code, discount, type: 1 });
test("not corrupted when nothing changed", () => {
const result = detectCouponOverwrite(snapshot(), live(), [coupon()]);
assert.equal(result.isCorrupted, false);
});
test("not corrupted when no active coupon", () => {
const result = detectCouponOverwrite(
snapshot({ couponDiscount: 0 }),
live({ couponDiscount: 0 }),
[]
);
assert.equal(result.isCorrupted, false);
});
test("corrupted when discount wiped but total unchanged", () => {
const liveOrder = live({ couponDiscount: 0, totalIncTax: 90, dateModified: "2026-07-05T10:00:00Z" });
const result = detectCouponOverwrite(snapshot(), liveOrder, [coupon()]);
assert.equal(result.isCorrupted, true);
assert.equal(result.deltaMissing, 0);
});
test("not corrupted when total dropped by the expected discount", () => {
const liveOrder = live({ couponDiscount: 0, totalIncTax: 80, dateModified: "2026-07-05T10:00:00Z" });
const result = detectCouponOverwrite(snapshot(), liveOrder, [coupon()]);
assert.equal(result.isCorrupted, false);
});
test("not corrupted when discount increased", () => {
const liveOrder = live({ couponDiscount: 15, totalIncTax: 85, dateModified: "2026-07-05T10:00:00Z" });
const result = detectCouponOverwrite(snapshot(), liveOrder, [coupon({ discount: 15 })]);
assert.equal(result.isCorrupted, false);
});
Case studies
The support team that broke a discount while leaving a note
A support agent added an internal note to a customer's order using an in-house tool that sent the full order object back on every save, including recalculated total fields. The order had a 15 percent off coupon applied at checkout. After the note was saved, the customer's invoice total had quietly grown back toward full price, and nobody noticed until the customer complained about being overcharged on a partial refund calculated off the wrong total.
The reconciliation job now runs nightly. It compares every order modified that day against its checkout-time snapshot and the live coupons sub-resource. It caught this exact order the next morning, days before the refund would have gone out at the wrong amount, and reported the expected discount, the observed discount, and the coupon code so support could recalculate the refund by hand.
The fulfillment integration that corrected a shipping estimate
A warehouse integration updated shipping_cost_ex_tax on orders once the real carrier rate was known, replacing the storefront's estimate. That PUT recalculated the order totals from the current line items and cleared the coupon discount that had been applied at checkout, even though shipping was the only thing the integration meant to touch.
Because the reconciliation job treats the coupons sub-resource as the source of truth rather than trusting the order's own coupon_discount field, it flagged every affected order the same way. The fix was on the integration side, resend total_ex_tax and total_inc_tax together whenever shipping changes, but the reconciliation job is what caught the pattern before it reached a large batch of orders.
After this runs on a schedule, no order with a coupon silently loses its discount without someone finding out within a day. The report tells you exactly which orders, what discount was expected, what discount survived, and which coupon codes were involved, so a human can decide the right correction, a manual total fix, a partial refund adjustment, or a process change on the calling integration, instead of a script guessing and rewriting a completed order's totals on its own.
FAQ
Why does updating an unrelated order field wipe out a coupon discount?
BigCommerce's V2 Orders API treats coupon_discount as a read-only value calculated from the /v2/orders/{id}/coupons sub-resource and each line item's applied_discounts. There is no writable coupon_discount field to resend. When a PUT to /v2/orders/{id} changes any total-affecting property, line items, subtotal or total fields, shipping, handling, wrapping, or fees, BigCommerce recalculates the order totals from the current line items and cost fields, and per BigCommerce's own documentation the PUT request clears all discounts and promotions applied to the changed order line items.
Can I just resend the coupon_discount value on every PUT to be safe?
No. coupon_discount is not an independently writable field on the order record, so there is nothing to resend that BigCommerce will honor. The only sanctioned way to protect a total that already reflects a coupon is to include a matching total_ex_tax and total_inc_tax pair in the same PUT request, computed from the untouched line items plus the original discount, and to never send a partial total update.
Should a reconciliation script auto-fix an order once it detects a wiped-out coupon?
Treat it as report-only by default. There is no safe idempotent call to reapply a coupon on a completed order, and forcing total_ex_tax and total_inc_tax back down risks conflicting with payment or refund records already captured on the order's transactions. Only under an explicit allow-write flag should the script resend the last known-good total pair together, then re-fetch the order to confirm the fix before marking it reconciled.
Related field notes
Citations
On the problem:
- BigCommerce API Reference: Update Order, PUT /v2/orders/{id} clears discounts and promotions on changed line items. docs.bigcommerce.com update order
- BigCommerce Developer Center: Orders Overview, order totals and status model. developer.bigcommerce.com orders overview
- bigcommerce-api-php Issue #144: order and coupon behavior on update. github.com bigcommerce-api-php issue #144
On the solution:
- BigCommerce API Reference: Update Order (V2), the fields that trigger a total recalculation. docs.bigcommerce.com update order (V2)
- BigCommerce API Reference: Get Order, the coupon_discount and total fields returned. docs.bigcommerce.com get order
- BigCommerce API Reference: List Order Coupons, the /v2/orders/{id}/coupons sub-resource. docs.bigcommerce.com list order coupons
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, webhooks, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this catch a coupon that quietly disappeared?
If this saved you from an under-refund, an overcharged customer, or a pile of manual order audits, 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