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.

Python and Node.js BigCommerce V2 Orders API Safe by default (report only)
Text
Photo by Tamanna Rumee on Unsplash
The short answer

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.

Order placed coupon applied PUT unrelated field e.g. staff_notes Totals recalculated coupon_discount 0 discount cleared Total grows back
The coupon sub-resource still lists the code as active, but the live order's coupon_discount has been recalculated to zero and the total has quietly grown back.

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:

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 key insight

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.

Scheduled job runs on a timer List modified orders since last run Read order + coupons totals, coupon_discount Discount still reconciles? yes no, report only Flag order expected vs. observed
The job only reports the orders where the coupons sub-resource still lists an active coupon but the live total no longer reflects it. Nothing is auto-rewritten by default.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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()
step2.js
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();
}
3

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.

step3.py
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")
step3.js
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`);
}
4

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.

decide.py
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")}
decide.js
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 };
}
5

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.

apply.py
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),
    })
apply.js
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),
  });
}
6

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.

Run it safe

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_coupon_overwrite.py
"""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-coupon-overwrite.js
/**
 * 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.

test_overwrites_coupon_discount.py
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
detect-coupon-overwrite.test.js
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

Staff notes wiped a promo

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.

Shipping cost correction

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.

What good looks like

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:

  1. BigCommerce API Reference: Update Order, PUT /v2/orders/{id} clears discounts and promotions on changed line items. docs.bigcommerce.com update order
  2. BigCommerce Developer Center: Orders Overview, order totals and status model. developer.bigcommerce.com orders overview
  3. bigcommerce-api-php Issue #144: order and coupon behavior on update. github.com bigcommerce-api-php issue #144

On the solution:

  1. BigCommerce API Reference: Update Order (V2), the fields that trigger a total recalculation. docs.bigcommerce.com update order (V2)
  2. BigCommerce API Reference: Get Order, the coupon_discount and total fields returned. docs.bigcommerce.com get order
  3. 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.

Contact me on LinkedIn

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

Back to all BigCommerce field notes