Diagnostic Promotions and coupons
Expired promotion still applies
The sale ended last week, but the discount is still showing up at checkout. A customer gets a price they should not, a report shows a promotion that was supposed to be retired, and nobody changed anything in the admin. BigCommerce's own status field on the promotion is the reason: it does not flip to DISABLED on its own when end_date passes. Here is why that gap exists and a small script that finds every promotion still ENABLED past its end date and disables it safely.
BigCommerce's V3 Promotions object keeps status (ENABLED or DISABLED) as its own field, independent of end_date. The platform is expected to stop honoring a rule once end_date passes, but nothing automatically writes DISABLED back onto the promotion, so any code that only checks status == "ENABLED" keeps discounting. Run a small Python or Node.js script that pages GET /v3/promotions, flags every promotion where status is still ENABLED but end_date has already passed in UTC (or current_uses has hit max_uses), cross-checks real orders placed after that date to confirm actual leakage, and then PUTs {"status": "DISABLED"} only after a dry run and a fresh re-fetch. Full code, tests, and a dry run guard are below.
The problem in plain words
A BigCommerce promotion carries two separate pieces of information that look like they should agree but do not have to. One is end_date, the day you told BigCommerce the promotion should stop. The other is status, a plain ENABLED or DISABLED switch. You might expect BigCommerce to flip that switch off the moment the date passes. It does not. The rule is meant to stop being honored once end_date is in the past, but the API keeps reporting status: "ENABLED" until a human, or a script, explicitly sets it to DISABLED.
That gap only matters to code that trusts status on its own. A custom storefront, a cached price calculation, or an integration that lists promotions and only checks whether status == "ENABLED" has no reason to also check the date, so it keeps applying a discount that should have ended. The order still ships, the customer still gets the price, and the only sign anything is wrong is a promotion in the list that nobody remembers being live.
Why it happens
status only changes when someone or something writes to it. A few common ways a store ends up with a promotion that should be dead but is not:
- An integration or custom storefront lists promotions filtered on
?status=ENABLEDand stops there, never comparingend_dateto the current time, so it keeps offering a discount BigCommerce itself has stopped intending to honor. end_dateis evaluated in the store's configured Date and Timezone from the Store Profile setting, effectively store-local 23:59:59 on the entered day, not UTC. Code that parses the date as a naive UTC instant can be off by the timezone offset in either direction, so a promotion can read as not-yet-expired when it truly is, or the reverse.- A cached price or cart calculation snapshot is refreshed on a schedule and simply has not re-read the promotion since the end date passed, so it keeps applying stale terms until the next refresh.
- The secondary version of the same symptom:
max_usesis reached, but nobody wroteDISABLED, socurrent_usesquietly climbs past what should have been the cutoff for a redemption-limited deal.
None of this shows up as an error anywhere. The promotion simply keeps discounting orders it should not touch, right up until someone notices the numbers do not look right. See the citations at the end for the exact docs and support threads.
Do not trust status alone, and do not trust a naive date comparison either. Parse end_date and the current time as UTC instants, and treat a promotion as expired the moment end_date is at or before now, or the moment current_uses reaches max_uses, regardless of what status currently says. Then confirm real orders were actually discounted after that point before you act, since a stale flag with zero real orders behind it is a much smaller problem than one that is actively costing money.
The fix, as a flow
We do not touch the live checkout. We add a job that lists every ENABLED promotion, classifies each one with a pure function that only looks at end_date, current_uses, and max_uses, cross-checks V2 orders placed after the end date to confirm the discount actually posted on a real order, and only then disables the promotion, guarded by a dry run and a fresh re-fetch right before the write.
Build it step by step
Get a store hash and an API account token
In your BigCommerce control panel, go to Settings, API, Store-level API accounts, and create an account with the Marketing scope set to modify, which covers Promotions. Note the store hash from your control panel URL and the access token from the API account. Keep both in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export DRY_RUN="true" // start safe, change to false to write
Talk to the BigCommerce REST API
Every call sends your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper wraps GET and PUT, raises on a non-2xx response, and unwraps the V3 {"data": ...} envelope so callers get plain objects back.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
if not r.content:
return None
body = r.json()
return body["data"] if isinstance(body, dict) and "data" in body else body
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
if (!text) return null;
const json = JSON.parse(text);
return json && typeof json === "object" && "data" in json ? json.data : json;
}
List ENABLED promotions and page through them
Call GET /v3/promotions?status=ENABLED and follow meta.pagination.links.next until it is empty. For each promotion keep id, name, status, end_date, start_date, current_uses, max_uses, and redemption_type, since those are exactly the fields the decision needs.
def enabled_promotions():
path = "/v3/promotions?status=ENABLED&limit=250"
while path:
r = requests.get(
BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
for promo in body.get("data", []):
yield promo
next_url = (body.get("meta", {}).get("pagination", {}).get("links", {}) or {}).get("next")
path = next_url.replace(BASE, "") if next_url else None
async function* enabledPromotions() {
let path = "/v3/promotions?status=ENABLED&limit=250";
while (path) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
headers: { "X-Auth-Token": TOKEN, "Accept": "application/json" },
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const body = await res.json();
for (const promo of body.data || []) yield promo;
const nextUrl = body.meta?.pagination?.links?.next;
path = nextUrl ? nextUrl.replace(BASE, "") : null;
}
}
Decide, with one pure function
Keep the classification in its own function that takes one promotion and the current time and returns whether it is expired, why, and what to do. It never touches the network, so it is easy to read and easy to test. It only says DISABLE when status is currently ENABLED and either end_date has passed in UTC or current_uses has reached max_uses.
from datetime import datetime, timezone
def classify_promotion(promo, now_iso):
if promo.get("status") != "ENABLED":
return {"expired": False, "reason": None, "action": "NONE"}
now = datetime.fromisoformat(now_iso.replace("Z", "+00:00"))
end_date = promo.get("end_date")
if end_date:
end = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
if end <= now:
return {"expired": True, "reason": "past_end_date", "action": "DISABLE"}
max_uses = promo.get("max_uses")
if max_uses is not None and promo.get("current_uses", 0) >= max_uses:
return {"expired": True, "reason": "max_uses_reached", "action": "DISABLE"}
return {"expired": False, "reason": None, "action": "NONE"}
export function classifyPromotion(promo, nowIso) {
if (promo.status !== "ENABLED") {
return { expired: false, reason: null, action: "NONE" };
}
const now = new Date(nowIso).getTime();
const endDate = promo.end_date;
if (endDate !== null && endDate !== undefined) {
const end = new Date(endDate).getTime();
if (end <= now) {
return { expired: true, reason: "past_end_date", action: "DISABLE" };
}
}
const maxUses = promo.max_uses;
if (maxUses !== null && maxUses !== undefined && promo.current_uses >= maxUses) {
return { expired: true, reason: "max_uses_reached", action: "DISABLE" };
}
return { expired: false, reason: null, action: "NONE" };
}
Confirm real leakage before you act
A promotion flagged as expired-but-active is a stale flag until you prove it discounted a real order. Query GET /v2/orders?min_date_created=<end_date>&status_id=11 and check whether any of those orders actually carry a discount from this promotion's coupon code or an automatic markdown that matches. This step is read only, and it is what turns "the flag looks wrong" into "this is costing money."
def orders_after_end_date(end_date):
"""Read only. Orders placed on or after end_date that are still
Awaiting Fulfillment (status_id 11), the freshest slice worth checking."""
r = requests.get(
BASE + "v2/orders",
params={"min_date_created": end_date, "status_id": 11},
headers={"X-Auth-Token": TOKEN, "Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json() or []
async function ordersAfterEndDate(endDate) {
// Read only. Orders placed on or after end_date that are still
// Awaiting Fulfillment (status_id 11), the freshest slice worth checking.
const qs = new URLSearchParams({ min_date_created: endDate, status_id: "11" });
const res = await fetch(`${BASE}v2/orders?${qs}`, {
headers: { "X-Auth-Token": TOKEN, "Accept": "application/json" },
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return (await res.json()) || [];
}
Re-fetch, then disable, with a dry run guard
Before writing, re-fetch GET /v3/promotions/{promotionId} to confirm end_date and current_uses have not changed since the scan, so the job never races a legitimate admin edit. Log every candidate, its id, name, end_date, current_uses/max_uses, and reason, whether or not DRY_RUN suppresses the write, so there is always an audit trail. Only flip DRY_RUN to false once a merchant has reviewed the list, since disabling an AUTOMATIC promotion is a storefront-visible change.
Always start with DRY_RUN=true. Re-check end_date and current_uses right before writing, and never disable an AUTOMATIC promotion without a merchant reviewing the logged candidate list first, since customers may currently be relying on that discount at checkout.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs every candidate, respects the dry run flag, and only disables a promotion that the pure classifier flagged and a fresh re-fetch still confirms.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find and safely disable BigCommerce promotions that are still ENABLED
past their end_date (or past max_uses), which lets them keep discounting
orders they should no longer touch.
BigCommerce's V3 Promotions object stores status (ENABLED/DISABLED) as its
own field, independent of end_date on the rule. The platform is expected to
stop honoring a rule once end_date passes, but status itself is never
automatically flipped to DISABLED in the API response. Any integration or
cached calculation that only checks status == "ENABLED" keeps applying the
discount. end_date is also evaluated in the store's configured Date and
Timezone (Store Profile setting), effectively store-local 23:59:59 on the
entered day, not UTC, so a naive UTC comparison can be off in either
direction.
This pages GET /v3/promotions, classifies each ENABLED promotion with a pure
function against end_date and max_uses/current_uses, cross-checks V2 orders
placed after end_date to confirm the discount actually posted on a real
order, re-fetches the single promotion right before writing to avoid racing
a legitimate admin edit, and PUTs {"status": "DISABLED"} only when DRY_RUN is
false. Every candidate is logged whether or not DRY_RUN suppresses the
write. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/expired-promotion-still-applies/
"""
import os
import logging
from datetime import datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("disable_expired_promotions")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
HEADERS = {"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"}
def bc(method, path, **kwargs):
r = requests.request(method, BASE + path.lstrip("/"), headers=HEADERS, timeout=30, **kwargs)
r.raise_for_status()
if not r.content:
return None
body = r.json()
return body["data"] if isinstance(body, dict) and "data" in body else body
def classify_promotion(promo, now_iso):
"""Pure. No I/O.
promo: {"status": "ENABLED"|"DISABLED", "end_date": str|None,
"start_date": str|None, "current_uses": int,
"max_uses": int|None, "redemption_type": "AUTOMATIC"|"COUPON"}
now_iso: current time as an ISO-8601 UTC string.
Returns {"expired": bool, "reason": str|None, "action": "DISABLE"|"NONE"}.
1. Anything not currently ENABLED is already inactive: nothing to do.
2. end_date and now are both parsed as UTC instants; a null end_date
never expires on its own.
3. If end_date has passed, expired with reason "past_end_date".
4. Else if max_uses is set and current_uses has reached it, expired
with reason "max_uses_reached" (the secondary cause of the same
symptom class).
5. Otherwise not expired.
"""
if promo.get("status") != "ENABLED":
return {"expired": False, "reason": None, "action": "NONE"}
now = datetime.fromisoformat(now_iso.replace("Z", "+00:00"))
end_date = promo.get("end_date")
if end_date:
end = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
if end <= now:
return {"expired": True, "reason": "past_end_date", "action": "DISABLE"}
max_uses = promo.get("max_uses")
if max_uses is not None and promo.get("current_uses", 0) >= max_uses:
return {"expired": True, "reason": "max_uses_reached", "action": "DISABLE"}
return {"expired": False, "reason": None, "action": "NONE"}
def enabled_promotions():
"""Read-only. Pages every ENABLED promotion via meta.pagination.links.next."""
path = "/v3/promotions?status=ENABLED&limit=250"
while path:
r = requests.get(BASE + path.lstrip("/"), headers=HEADERS, timeout=30)
r.raise_for_status()
body = r.json()
for promo in body.get("data", []):
yield promo
next_url = (body.get("meta", {}).get("pagination", {}).get("links", {}) or {}).get("next")
path = next_url.replace(BASE, "") if next_url else None
def orders_after_end_date(end_date):
"""Read-only. Orders placed on or after end_date, still Awaiting
Fulfillment (status_id 11), used to confirm real leakage."""
r = requests.get(
BASE + "v2/orders",
params={"min_date_created": end_date, "status_id": 11},
headers=HEADERS, timeout=30,
)
r.raise_for_status()
return r.json() or []
def refetch_promotion(promotion_id):
"""Read-only. Confirms end_date/current_uses have not changed since the
scan, so the write never races a legitimate admin edit."""
return bc("GET", f"/v3/promotions/{promotion_id}")
def disable_promotion(promotion_id):
return bc("PUT", f"/v3/promotions/{promotion_id}", json={"status": "DISABLED"})
def run():
now_iso = datetime.utcnow().isoformat() + "Z"
candidates = 0
disabled = 0
for promo in enabled_promotions():
result = classify_promotion(promo, now_iso)
if result["action"] != "DISABLE":
continue
candidates += 1
log.info(
"Promotion %r (id=%s) expired: %s. end_date=%s current_uses=%s/%s. %s",
promo.get("name"), promo.get("id"), result["reason"],
promo.get("end_date"), promo.get("current_uses"), promo.get("max_uses"),
"would disable" if DRY_RUN else "disabling",
)
if promo.get("end_date"):
leaked = orders_after_end_date(promo["end_date"])
if leaked:
log.warning(
"Promotion %r has %d order(s) placed after end_date: real leakage confirmed.",
promo.get("name"), len(leaked),
)
if DRY_RUN:
continue
fresh = refetch_promotion(promo["id"])
refreshed = classify_promotion(fresh, datetime.utcnow().isoformat() + "Z")
if refreshed["action"] != "DISABLE":
log.info("Promotion %r changed since the scan. Skipping.", promo.get("name"))
continue
disable_promotion(promo["id"])
disabled += 1
log.info("Done. %d candidate(s) found, %d %s.", candidates, disabled if not DRY_RUN else candidates,
"disabled" if not DRY_RUN else "to disable")
if __name__ == "__main__":
run()
/**
* Find and safely disable BigCommerce promotions that are still ENABLED
* past their end_date (or past max_uses), which lets them keep discounting
* orders they should no longer touch.
*
* BigCommerce's V3 Promotions object stores status (ENABLED/DISABLED) as its
* own field, independent of end_date on the rule. The platform is expected
* to stop honoring a rule once end_date passes, but status itself is never
* automatically flipped to DISABLED in the API response. Any integration or
* cached calculation that only checks status === "ENABLED" keeps applying
* the discount. end_date is also evaluated in the store's configured Date
* and Timezone (Store Profile setting), effectively store-local 23:59:59 on
* the entered day, not UTC, so a naive UTC comparison can be off in either
* direction.
*
* This pages GET /v3/promotions, classifies each ENABLED promotion with a
* pure function against end_date and max_uses/current_uses, cross-checks V2
* orders placed after end_date to confirm the discount actually posted on a
* real order, re-fetches the single promotion right before writing to avoid
* racing a legitimate admin edit, and PUTs {"status": "DISABLED"} only when
* DRY_RUN is false. Every candidate is logged whether or not DRY_RUN
* suppresses the write. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/expired-promotion-still-applies/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example-store-hash";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy-token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const HEADERS = { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" };
/**
* Pure. No I/O.
*
* promo: { status: "ENABLED"|"DISABLED", end_date: string|null,
* start_date: string|null, current_uses: number,
* max_uses: number|null, redemption_type: "AUTOMATIC"|"COUPON" }
* nowIso: current time as an ISO-8601 UTC string.
*
* Returns { expired: boolean, reason: string|null, action: "DISABLE"|"NONE" }.
*
* 1. Anything not currently ENABLED is already inactive: nothing to do.
* 2. end_date and now are both parsed as UTC instants; a null end_date
* never expires on its own.
* 3. If end_date has passed, expired with reason "past_end_date".
* 4. Else if max_uses is set and current_uses has reached it, expired with
* reason "max_uses_reached" (the secondary cause of the same symptom
* class).
* 5. Otherwise not expired.
*/
export function classifyPromotion(promo, nowIso) {
if (promo.status !== "ENABLED") {
return { expired: false, reason: null, action: "NONE" };
}
const now = new Date(nowIso).getTime();
const endDate = promo.end_date;
if (endDate !== null && endDate !== undefined) {
const end = new Date(endDate).getTime();
if (end <= now) {
return { expired: true, reason: "past_end_date", action: "DISABLE" };
}
}
const maxUses = promo.max_uses;
if (maxUses !== null && maxUses !== undefined && promo.current_uses >= maxUses) {
return { expired: true, reason: "max_uses_reached", action: "DISABLE" };
}
return { expired: false, reason: null, action: "NONE" };
}
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method, headers: HEADERS, body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
if (!text) return null;
const json = JSON.parse(text);
return json && typeof json === "object" && "data" in json ? json.data : json;
}
/** Read-only. Pages every ENABLED promotion via meta.pagination.links.next. */
async function* enabledPromotions() {
let path = "/v3/promotions?status=ENABLED&limit=250";
while (path) {
const res = await fetch(BASE + path.replace(/^\//, ""), { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const body = await res.json();
for (const promo of body.data || []) yield promo;
const nextUrl = body.meta?.pagination?.links?.next;
path = nextUrl ? nextUrl.replace(BASE, "") : null;
}
}
/**
* Read-only. Orders placed on or after end_date, still Awaiting Fulfillment
* (status_id 11), used to confirm real leakage.
*/
async function ordersAfterEndDate(endDate) {
const qs = new URLSearchParams({ min_date_created: endDate, status_id: "11" });
const res = await fetch(`${BASE}v2/orders?${qs}`, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return (await res.json()) || [];
}
/**
* Read-only. Confirms end_date/current_uses have not changed since the
* scan, so the write never races a legitimate admin edit.
*/
async function refetchPromotion(promotionId) {
return bc("GET", `/v3/promotions/${promotionId}`);
}
async function disablePromotion(promotionId) {
return bc("PUT", `/v3/promotions/${promotionId}`, { status: "DISABLED" });
}
export async function run() {
const nowIso = new Date().toISOString();
let candidates = 0;
let disabled = 0;
for await (const promo of enabledPromotions()) {
const result = classifyPromotion(promo, nowIso);
if (result.action !== "DISABLE") continue;
candidates++;
console.log(
`Promotion "${promo.name}" (id=${promo.id}) expired: ${result.reason}. end_date=${promo.end_date} current_uses=${promo.current_uses}/${promo.max_uses}. ${DRY_RUN ? "would disable" : "disabling"}`
);
if (promo.end_date) {
const leaked = await ordersAfterEndDate(promo.end_date);
if (leaked.length) {
console.warn(`Promotion "${promo.name}" has ${leaked.length} order(s) placed after end_date: real leakage confirmed.`);
}
}
if (DRY_RUN) continue;
const fresh = await refetchPromotion(promo.id);
const refreshed = classifyPromotion(fresh, new Date().toISOString());
if (refreshed.action !== "DISABLE") {
console.log(`Promotion "${promo.name}" changed since the scan. Skipping.`);
continue;
}
await disablePromotion(promo.id);
disabled++;
}
console.log(`Done. ${candidates} candidate(s) found, ${DRY_RUN ? candidates : disabled} ${DRY_RUN ? "to disable" : "disabled"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The classification rule is the part most worth testing, because it decides which promotions get disabled. Because we kept classify_promotion pure, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.
from disable_expired_promotions import classify_promotion
NOW = "2026-07-10T00:00:00Z"
def promo(**over):
base = {
"status": "ENABLED",
"end_date": "2026-08-01T00:00:00Z",
"start_date": "2026-01-01T00:00:00Z",
"current_uses": 0,
"max_uses": None,
"redemption_type": "COUPON",
}
base.update(over)
return base
def test_not_expired_when_disabled_already():
result = classify_promotion(promo(status="DISABLED", end_date="2026-01-01T00:00:00Z"), NOW)
assert result == {"expired": False, "reason": None, "action": "NONE"}
def test_not_expired_when_end_date_in_future():
result = classify_promotion(promo(), NOW)
assert result == {"expired": False, "reason": None, "action": "NONE"}
def test_expired_past_end_date():
result = classify_promotion(promo(end_date="2026-06-01T00:00:00Z"), NOW)
assert result == {"expired": True, "reason": "past_end_date", "action": "DISABLE"}
def test_expired_when_end_date_equals_now():
result = classify_promotion(promo(end_date=NOW), NOW)
assert result["expired"] is True
assert result["reason"] == "past_end_date"
def test_never_expires_with_null_end_date_unless_max_uses_hit():
result = classify_promotion(promo(end_date=None), NOW)
assert result == {"expired": False, "reason": None, "action": "NONE"}
def test_expired_when_max_uses_reached():
result = classify_promotion(promo(end_date=None, current_uses=50, max_uses=50), NOW)
assert result == {"expired": True, "reason": "max_uses_reached", "action": "DISABLE"}
def test_not_expired_when_under_max_uses():
result = classify_promotion(promo(end_date=None, current_uses=49, max_uses=50), NOW)
assert result == {"expired": False, "reason": None, "action": "NONE"}
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyPromotion } from "./disable-expired-promotions.js";
const NOW = "2026-07-10T00:00:00Z";
const promo = (over = {}) => ({
status: "ENABLED",
end_date: "2026-08-01T00:00:00Z",
start_date: "2026-01-01T00:00:00Z",
current_uses: 0,
max_uses: null,
redemption_type: "COUPON",
...over,
});
test("not expired when disabled already", () => {
const result = classifyPromotion(promo({ status: "DISABLED", end_date: "2026-01-01T00:00:00Z" }), NOW);
assert.deepEqual(result, { expired: false, reason: null, action: "NONE" });
});
test("not expired when end_date in future", () => {
const result = classifyPromotion(promo(), NOW);
assert.deepEqual(result, { expired: false, reason: null, action: "NONE" });
});
test("expired past end_date", () => {
const result = classifyPromotion(promo({ end_date: "2026-06-01T00:00:00Z" }), NOW);
assert.deepEqual(result, { expired: true, reason: "past_end_date", action: "DISABLE" });
});
test("expired when end_date equals now", () => {
const result = classifyPromotion(promo({ end_date: NOW }), NOW);
assert.equal(result.expired, true);
assert.equal(result.reason, "past_end_date");
});
test("never expires with null end_date unless max_uses hit", () => {
const result = classifyPromotion(promo({ end_date: null }), NOW);
assert.deepEqual(result, { expired: false, reason: null, action: "NONE" });
});
test("expired when max_uses reached", () => {
const result = classifyPromotion(promo({ end_date: null, current_uses: 50, max_uses: 50 }), NOW);
assert.deepEqual(result, { expired: true, reason: "max_uses_reached", action: "DISABLE" });
});
test("not expired when under max_uses", () => {
const result = classifyPromotion(promo({ end_date: null, current_uses: 49, max_uses: 50 }), NOW);
assert.deepEqual(result, { expired: false, reason: null, action: "NONE" });
});
Case studies
The site-wide sale that never really ended
A furniture retailer ran a two-week automatic discount tied to a spring sale. The end date came and went, but the storefront's own promotion widget only checked status, saw ENABLED, and kept the banner and the discount live at checkout for another eleven days before finance noticed margin was lower than the sale calendar predicted.
Running the scan in dry run turned up the promotion immediately, flagged past_end_date, and the order cross-check showed dozens of orders placed after the end date still carrying the automatic markdown. That was the proof needed to disable it that afternoon instead of waiting for the next planning meeting.
The coupon that looked expired a day early
A store with its Store Profile timezone set to a US Pacific offset had a promotion configured to end on a specific date. A partner integration parsed end_date as naive UTC and disabled the coupon a few hours before the store's own local cutoff, cutting off legitimate customers on the last day of the sale.
Once the team compared the two computations side by side, they anchored their own end_date parsing strictly to a UTC instant and stopped guessing at an offset, and added the confirming order cross-check so the same class of mismatch would show up as a clear signal rather than silently costing them the last hours of a sale.
After this runs on a schedule, a promotion past its end date does not linger. The pure classifier catches it the moment end_date or max_uses says it should be over, the order cross-check confirms whether it is a real leak or just a stale flag, and the re-fetch before writing means the job never fights a merchant who is mid-edit. Every candidate is logged, so nothing gets disabled quietly and nothing stays live by accident.
FAQ
Why does a BigCommerce promotion still discount orders after its end date has passed?
BigCommerce's V3 Promotions object stores its own status field, ENABLED or DISABLED, separately from the end_date on the rule. The platform is expected to stop honoring the rule once end_date passes, but status itself is not automatically flipped to DISABLED in the API response. Any integration or cached calculation that only checks status == ENABLED, instead of also comparing end_date against now, keeps applying the discount.
What time zone is a BigCommerce promotion end_date evaluated in?
end_date is evaluated in the store's configured Date and Timezone from the Store Profile setting, effectively store-local 23:59:59 on the entered day, not UTC. A naive UTC comparison against that value can make a promotion look not yet expired for up to the timezone offset, or look expired early, either of which lets it discount orders it should no longer touch.
Is it safe to automatically disable a promotion once it looks expired?
Only behind a dry run and a re-check. Re-fetch the single promotion right before writing to confirm end_date and current_uses have not changed since the scan, log every candidate whether or not the write happens, and keep DRY_RUN true until a merchant has reviewed the list, since disabling an AUTOMATIC promotion is a storefront-visible change customers may be relying on.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: Promotions (Store Operations). developer.bigcommerce.com/docs/store-operations/promotions
- BigCommerce Support: Coupon Promotions (Legacy Editor). support.bigcommerce.com/s/article/Coupon-Promotions
- BigCommerce Community: What Time Zone is the Coupon Expiration Date? support.bigcommerce.com coupon expiration date timezone
On the solution:
- BigCommerce Developer Center: Promotions Single (Get/Update a Promotion). developer.bigcommerce.com/docs/rest-management/promotions/promotions-single
- BigCommerce Developer Center: Promotions (REST Management API). developer.bigcommerce.com/docs/rest-management/promotions
- BigCommerce Developer Center: Promotion Settings. developer.bigcommerce.com/docs/rest-management/promotion-settings
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, promotions, 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 promotion that should have ended?
If this closed a gap between your sale calendar and what checkout was actually applying, 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