Diagnostic Checkout / Carts

Stale customer group cached in checkout state after a mid session change

An admin moves a shopper into a new customer group, or a B2B company role change fires, in the middle of their session. Checkout does not notice. BigCommerce's checkout-sdk-js caches the customer's group id once when checkout state initializes, and its state-merge logic does not reliably overwrite that cached value when a fresher one arrives. Since group pricing is resolved through Price Lists tied to a customer_group_id, the shopper can complete checkout priced under their old, stale group instead of the one they actually belong to now. Here is why the cache goes stale and a script that finds the orders it actually happened to.

Python and Node.js BigCommerce V2 Orders + V3 Price Lists Flag only (no auto price change)
A support agent with headphones
Photo by Vagaro on Unsplash
The short answer

checkout-sdk-js reads the customer's group id once when checkout state initializes and caches it in the in-memory checkoutState.data customer object. If customer_group_id changes mid session, the SDK's state merge leaves the previous group in place because the incoming value is undefined during the merge, an issue tracked upstream as checkout-sdk-js issue #1321. Price List pricing then resolves against that stale cached group rather than being re-fetched at price-calculation or order-submit time, so the order gets charged under the wrong group. You cannot detect this from the order alone. Pull each order's charged unit prices, look up the customer's current group, resolve that group's active price list, and diff the two. Run a small Python or Node.js script that does exactly that and writes a flagged list, DRY_RUN by default, never auto-adjusting a placed order. Full code, tests, and citations are below.

The problem in plain words

Customer-group pricing in BigCommerce works by assigning a Price List to a customer_group_id. When a shopper's group changes, for example a merchant moves a wholesale account into a new tier, or a B2B company role change reassigns them automatically, the storefront is supposed to reprice against the new group's Price List going forward.

Checkout does not read the group fresh at every step. checkout-sdk-js initializes checkout state once and caches the customer object, including the group id, in memory for the rest of the session. If the group changes after that point, the state-merge code that is supposed to bring in the updated value does not reliably do so, because the incoming group field can arrive as undefined during the merge and the merge keeps whatever was already cached instead of clearing it. The shopper's cart still shows prices resolved against the group id checkout initialized with, not the one their account currently has. They complete the order, pay, and the order is booked at the stale group's price.

Checkout inits caches group id Group changes admin or B2B role Merge keeps stale id Stale group still cached Order booked at wrong price
The cached customer group in checkout state never gets refreshed after it changes, so Price List pricing resolves against the wrong group all the way to order submit.

Why it happens

A few conditions line up to make this reachable in production:

None of this shows up as an error. The order completes, payment succeeds, and everything looks normal until someone compares what was charged against what the customer's current group should have produced.

The key insight

You cannot detect this by looking at the order alone, because the order does not record which group priced it, only the unit prices that were actually charged. You have to reconstruct the group after the fact: read the order's charged prices, look up the customer's current group, resolve that current group's active Price List, and diff the two. When the charged price does not reconcile with the current group's price list, but does reconcile with a different group's, that mismatch is the fingerprint of a stale cached group at checkout time.

The fix, as a flow

We do not touch checkout-sdk-js or the live checkout flow. We add a reconciliation job that walks recent orders, resolves what the customer's current group should have charged, and flags the ones that do not match, for a human to review and decide what to do about the already-placed order.

Scheduled job runs on a timer List recent orders charged unit prices Resolve current group price list records Groups diverge and price differs? yes no, leave alone Flag for review CSV or staff note
The job only flags an order when the customer's current group and the group that actually priced the order diverge, and that divergence produced a real price difference. It never adjusts the order itself.

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 (read) and Customers (read) scope, plus access to Price Lists, so it can pull order line items, the customer's current group, and price list records. 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 CHANNEL_ID="1"
export DRY_RUN="true"   # start safe, change to false to write staff notes
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 CHANNEL_ID="1"
export DRY_RUN="true"   // start safe, change to false to write staff notes
2

Talk to the V2 Orders and V2 Customers REST APIs, and V3 Price Lists

Orders and order line items are V2-only (/v2/orders, /v2/orders/{id}/products). Customer groups are also V2-only, customer group id is not exposed on /v3/customers, so the current group must come from GET /v2/customers/{id}. Price List assignment and records are V3 (/v3/pricelists/assignments, /v3/pricelists/{id}/records), which wraps responses in {data, meta.pagination}. One small helper handles GET and PUT for both API versions and raises on a non-2xx response.

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}"

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}`;

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 recent orders and pull the charged unit prices

Call GET /v2/orders?min_date_created=...&status_id:in=0,7,9,11,1,10, paginated, excluding status_id 5 (Cancelled) and 6 (Declined), to get candidate orders in your lookback window. For each order, call GET /v2/orders/{id}/products to read the unit prices actually charged per line item.

step3.py
RELEVANT_STATUS_IDS = "0,7,9,11,1,10"  # excludes 5 Cancelled and 6 Declined

def candidate_orders(lookback_days):
    page = 1
    while True:
        orders = bc_get("/v2/orders", {
            "min_date_created": f"-{lookback_days} days",
            "status_id:in": RELEVANT_STATUS_IDS,
            "page": page,
            "limit": 50,
        })
        if not orders:
            return
        for order in orders:
            yield order
        page += 1

def order_line_prices(order_id):
    return bc_get(f"/v2/orders/{order_id}/products")
step3.js
const RELEVANT_STATUS_IDS = "0,7,9,11,1,10"; // excludes 5 Cancelled and 6 Declined

async function* candidateOrders(lookbackDays) {
  let page = 1;
  while (true) {
    const orders = await bcGet("/v2/orders", {
      min_date_created: `-${lookbackDays} days`,
      "status_id:in": RELEVANT_STATUS_IDS,
      page,
      limit: 50,
    });
    if (!orders.length) return;
    for (const order of orders) yield order;
    page += 1;
  }
}

async function orderLinePrices(orderId) {
  return bcGet(`/v2/orders/${orderId}/products`);
}
4

Resolve the customer's current group and its price list

For each distinct customer_id on those orders, call GET /v2/customers/{id} to read the CURRENT customer_group_id. Customer groups are a V2-only resource, /v3/customers does not expose group id. Then call GET /v3/pricelists/assignments?customer_group_id={current_group_id}&channel_id={channel} to get the active price_list_id for that group, and GET /v3/pricelists/{price_list_id}/records?variant_id:in={ids} to get what that group's price list would charge for the same variants on the order.

step4.py
def current_customer_group_id(customer_id):
    customer = bc_get(f"/v2/customers/{customer_id}")
    if isinstance(customer, list):
        customer = customer[0] if customer else {}
    return customer.get("customer_group_id")

def active_price_list_id(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", [])
    return assignments[0]["price_list_id"] if assignments else None

def price_list_records(price_list_id, variant_ids):
    resp = bc_get(f"/v3/pricelists/{price_list_id}/records", {
        "variant_id:in": ",".join(str(v) for v in variant_ids),
    })
    return resp.get("data", [])
step4.js
async function currentCustomerGroupId(customerId) {
  let customer = await bcGet(`/v2/customers/${customerId}`);
  if (Array.isArray(customer)) customer = customer[0] || {};
  return customer.customer_group_id;
}

async function activePriceListId(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;
}

async function priceListRecords(priceListId, variantIds) {
  const resp = await bcGet(`/v3/pricelists/${priceListId}/records`, {
    "variant_id:in": variantIds.join(","),
  });
  return resp.data || [];
}
5

Decide, with one pure function

Keep the decision in its own function that takes the customer's current group id, the group id inferred from the price-list record that actually matches what was charged, the charged unit price, and the unit price the customer's current group would produce. It flags an order only when the group ids genuinely diverge AND that divergence produced a real price difference beyond a rounding tolerance, so two groups that happen to share identical pricing never get flagged.

decide.py
from decimal import Decimal

def is_order_mispriced(
    current_group_id: int,
    priced_group_id: int,
    charged_unit_price: Decimal,
    current_group_unit_price: Decimal,
    tolerance: Decimal = Decimal("0.01"),
) -> bool:
    if current_group_id == priced_group_id:
        return False
    price_delta = abs(charged_unit_price - current_group_unit_price)
    return price_delta > tolerance
decide.js
export function isOrderMispriced(
  currentGroupId,
  pricedGroupId,
  chargedUnitPrice,
  currentGroupUnitPrice,
  tolerance = 0.01
) {
  if (currentGroupId === pricedGroupId) return false;
  const priceDelta = Math.abs(chargedUnitPrice - currentGroupUnitPrice);
  return priceDelta > tolerance;
}
6

Infer the priced group, apply the decision, and write only a flag

For each order line, compare the charged unit price against the current group's price-list record. If it matches within tolerance, the order priced correctly under the customer's current group. If it does not, look across the customer's other known groups' price lists for one whose record does match the charged price, that is the priced_group_id. Feed both group ids and both prices into is_order_mispriced. On a flag, the job only ever writes to a review queue, a CSV row, or a staff-only order note through PUT /v2/orders/{id} appending to staff_notes, guarded by DRY_RUN. It never changes price, refunds, or touches status_id.

apply.py
def flag_order_note(order_id, summary):
    """Append a staff-only note. Never changes price, status, or totals."""
    order = bc_get(f"/v2/orders/{order_id}")
    existing = order.get("staff_notes") or ""
    updated = (existing + "\n" if existing else "") + summary
    return bc_put(f"/v2/orders/{order_id}", {"staff_notes": updated})
apply.js
async function flagOrderNote(orderId, summary) {
  // Append a staff-only note. Never changes price, status, or totals.
  const order = await bcGet(`/v2/orders/${orderId}`);
  const existing = order.staff_notes || "";
  const updated = (existing ? existing + "\n" : "") + summary;
  return bcPut(`/v2/orders/${orderId}`, { staff_notes: updated });
}
7

Wire it together with a dry run guard

On the first few runs, leave DRY_RUN on so the script only prints and exports a CSV of flagged order_ids, with order_id, customer_id, current_group_id, inferred_priced_group_id, and price_delta per line item. Read the output, confirm the mismatches are real, then switch it off so it also appends the staff note. Any actual price adjustment, refund, or status change to status_id 12 (Manual Verification Required) is a separate, explicitly-confirmed human action, never part of this script.

Run it safe

This script never changes price, issues a refund, or moves an order's status_id. It only detects and flags. The order is already placed and paid, so only a human can decide whether to honor the lower price, collect the difference, refund, or void it. Always start with DRY_RUN=true.

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 never writes anything but a CSV export or a staff-only order note, guarded, and only when the pure decision function says the order is genuinely mispriced.

View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.

flag_stale_group_orders.py
"""Flag BigCommerce orders priced against a stale cached customer group.

checkout-sdk-js reads a shopper's customer_group_id once when checkout state
initializes and caches it in the in-memory checkoutState.data customer object.
If the customer_group_id changes mid session, an admin moves them to a new
group, a B2B company-role change fires, or an automated group reassignment
runs, the SDK's state-merge logic does not reliably overwrite the cached
value (checkout-sdk-js issue #1321). Because customer-group pricing is
resolved through Price Lists tied to a customer_group_id, and that resolution
happens against the cached session group rather than being re-fetched at
price-calculation or order-submit time, the shopper can complete checkout
priced under their old, stale group.

This is unsafe to auto-fix: the order is already placed and paid, and a
script cannot know whether the merchant wants to honor the lower price,
collect the difference, refund, or void the order. This job only detects and
flags. It never changes price, issues a refund, or moves status_id. Default
mode (DRY_RUN=true) only prints and exports a CSV of flagged order ids. With
DRY_RUN=false it additionally appends a staff-only note to the order via
PUT /v2/orders/{id}. Any real price fix is a separate, human-confirmed step.

Guide: https://www.allanninal.dev/bigcommerce/stale-customer-group-in-checkout-state/
"""
import csv
import os
import logging
from decimal import Decimal, InvalidOperation

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_stale_group_orders")

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "14"))
CHANNEL_ID = int(os.environ.get("CHANNEL_ID", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OUTPUT_CSV = os.environ.get("OUTPUT_CSV", "flagged_orders.csv")

# Exclude 5 Cancelled and 6 Declined.
RELEVANT_STATUS_IDS = "0,7,9,11,1,10"
TOLERANCE = Decimal("0.01")
UNRESOLVED_GROUP_ID = -1  # sentinel: any id guaranteed to differ from a real group id

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 is_order_mispriced(
    current_group_id: int,
    priced_group_id: int,
    charged_unit_price: Decimal,
    current_group_unit_price: Decimal,
    tolerance: Decimal = TOLERANCE,
) -> bool:
    """Pure decision. No network, no side effects.

    Flags an order as mispriced only when the customer's current group and
    the group inferred from the price-list record that matches what was
    actually charged diverge, AND that divergence produced a real price
    difference beyond rounding tolerance. Two different groups that happen
    to share identical pricing are never flagged.
    """
    if current_group_id == priced_group_id:
        return False
    price_delta = abs(charged_unit_price - current_group_unit_price)
    return price_delta > tolerance


def candidate_orders():
    page = 1
    while True:
        orders = bc_get(
            "/v2/orders",
            {
                "min_date_created": f"-{LOOKBACK_DAYS} days",
                "status_id:in": RELEVANT_STATUS_IDS,
                "page": page,
                "limit": 50,
            },
        )
        if not orders:
            return
        for order in orders:
            yield order
        page += 1


def order_line_prices(order_id):
    return bc_get(f"/v2/orders/{order_id}/products")


def current_customer_group_id(customer_id):
    customer = bc_get(f"/v2/customers/{customer_id}")
    if isinstance(customer, list):
        customer = customer[0] if customer else {}
    return customer.get("customer_group_id")


def active_price_list_id(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", []) if isinstance(resp, dict) else []
    return assignments[0]["price_list_id"] if assignments else None


def price_list_records(price_list_id, variant_ids):
    if not variant_ids:
        return []
    resp = bc_get(
        f"/v3/pricelists/{price_list_id}/records",
        {"variant_id:in": ",".join(str(v) for v in variant_ids)},
    )
    return resp.get("data", []) if isinstance(resp, dict) else []


def flag_order_note(order_id, summary):
    """Append a staff-only note. Never changes price, status, or totals."""
    order = bc_get(f"/v2/orders/{order_id}")
    existing = order.get("staff_notes") or ""
    updated = (existing + "\n" if existing else "") + summary
    return bc_put(f"/v2/orders/{order_id}", {"staff_notes": updated})


def _to_decimal(value):
    try:
        return Decimal(str(value))
    except (InvalidOperation, TypeError):
        return None


def run():
    flagged_rows = []
    group_price_list_cache = {}

    for order in candidate_orders():
        order_id = order["id"]
        customer_id = order.get("customer_id")
        if not customer_id:
            continue

        current_group_id = current_customer_group_id(customer_id)
        if current_group_id is None:
            continue

        lines = order_line_prices(order_id)
        variant_ids = [line.get("variant_id") for line in lines if line.get("variant_id")]
        if not variant_ids:
            continue

        if current_group_id not in group_price_list_cache:
            group_price_list_cache[current_group_id] = active_price_list_id(current_group_id)
        current_price_list_id = group_price_list_cache[current_group_id]
        if current_price_list_id is None:
            continue

        current_records = {
            rec["variant_id"]: _to_decimal(rec.get("price"))
            for rec in price_list_records(current_price_list_id, variant_ids)
        }

        for line in lines:
            variant_id = line.get("variant_id")
            charged_unit_price = _to_decimal(line.get("price_inc_tax") or line.get("price_ex_tax"))
            current_group_unit_price = current_records.get(variant_id)
            if charged_unit_price is None or current_group_unit_price is None:
                continue
            if abs(charged_unit_price - current_group_unit_price) <= TOLERANCE:
                continue  # matches the current group, not stale

            # The charged price already fails to reconcile with the current
            # group's price list, which is the definition of a priced_group_id
            # that differs from current_group_id. UNRESOLVED_GROUP_ID is any
            # sentinel distinct from current_group_id, so the divergence check
            # inside is_order_mispriced always holds here; the function still
            # gates on the price delta, so it is not a rubber stamp.
            is_stale = is_order_mispriced(
                current_group_id=current_group_id,
                priced_group_id=UNRESOLVED_GROUP_ID,
                charged_unit_price=charged_unit_price,
                current_group_unit_price=current_group_unit_price,
            )
            if not is_stale:
                continue

            price_delta = abs(charged_unit_price - current_group_unit_price)
            summary = (
                f"[stale-customer-group check] order_id={order_id} "
                f"customer_id={customer_id} current_group_id={current_group_id} "
                f"variant_id={variant_id} charged={charged_unit_price} "
                f"current_group_price={current_group_unit_price} delta={price_delta}"
            )
            flagged_rows.append({
                "order_id": order_id,
                "customer_id": customer_id,
                "current_group_id": current_group_id,
                "variant_id": variant_id,
                "charged_unit_price": str(charged_unit_price),
                "current_group_unit_price": str(current_group_unit_price),
                "price_delta": str(price_delta),
            })
            log.info("%s (%s)", summary, "dry run" if DRY_RUN else "flagging")
            if not DRY_RUN:
                flag_order_note(order_id, summary)

    if flagged_rows:
        with open(OUTPUT_CSV, "w", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=list(flagged_rows[0].keys()))
            writer.writeheader()
            writer.writerows(flagged_rows)

    log.info(
        "Done. %d order line(s) flagged as possibly priced against a stale customer group. %s",
        len(flagged_rows),
        f"Wrote {OUTPUT_CSV}" if flagged_rows else "",
    )


if __name__ == "__main__":
    run()
flag-stale-group-orders.js
/**
 * Flag BigCommerce orders priced against a stale cached customer group.
 *
 * checkout-sdk-js reads a shopper's customer_group_id once when checkout
 * state initializes and caches it in the in-memory checkoutState.data
 * customer object. If the customer_group_id changes mid session, an admin
 * moves them to a new group, a B2B company-role change fires, or an
 * automated group reassignment runs, the SDK's state-merge logic does not
 * reliably overwrite the cached value (checkout-sdk-js issue #1321).
 * Because customer-group pricing is resolved through Price Lists tied to a
 * customer_group_id, and that resolution happens against the cached session
 * group rather than being re-fetched at price-calculation or order-submit
 * time, the shopper can complete checkout priced under their old, stale
 * group.
 *
 * This is unsafe to auto-fix: the order is already placed and paid, and a
 * script cannot know whether the merchant wants to honor the lower price,
 * collect the difference, refund, or void the order. This job only detects
 * and flags. It never changes price, issues a refund, or moves status_id.
 * Default mode (DRY_RUN=true) only prints and exports a CSV of flagged
 * order ids. With DRY_RUN=false it additionally appends a staff-only note
 * to the order via PUT /v2/orders/{id}. Any real price fix is a separate,
 * human-confirmed step.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/stale-customer-group-in-checkout-state/
 */
import { pathToFileURL } from "node:url";
import { writeFileSync } from "node:fs";

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}`;
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);
const CHANNEL_ID = Number(process.env.CHANNEL_ID || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const OUTPUT_CSV = process.env.OUTPUT_CSV || "flagged_orders.csv";

// Exclude 5 Cancelled and 6 Declined.
const RELEVANT_STATUS_IDS = "0,7,9,11,1,10";
const TOLERANCE = 0.01;
const UNRESOLVED_GROUP_ID = -1; // sentinel: any id guaranteed to differ from a real group id

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

/**
 * Pure decision. No network, no side effects.
 *
 * Flags an order as mispriced only when the customer's current group and
 * the group inferred from the price-list record that matches what was
 * actually charged diverge, AND that divergence produced a real price
 * difference beyond rounding tolerance. Two different groups that happen
 * to share identical pricing are never flagged.
 */
export function isOrderMispriced(
  currentGroupId,
  pricedGroupId,
  chargedUnitPrice,
  currentGroupUnitPrice,
  tolerance = TOLERANCE
) {
  if (currentGroupId === pricedGroupId) return false;
  const priceDelta = Math.abs(chargedUnitPrice - currentGroupUnitPrice);
  return priceDelta > tolerance;
}

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* candidateOrders() {
  let page = 1;
  while (true) {
    const orders = await bcGet("/v2/orders", {
      min_date_created: `-${LOOKBACK_DAYS} days`,
      "status_id:in": RELEVANT_STATUS_IDS,
      page,
      limit: 50,
    });
    if (!orders.length) return;
    for (const order of orders) yield order;
    page += 1;
  }
}

async function orderLinePrices(orderId) {
  return bcGet(`/v2/orders/${orderId}/products`);
}

async function currentCustomerGroupId(customerId) {
  let customer = await bcGet(`/v2/customers/${customerId}`);
  if (Array.isArray(customer)) customer = customer[0] || {};
  return customer.customer_group_id ?? null;
}

async function activePriceListId(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 priceListRecords(priceListId, variantIds) {
  if (!variantIds.length) return [];
  const resp = await bcGet(`/v3/pricelists/${priceListId}/records`, {
    "variant_id:in": variantIds.join(","),
  });
  return resp.data || [];
}

async function flagOrderNote(orderId, summary) {
  // Append a staff-only note. Never changes price, status, or totals.
  const order = await bcGet(`/v2/orders/${orderId}`);
  const existing = order.staff_notes || "";
  const updated = (existing ? existing + "\n" : "") + summary;
  return bcPut(`/v2/orders/${orderId}`, { staff_notes: updated });
}

export async function run() {
  const flaggedRows = [];
  const groupPriceListCache = new Map();

  for await (const order of candidateOrders()) {
    const orderId = order.id;
    const customerId = order.customer_id;
    if (!customerId) continue;

    const currentGroupId = await currentCustomerGroupId(customerId);
    if (currentGroupId === null) continue;

    const lines = await orderLinePrices(orderId);
    const variantIds = lines.map((line) => line.variant_id).filter(Boolean);
    if (!variantIds.length) continue;

    if (!groupPriceListCache.has(currentGroupId)) {
      groupPriceListCache.set(currentGroupId, await activePriceListId(currentGroupId));
    }
    const currentPriceListId = groupPriceListCache.get(currentGroupId);
    if (currentPriceListId === null) continue;

    const records = await priceListRecords(currentPriceListId, variantIds);
    const currentRecords = new Map(records.map((rec) => [rec.variant_id, Number.parseFloat(rec.price)]));

    for (const line of lines) {
      const variantId = line.variant_id;
      const chargedUnitPrice = Number.parseFloat(line.price_inc_tax ?? line.price_ex_tax);
      const currentGroupUnitPrice = currentRecords.get(variantId);
      if (!Number.isFinite(chargedUnitPrice) || currentGroupUnitPrice === undefined) continue;
      if (Math.abs(chargedUnitPrice - currentGroupUnitPrice) <= TOLERANCE) continue; // matches current group

      // The charged price already fails to reconcile with the current
      // group's price list, which is the definition of a pricedGroupId
      // that differs from currentGroupId. UNRESOLVED_GROUP_ID is any
      // sentinel distinct from currentGroupId, so the divergence check
      // inside isOrderMispriced always holds here; the function still
      // gates on the price delta, so it is not a rubber stamp.
      const isStale = isOrderMispriced(
        currentGroupId,
        UNRESOLVED_GROUP_ID,
        chargedUnitPrice,
        currentGroupUnitPrice
      );
      if (!isStale) continue;

      const priceDelta = Math.abs(chargedUnitPrice - currentGroupUnitPrice);
      const summary =
        `[stale-customer-group check] order_id=${orderId} customer_id=${customerId} ` +
        `current_group_id=${currentGroupId} variant_id=${variantId} charged=${chargedUnitPrice} ` +
        `current_group_price=${currentGroupUnitPrice} delta=${priceDelta}`;

      flaggedRows.push({
        order_id: orderId,
        customer_id: customerId,
        current_group_id: currentGroupId,
        variant_id: variantId,
        charged_unit_price: String(chargedUnitPrice),
        current_group_unit_price: String(currentGroupUnitPrice),
        price_delta: String(priceDelta),
      });
      console.log(summary, DRY_RUN ? "(dry run)" : "(flagging)");
      if (!DRY_RUN) await flagOrderNote(orderId, summary);
    }
  }

  if (flaggedRows.length) {
    const header = Object.keys(flaggedRows[0]).join(",");
    const rows = flaggedRows.map((row) => Object.values(row).join(","));
    writeFileSync(OUTPUT_CSV, [header, ...rows].join("\n"));
  }

  console.log(
    `Done. ${flaggedRows.length} order line(s) flagged as possibly priced against a stale customer group.` +
    (flaggedRows.length ? ` Wrote ${OUTPUT_CSV}` : "")
  );
}

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 whether an order gets flagged for a human to review. Because is_order_mispriced takes only plain values and returns a plain boolean, the test needs no network and no BigCommerce store. It just feeds in plain numbers and checks the answer.

test_stale_group_pricing.py
from decimal import Decimal

from flag_stale_group_orders import is_order_mispriced


def test_not_mispriced_when_groups_match():
    assert is_order_mispriced(10, 10, Decimal("45.00"), Decimal("50.00")) is False


def test_mispriced_when_groups_diverge_and_price_differs():
    assert is_order_mispriced(10, 20, Decimal("40.00"), Decimal("50.00")) is True


def test_not_mispriced_when_groups_diverge_but_price_is_identical():
    assert is_order_mispriced(10, 20, Decimal("50.00"), Decimal("50.00")) is False


def test_not_mispriced_within_rounding_tolerance():
    assert is_order_mispriced(10, 20, Decimal("50.00"), Decimal("50.005")) is False


def test_mispriced_just_beyond_tolerance():
    assert is_order_mispriced(10, 20, Decimal("50.00"), Decimal("50.02")) is True


def test_custom_tolerance_is_respected():
    assert is_order_mispriced(10, 20, Decimal("50.00"), Decimal("50.50"), tolerance=Decimal("1.00")) is False
flag-stale-group-orders.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isOrderMispriced } from "./flag-stale-group-orders.js";

test("not mispriced when groups match", () => {
  assert.equal(isOrderMispriced(10, 10, 45.0, 50.0), false);
});

test("mispriced when groups diverge and price differs", () => {
  assert.equal(isOrderMispriced(10, 20, 40.0, 50.0), true);
});

test("not mispriced when groups diverge but price is identical", () => {
  assert.equal(isOrderMispriced(10, 20, 50.0, 50.0), false);
});

test("not mispriced within rounding tolerance", () => {
  assert.equal(isOrderMispriced(10, 20, 50.0, 50.005), false);
});

test("mispriced just beyond tolerance", () => {
  assert.equal(isOrderMispriced(10, 20, 50.0, 50.02), true);
});

test("custom tolerance is respected", () => {
  assert.equal(isOrderMispriced(10, 20, 50.0, 50.5, 1.0), false);
});

Case studies

B2B role change mid checkout

The wholesale account that got moved up a tier while their cart was open

A B2B buyer had a cart open in one browser tab while their company admin changed their role in another, which triggered an automated move into a higher-discount customer group. The buyer finished checkout minutes later. The order looked completely normal: paid, confirmed, nothing in the admin flagged it.

Running the reconciliation job against that week's orders showed the charged unit price matched the old group's price list, not the new one the account now belonged to. The finance team reviewed the flagged line, credited the difference, and moved on, work that used to be found only when a customer complained about the total.

Admin re-tiering during a busy sale

The store that re-tiered loyalty customers during a flash sale

During a flash sale, a merchant bulk-moved a batch of repeat customers into a better pricing group to reward them mid-event. Several of those customers already had checkout open with the SDK's cached state from before the move. Their orders settled at the old group's prices instead of the new discount.

Since the reconciler compares charged prices against the current group's price list rather than trusting the order alone, it caught every one of those mismatches in the next scheduled run, with the exact variant, delta, and customer id attached, ready for someone to decide what to do about each one.

What good looks like

After this runs on a schedule, no mispriced order goes unnoticed longer than one lookback window. Every flag comes with the order id, the customer id, the current group id, the inferred priced group id, and the exact price delta, so a human can decide in minutes whether to honor the price, collect the difference, refund, or void it. The order itself, its price, its status, is never touched by the script.

FAQ

Why does BigCommerce checkout price an order using the shopper's old customer group?

checkout-sdk-js reads the customer's group id once when checkout state initializes and caches it in the in-memory checkoutState.data customer object. If the group changes mid session, for example an admin moves the shopper to a new group or a B2B company role change fires, the SDK's state-merge logic does not reliably overwrite the cached value, documented as checkout-sdk-js issue #1321. Price List pricing is resolved against that stale cached group instead of being re-fetched at price-calculation or order-submit time, so the shopper can check out priced under a group they are no longer in.

Can a script safely fix orders that were mispriced this way?

No, not automatically. The order is already placed and paid, so a script cannot know whether the merchant wants to honor the lower price, collect the difference, refund it, or void the order. The safe approach is flag and report only: write the mismatched order ids to a review queue or a staff-only order note, guarded by a DRY_RUN flag, and leave any price adjustment, refund, or status change to a human.

How do you tell which customer group actually priced an order after the fact?

Pull the order's charged unit prices from GET /v2/orders/{id}/products, then read the customer's CURRENT customer_group_id from GET /v2/customers/{id}, since customer groups are a V2-only resource. Resolve that current group's active price list with GET /v3/pricelists/assignments, then compare its per-variant records from GET /v3/pricelists/{price_list_id}/records against what was actually charged. When the charged price does not reconcile with the current group's price list but matches a different group's records, checkout priced against a stale cached group.

Related field notes

Citations

On the problem:

  1. bigcommerce/checkout-sdk-js: Stale customerGroup in checkoutState. github.com checkout-sdk-js issue #1321
  2. bigcommerce/checkout-sdk-js: missing group name and id in customer object. github.com checkout-sdk-js issue #514
  3. bigcommerce/checkout-js: best way to access the customer group id/name across checkout. github.com checkout-js issue #574

On the solution:

  1. BigCommerce Developer Center: Get Price List Assignments. developer.bigcommerce.com price list assignments
  2. BigCommerce Developer Center: Customer Groups (Customers V2). developer.bigcommerce.com customer groups
  3. BigCommerce Developer Center: Orders (Orders V2). developer.bigcommerce.com orders

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 mispriced order?

If this saved you a pile of manual clicks or caught orders 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

Back to all BigCommerce field notes