Reconciler Customers
BigCommerce customer group change does not immediately refresh cached pricing
An admin moves a customer into a new pricing group. The customer record updates right away. But the cart the customer already has open, their browser session, or the CDN's cached version of the page keeps quoting the old group's price list for minutes, sometimes longer. BigCommerce resolves customer-group pricing once per cart or session, not on every page view, so nothing tells that stale cart to look again. Here is why that gap opens up and a small script that finds the carts genuinely quoting a stale price and safely forces them to re-resolve.
BigCommerce resolves customer-group pricing by joining the customer's customer_group_id to a price list through /v3/pricelists/assignments, then reading that price list's records for the variant. That resolution happens once per cart or session, existing carts keep the price snapshot captured under the old group, storefront and CDN edge caching can serve pre-rendered pricing for several minutes, and BigCommerce's own support documentation warns pricing changes can take up to roughly 10 minutes to propagate. Run a small Python or Node.js script that reads each flagged customer's current customer_group_id from the V2 API, resolves the price list that should now apply, compares it against the cart's recorded line item price, and only for a genuine mismatch forces the cart to re-resolve, either by resubmitting the line item or deleting the cart outright. Full code, tests, and a dry run guard are below.
The problem in plain words
Customer-group pricing in BigCommerce is not evaluated fresh on every page load. When a cart is created, or a storefront session starts, BigCommerce looks up the customer's customer_group_id, finds the price list assigned to that group for the channel in use through /v3/pricelists/assignments, and then reads the matching variant record from /v3/pricelists/{id}/records. That price gets captured onto the cart's line items and, on the storefront, can also sit behind edge caching for a few minutes at a time.
Move that customer to a different group and the customer record changes instantly, the new customer_group_id is there the moment you read it back. But nothing reaches into the cart that was already built under the old group and rewrites its price. Nothing tells the CDN to drop the page it already cached. The customer keeps browsing, keeps seeing the old number, and only a brand new cart or session forces BigCommerce to run the lookup again with the new group.
Why it happens
Customer-group pricing in BigCommerce is a resolve-once, cache-heavy chain, and each link is a place staleness can survive a group change:
- Customer groups themselves are "not yet available on the V3 Customers API," so
customer_group_idonly appears on the V2 customer record, and any code or dashboard reading only/v3/customerswill not even see the group change reflected. - Price resolution is a join:
customer_group_idto a price list through/v3/pricelists/assignments, filtered by channel, then/v3/pricelists/{id}/recordsfor the specific variant. That join runs once, typically at cart creation or session start, not on every request. - An already-open cart keeps the
list_price/sale_priceit captured at creation time on each line item. Changing the customer's group afterward does not walk existing carts and rewrite their line items. - Storefront and CDN edge caching can serve a pre-rendered version of a product or cart page for several minutes after the underlying data changed, independent of the cart's own cached price.
- BigCommerce's own support documentation warns that pricing changes, including changes that flow from a group reassignment, can take up to roughly 10 minutes to propagate through the platform's caches.
None of this is a bug in the sense of broken code. It is a caching design that trades a small window of staleness for not re-running the group-to-price-list join on every single page view. See the citations at the end for the exact support threads and docs.
A customer's current customer_group_id is the truth. A cart's line item price is a snapshot, not a live value. So the safe pattern is not "rewrite the price on the cart" and it is definitely not "edit the price list record." It is "compare the cart's snapshot against what the customer's current group would now produce, and if they disagree, force the cart itself to re-resolve." We read /v2/customers/{id} for the real group, /v3/pricelists/assignments for the price list that group is bound to on that channel, and /v3/pricelists/{id}/records for what the variant should cost right now, and we only ever touch the one stale cart, never the shared price list record.
The fix, as a flow
We do not touch the price list, the customer group configuration, or the storefront caching rules. We add a job that audits open carts for customers who changed groups recently, decides per cart whether its price truly went stale, and if it did, forces that one cart to re-resolve against the customer's current price list.
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 reuse an existing app's credentials. Grant it Customers (read-only is enough for the V2 lookup) and Carts (modify) scope so it can read line items and force a re-resolve. 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 CHANNEL_ID="1"
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="..."
export CHANNEL_ID="1"
export DRY_RUN="true" // start safe, change to false to write
Talk to the V2 customer record and the V3 Price Lists API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/ with the token in the X-Auth-Token header, and Accept: application/json. Customer group is V2-only, so we hit /v2/customers/{id} for that field, and everything else, price lists, assignments, and carts, is V3. A small helper handles GET, PUT, and DELETE and raises on a non-2xx response.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_ROOT = f"https://api.bigcommerce.com/stores/{STORE_HASH}"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_ROOT}{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_ROOT}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
def bc_delete(path):
r = requests.delete(f"{API_ROOT}{path}", headers=HEADERS, timeout=30)
r.raise_for_status()
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_ROOT = `https://api.bigcommerce.com/stores/${STORE_HASH}`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGet(path, params = {}) {
const url = new URL(`${API_ROOT}${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_ROOT}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function bcDelete(path) {
const res = await fetch(`${API_ROOT}${path}`, { method: "DELETE", headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
}
Resolve the customer's current group and price list
For each customer to audit, call GET /v2/customers/{customer_id} to read the current customer_group_id, since that field is not on /v3/customers. Then call GET /v3/pricelists/assignments?customer_group_id={id}&channel_id={channel} to find the price_list_id actually bound to that group for the storefront channel the customer is using.
def current_customer_group_id(customer_id):
customer = bc_get(f"/v2/customers/{customer_id}")
return customer.get("customer_group_id")
def price_list_id_for_group(customer_group_id, channel_id):
resp = bc_get("/v3/pricelists/assignments", {
"customer_group_id": customer_group_id,
"channel_id": channel_id,
})
assignments = resp.get("data") or []
return assignments[0]["price_list_id"] if assignments else None
async function currentCustomerGroupId(customerId) {
const customer = await bcGet(`/v2/customers/${customerId}`);
return customer.customer_group_id;
}
async function priceListIdForGroup(customerGroupId, channelId) {
const resp = await bcGet("/v3/pricelists/assignments", {
customer_group_id: customerGroupId,
channel_id: channelId,
});
const assignments = resp.data || [];
return assignments.length ? assignments[0].price_list_id : null;
}
Read the cart's cached price and the price list's current record
For each product or variant the customer has in an open cart, call GET /v3/carts/{cart_id} (Management API) and inspect each line item's recorded list_price and sale_price, that is the snapshot the cart is still quoting. Then call GET /v3/pricelists/{price_list_id}/records?variant_id={variant_id} to get the price that should apply right now under the customer's current group.
def get_cart(cart_id):
return bc_get(f"/v3/carts/{cart_id}")
def price_list_record_for_variant(price_list_id, variant_id):
resp = bc_get(f"/v3/pricelists/{price_list_id}/records", {"variant_id": variant_id})
records = resp.get("data") or []
return records[0] if records else None
async function getCart(cartId) {
return bcGet(`/v3/carts/${cartId}`);
}
async function priceListRecordForVariant(priceListId, variantId) {
const resp = await bcGet(`/v3/pricelists/${priceListId}/records`, { variant_id: variantId });
const records = resp.data || [];
return records.length ? records[0] : null;
}
Decide, with one pure function
Keep the comparison in its own function that takes only the cart's line item and the price list's record, both plain dicts, and returns a plain boolean. Prefer the record's sale_price when it is set, otherwise its price, that is what "the calculated price" means for this group. Compare that against the cart's own sale_price if set, otherwise its list_price. Anything past a one-cent tolerance is a real mismatch, not rounding noise.
from decimal import Decimal
def is_price_stale(cart_line_item: dict, price_list_record: dict, tolerance: Decimal = Decimal("0.01")) -> bool:
expected = (
price_list_record["sale_price"]
if price_list_record.get("sale_price") is not None
else price_list_record["price"]
)
actual = (
cart_line_item["sale_price"]
if cart_line_item.get("sale_price") is not None
else cart_line_item["list_price"]
)
return abs(Decimal(str(expected)) - Decimal(str(actual))) > tolerance
export function isPriceStale(cartLineItem, priceListRecord, tolerance = 0.01) {
const expected =
priceListRecord.sale_price !== null && priceListRecord.sale_price !== undefined
? priceListRecord.sale_price
: priceListRecord.price;
const actual =
cartLineItem.sale_price !== null && cartLineItem.sale_price !== undefined
? cartLineItem.sale_price
: cartLineItem.list_price;
return Math.abs(Number(expected) - Number(actual)) > tolerance;
}
Force the stale cart to re-resolve, never rewrite the price list
When is_price_stale is true, do not touch the price list record, that would change pricing for every customer in the group, not just this one cart. Instead force re-resolution on the flagged cart alone: call PUT /v3/carts/{cart_id}/items/{item_id} re-submitting the same quantity, which makes BigCommerce recompute the line item price against the customer's current price list, or call DELETE /v3/carts/{cart_id} so the storefront issues a fresh POST /v3/carts on the next add-to-cart, which resolves customer_group_id to a price list at creation time.
def force_line_item_reresolve(cart_id, item_id, quantity):
return bc_put(f"/v3/carts/{cart_id}/items/{item_id}", {
"line_item": {"quantity": quantity},
})
def force_cart_refresh_by_delete(cart_id):
bc_delete(f"/v3/carts/{cart_id}")
async function forceLineItemReresolve(cartId, itemId, quantity) {
return bcPut(`/v3/carts/${cartId}/items/${itemId}`, {
line_item: { quantity },
});
}
async function forceCartRefreshByDelete(cartId) {
await bcDelete(`/v3/carts/${cartId}`);
}
Always start with DRY_RUN=true, and only log the affected cart_id, customer_id, old group, new group, and price delta. Never rewrite the price list record itself to "fix" one cart, that alters pricing for every customer in the group. Only perform the cart refresh or delete when DRY_RUN=false, and only on carts where the comparison found a genuine mismatch, not on every cart belonging to a recently moved customer.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and only forces a cart to re-resolve when the cart's own recorded price genuinely disagrees with what the customer's current group and price list would produce.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find and repair BigCommerce carts still quoting a stale customer-group price.
BigCommerce resolves customer-group pricing by joining the customer's
customer_group_id (a V2-only field, customer groups are "not yet available on
the V3 Customers API") to a price list through /v3/pricelists/assignments,
then reading /v3/pricelists/{id}/records for the variant. That resolution
happens once per cart or session and gets cached: an existing cart keeps the
price snapshot captured under the old group, storefront and CDN edge caching
can serve pre-rendered pricing for several minutes, and BigCommerce support
documentation itself warns pricing changes can take up to about 10 minutes to
propagate. So when an admin moves a customer between groups, the customer
record updates immediately but an already-created cart, an active browser
session, or an edge-cached page keeps quoting the old group's price list
until a new cart or session forces re-resolution.
This job audits a list of customer/cart pairs, reads the customer's current
group and the price list it maps to, compares that against each cart line
item's recorded price, and for a genuine mismatch forces that one cart to
re-resolve, either by resubmitting the line item quantity or deleting the
cart. It never rewrites the price list record itself, that would change
pricing for every customer in the group, not just fix the one stale cart.
Guide: https://www.allanninal.dev/bigcommerce/customer-group-change-stale-price-cache/
"""
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("refresh_stale_group_pricing")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_ROOT = f"https://api.bigcommerce.com/stores/{STORE_HASH}"
CHANNEL_ID = os.environ.get("CHANNEL_ID", "1")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_ROOT}{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_ROOT}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
def bc_delete(path):
r = requests.delete(f"{API_ROOT}{path}", headers=HEADERS, timeout=30)
r.raise_for_status()
def is_price_stale(cart_line_item: dict, price_list_record: dict, tolerance: Decimal = Decimal("0.01")) -> bool:
"""Pure decision. No network, no side effects.
expected = price_list_record's sale_price if set, else its price.
actual = cart_line_item's sale_price if set, else its list_price.
Returns True when the two disagree by more than tolerance, meaning the
cart is still quoting a price that does not match what the customer's
current group and price list would produce right now.
"""
expected = (
price_list_record["sale_price"]
if price_list_record.get("sale_price") is not None
else price_list_record["price"]
)
actual = (
cart_line_item["sale_price"]
if cart_line_item.get("sale_price") is not None
else cart_line_item["list_price"]
)
return abs(Decimal(str(expected)) - Decimal(str(actual))) > tolerance
def current_customer_group_id(customer_id):
customer = bc_get(f"/v2/customers/{customer_id}")
return customer.get("customer_group_id")
def price_list_id_for_group(customer_group_id, channel_id=CHANNEL_ID):
resp = bc_get("/v3/pricelists/assignments", {
"customer_group_id": customer_group_id,
"channel_id": channel_id,
})
assignments = resp.get("data") or []
return assignments[0]["price_list_id"] if assignments else None
def get_cart(cart_id):
return bc_get(f"/v3/carts/{cart_id}")
def price_list_record_for_variant(price_list_id, variant_id):
resp = bc_get(f"/v3/pricelists/{price_list_id}/records", {"variant_id": variant_id})
records = resp.get("data") or []
return records[0] if records else None
def force_line_item_reresolve(cart_id, item_id, quantity):
return bc_put(f"/v3/carts/{cart_id}/items/{item_id}", {"line_item": {"quantity": quantity}})
def force_cart_refresh_by_delete(cart_id):
bc_delete(f"/v3/carts/{cart_id}")
def audit_targets():
"""Yields dicts describing which customer/cart pairs to check.
In production this would come from an audit log of recent customer group
changes (for example a webhook or a scheduled export). Kept as a small
seam here so run() stays testable at the integration level too.
"""
raw = os.environ.get("AUDIT_TARGETS_JSON", "[]")
import json
return json.loads(raw)
def run():
checked = 0
repaired = 0
for target in audit_targets():
customer_id = target["customer_id"]
cart_id = target["cart_id"]
old_group_id = target.get("old_group_id")
new_group_id = current_customer_group_id(customer_id)
price_list_id = price_list_id_for_group(new_group_id)
if price_list_id is None:
log.warning("No price list assignment for customer %s group %s, skipping.", customer_id, new_group_id)
continue
cart = get_cart(cart_id)
for line_item in (cart.get("data", {}).get("line_items", {}).get("physical_items", []) or []):
checked += 1
record = price_list_record_for_variant(price_list_id, line_item["variant_id"])
if record is None:
continue
if not is_price_stale(line_item, record):
continue
expected = record["sale_price"] if record.get("sale_price") is not None else record["price"]
actual = line_item["sale_price"] if line_item.get("sale_price") is not None else line_item["list_price"]
log.info(
"cart_id=%s customer_id=%s old_group=%s new_group=%s cart_price=%s expected_price=%s (%s)",
cart_id, customer_id, old_group_id, new_group_id, actual, expected,
"dry run" if DRY_RUN else "repairing",
)
if not DRY_RUN:
force_line_item_reresolve(cart_id, line_item["id"], line_item["quantity"])
repaired += 1
log.info(
"Done. %d line item(s) checked, %d %s.",
checked, repaired, "would be repaired" if DRY_RUN else "repaired",
)
if __name__ == "__main__":
run()
/**
* Find and repair BigCommerce carts still quoting a stale customer-group price.
*
* BigCommerce resolves customer-group pricing by joining the customer's
* customer_group_id (a V2-only field, customer groups are "not yet available
* on the V3 Customers API") to a price list through /v3/pricelists/assignments,
* then reading /v3/pricelists/{id}/records for the variant. That resolution
* happens once per cart or session and gets cached: an existing cart keeps
* the price snapshot captured under the old group, storefront and CDN edge
* caching can serve pre-rendered pricing for several minutes, and
* BigCommerce support documentation itself warns pricing changes can take up
* to about 10 minutes to propagate. So when an admin moves a customer
* between groups, the customer record updates immediately but an
* already-created cart, an active browser session, or an edge-cached page
* keeps quoting the old group's price list until a new cart or session
* forces re-resolution.
*
* This job audits a list of customer/cart pairs, reads the customer's
* current group and the price list it maps to, compares that against each
* cart line item's recorded price, and for a genuine mismatch forces that
* one cart to re-resolve, either by resubmitting the line item quantity or
* deleting the cart. It never rewrites the price list record itself, that
* would change pricing for every customer in the group, not just fix the
* one stale cart.
*
* Guide: https://www.allanninal.dev/bigcommerce/customer-group-change-stale-price-cache/
*/
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_ROOT = `https://api.bigcommerce.com/stores/${STORE_HASH}`;
const CHANNEL_ID = process.env.CHANNEL_ID || "1";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* expected = priceListRecord's sale_price if set, else its price.
* actual = cartLineItem's sale_price if set, else its list_price.
* Returns true when the two disagree by more than tolerance, meaning the
* cart is still quoting a price that does not match what the customer's
* current group and price list would produce right now.
*/
export function isPriceStale(cartLineItem, priceListRecord, tolerance = 0.01) {
const expected =
priceListRecord.sale_price !== null && priceListRecord.sale_price !== undefined
? priceListRecord.sale_price
: priceListRecord.price;
const actual =
cartLineItem.sale_price !== null && cartLineItem.sale_price !== undefined
? cartLineItem.sale_price
: cartLineItem.list_price;
return Math.abs(Number(expected) - Number(actual)) > tolerance;
}
async function bcGet(path, params = {}) {
const url = new URL(`${API_ROOT}${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_ROOT}${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function bcDelete(path) {
const res = await fetch(`${API_ROOT}${path}`, { method: "DELETE", headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
}
async function currentCustomerGroupId(customerId) {
const customer = await bcGet(`/v2/customers/${customerId}`);
return customer.customer_group_id;
}
async function priceListIdForGroup(customerGroupId, channelId = CHANNEL_ID) {
const resp = await bcGet("/v3/pricelists/assignments", {
customer_group_id: customerGroupId,
channel_id: channelId,
});
const assignments = resp.data || [];
return assignments.length ? assignments[0].price_list_id : null;
}
async function getCart(cartId) {
return bcGet(`/v3/carts/${cartId}`);
}
async function priceListRecordForVariant(priceListId, variantId) {
const resp = await bcGet(`/v3/pricelists/${priceListId}/records`, { variant_id: variantId });
const records = resp.data || [];
return records.length ? records[0] : null;
}
async function forceLineItemReresolve(cartId, itemId, quantity) {
return bcPut(`/v3/carts/${cartId}/items/${itemId}`, { line_item: { quantity } });
}
async function forceCartRefreshByDelete(cartId) {
await bcDelete(`/v3/carts/${cartId}`);
}
function auditTargets() {
try {
return JSON.parse(process.env.AUDIT_TARGETS_JSON || "[]");
} catch {
return [];
}
}
export async function run() {
let checked = 0;
let repaired = 0;
for (const target of auditTargets()) {
const { customer_id: customerId, cart_id: cartId, old_group_id: oldGroupId } = target;
const newGroupId = await currentCustomerGroupId(customerId);
const priceListId = await priceListIdForGroup(newGroupId);
if (priceListId == null) {
console.warn(`No price list assignment for customer ${customerId} group ${newGroupId}, skipping.`);
continue;
}
const cart = await getCart(cartId);
const lineItems = cart?.data?.line_items?.physical_items || [];
for (const lineItem of lineItems) {
checked += 1;
const record = await priceListRecordForVariant(priceListId, lineItem.variant_id);
if (!record) continue;
if (!isPriceStale(lineItem, record)) continue;
const expected = record.sale_price != null ? record.sale_price : record.price;
const actual = lineItem.sale_price != null ? lineItem.sale_price : lineItem.list_price;
console.log(
`cart_id=${cartId} customer_id=${customerId} old_group=${oldGroupId} new_group=${newGroupId} ` +
`cart_price=${actual} expected_price=${expected} (${DRY_RUN ? "dry run" : "repairing"})`
);
if (!DRY_RUN) await forceLineItemReresolve(cartId, lineItem.id, lineItem.quantity);
repaired += 1;
}
}
console.log(
`Done. ${checked} line item(s) checked, ${repaired} ${DRY_RUN ? "would be repaired" : "repaired"}.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The comparison rule is the part most worth testing, because it decides whether a real customer's cart gets forced to re-resolve. Because is_price_stale takes only plain dicts and returns a plain boolean, the test needs no network and no BigCommerce store. It just feeds in fixture dicts and checks the answer.
from refresh_stale_group_pricing import is_price_stale
def cart_item(list_price="50.00", sale_price=None):
return {"list_price": list_price, "sale_price": sale_price}
def price_record(price="50.00", sale_price=None):
return {"price": price, "sale_price": sale_price}
def test_matching_prices_are_not_stale():
assert is_price_stale(cart_item(list_price="42.00"), price_record(price="42.00")) is False
def test_stale_when_cart_price_is_higher_than_current_list():
assert is_price_stale(cart_item(list_price="55.00"), price_record(price="42.00")) is True
def test_stale_when_cart_price_is_lower_than_current_list():
assert is_price_stale(cart_item(list_price="30.00"), price_record(price="42.00")) is True
def test_prefers_sale_price_on_the_price_list_record():
record = price_record(price="42.00", sale_price="35.00")
assert is_price_stale(cart_item(list_price="35.00"), record) is False
assert is_price_stale(cart_item(list_price="42.00"), record) is True
def test_prefers_sale_price_on_the_cart_line_item():
item = cart_item(list_price="42.00", sale_price="35.00")
assert is_price_stale(item, price_record(price="42.00")) is True
assert is_price_stale(item, price_record(price="35.00")) is False
def test_decimal_string_precision_edge_case_within_tolerance():
assert is_price_stale(cart_item(list_price="19.999"), price_record(price="20.00")) is False
def test_decimal_string_precision_edge_case_outside_tolerance():
assert is_price_stale(cart_item(list_price="19.90"), price_record(price="20.00")) is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { isPriceStale } from "./refresh-stale-group-pricing.js";
const cartItem = ({ listPrice = "50.00", salePrice = null } = {}) => ({
list_price: listPrice, sale_price: salePrice,
});
const priceRecord = ({ price = "50.00", salePrice = null } = {}) => ({
price, sale_price: salePrice,
});
test("matching prices are not stale", () => {
assert.equal(isPriceStale(cartItem({ listPrice: "42.00" }), priceRecord({ price: "42.00" })), false);
});
test("stale when cart price is higher than current list", () => {
assert.equal(isPriceStale(cartItem({ listPrice: "55.00" }), priceRecord({ price: "42.00" })), true);
});
test("stale when cart price is lower than current list", () => {
assert.equal(isPriceStale(cartItem({ listPrice: "30.00" }), priceRecord({ price: "42.00" })), true);
});
test("prefers sale_price on the price list record", () => {
const record = priceRecord({ price: "42.00", salePrice: "35.00" });
assert.equal(isPriceStale(cartItem({ listPrice: "35.00" }), record), false);
assert.equal(isPriceStale(cartItem({ listPrice: "42.00" }), record), true);
});
test("prefers sale_price on the cart line item", () => {
const item = cartItem({ listPrice: "42.00", salePrice: "35.00" });
assert.equal(isPriceStale(item, priceRecord({ price: "42.00" })), true);
assert.equal(isPriceStale(item, priceRecord({ price: "35.00" })), false);
});
test("decimal precision edge case within tolerance", () => {
assert.equal(isPriceStale(cartItem({ listPrice: "19.999" }), priceRecord({ price: "20.00" })), false);
});
test("decimal precision edge case outside tolerance", () => {
assert.equal(isPriceStale(cartItem({ listPrice: "19.90" }), priceRecord({ price: "20.00" })), true);
});
Case studies
The B2B customer who saw retail price for ten more minutes
A wholesale account manager moved a returning customer from the standard retail group into a wholesale group mid-call, then told the customer to reload their cart and check out. The customer's browser session and its cart had already resolved pricing under the retail group, so the reload still showed the retail price, and the customer called back annoyed.
Running the audit against that cart showed exactly one stale line item, the cart's recorded price disagreed with what the wholesale price list would now produce. Forcing that one cart to re-resolve, without touching the shared wholesale price list, fixed the customer's total in seconds instead of waiting out the propagation window.
The catalog team that moved 400 accounts overnight
A merchandising team ran a bulk migration moving four hundred loyalty customers into a new tiered-discount group overnight. The next morning, support fielded a wave of tickets from customers whose carts, created the evening before, were still quoting the old tier.
Rather than guessing which of the four hundred carts were actually affected, the team fed the migration list into the audit script. It only flagged carts where the recorded price genuinely disagreed with the new tier's price list, a fraction of the four hundred, and repaired only those, leaving everyone else's already-correct carts untouched.
After this runs against the customers you just moved between groups, a cart is never left quoting a price that genuinely disagrees with the customer's current price list, whether the staleness came from an old cart, an open session, or an edge cache. Carts whose price already matches stay exactly as they are, untouched, and the shared price list record is never rewritten just to satisfy one customer's cart.
FAQ
Why does a customer still see the old price after I move them to a new group?
BigCommerce resolves customer-group pricing once per cart or session by joining the customer's customer_group_id to a price list, then reading that price list's records. Moving a customer to a new group updates the customer record immediately, but any cart already created, any browser session already open, or any edge-cached storefront page keeps the price snapshot taken under the old group until something forces a fresh resolution. BigCommerce support documentation itself notes pricing changes can take up to about 10 minutes to propagate.
Is it safe to rewrite the price list record to fix a stale cart?
No. A price list record is shared by every customer assigned to that group. Editing the record to match one customer's expected price would change pricing storewide. The safe fix is to force re-resolution for the affected cart only, either by resubmitting its line item quantity or deleting the cart so the next add-to-cart creates a fresh one against the customer's current price list.
Where do I find which customer group a customer is actually in?
Customer groups are not yet available on the V3 Customers API, so GET /v3/customers omits customer_group_id. Read it from the V2 endpoint, GET /v2/customers/{customer_id}, then look up the price list bound to that group and channel with GET /v3/pricelists/assignments?customer_group_id={id}&channel_id={channel}.
Related field notes
Citations
On the problem:
- BigCommerce Support Community: Customer Group Pricing. support.bigcommerce.com customer group pricing
- BigCommerce Support Community: Change in BC Support with clearing Store Cache. support.bigcommerce.com clearing store cache
- BigCommerce Support Community: Getting customer group final prices through APIs. support.bigcommerce.com customer group final prices through APIs
On the solution:
- BigCommerce Developer Center: Price Lists. developer.bigcommerce.com price lists
- BigCommerce Docs: Get Price List Assignments. developer.bigcommerce.com price list assignments
- BigCommerce Docs: Customers V2. developer.bigcommerce.com customers v2
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 clear up your stale pricing?
If this saved you a support escalation or caught carts you would have otherwise missed, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi