Repair Pricing / Price Lists

Legacy customer group discount blocks a price list from attaching

You assigned a Price List to a customer group through the V3 API, the assignment record exists, but the storefront still shows the old pricing. Nothing about the assignment call failed. The group just still has a legacy discount configured underneath it, and BigCommerce customer groups only ever run one pricing mechanism at a time. Here is why that collision happens and a small script that finds and clears every group where it is silently blocking a price list.

Python and Node.js BigCommerce V2 Customer Groups + V3 Price Lists Safe by default (dry run)
Man in black jacket walking on sidewalk during daytime
Photo by Roma Kaiuk🇺🇦 on Unsplash
The short answer

BigCommerce customer groups support two mutually exclusive pricing mechanisms: legacy discount_rules (store-wide, category, or product percent, fixed, or price-modifier discounts, set through the V2 Customer Groups API) and V3 Price List assignments. A group can only run one at a time. If discount_rules is still non-empty on a group from before Price Lists were adopted, a Price List assignment you create with POST /v3/pricelists/assignments will not visibly apply at storefront for that group, because the legacy discount takes precedence and the group's pricing representation reverts to method/amount instead of price_list_id. Run a small Python or Node.js script that lists every group from GET /v2/customer_groups, cross-references it against GET /v3/pricelists/assignments, flags every group that has both a non-empty discount_rules array and an active assignment, and clears the legacy discount with PUT /v2/customer_groups/{id} and {"discount_rules": []}. Full code, tests, and a dry run guard are below.

The problem in plain words

A BigCommerce customer group is not just a label. It can carry its own pricing logic in one of two completely separate ways. The old way, still fully supported, is a discount_rules array set on the group itself through the V2 Customer Groups API: a flat percent off, a fixed dollar amount off, or a price modifier, scoped to the whole store, a category, or a product. The newer way is a V3 Price List, a separate object with its own per-product and per-variant prices, attached to a group through a row in /v3/pricelists/assignments.

Both mechanisms can technically exist on the same group at the same time, because nothing in the API rejects the second one once the first is present. But only one of them actually drives storefront pricing. When a merchant migrates a wholesale or VIP group over to Price Lists for finer merchandising control, and never goes back to clear out the discount that group had before, the old discount wins. The assignment call succeeds, the row shows up in /v3/pricelists/assignments, and yet every customer in that group keeps seeing prices computed from the leftover percent or fixed discount, not from the price list they were just moved to.

Customer group old discount_rules still set Price List assigned POST /v3/pricelists/assignments Legacy discount wins method/amount not price_list_id Storefront price unchanged
The price list assignment exists and looks correct in the API. The group's leftover legacy discount_rules still wins the storefront pricing decision.

Why it happens

Nothing in the API stops a merchant, or an integration, from configuring both mechanisms on the same group. A few common ways stores end up with the collision:

This is a documented but easy to miss interaction between two BigCommerce pricing systems that were built years apart. See the citations at the end for the exact support threads and docs.

The key insight

A Price List assignment existing in /v3/pricelists/assignments is not proof that it is the thing driving storefront prices. It only proves the assignment was created. The group's own discount_rules array from GET /v2/customer_groups is what actually decides whether the legacy path or the price list path wins. So the safe pattern is not "assign a price list and assume it works." It is "assign the price list, then confirm the group has no leftover legacy discount, because the two mechanisms are mutually exclusive and the legacy one takes precedence when both are present."

The fix, as a flow

We do not touch the price list itself or how it is assigned. We add a job that lists every customer group, lists every active price list assignment, cross-references the two, and clears the legacy discount only on the groups where both are present at once.

List customer groups GET /v2/customer_groups List price list assignments GET /v3/pricelists/assignments Rules set and assignment exists? yes, blocked no, leave alone Log flagged group id, name, rules, list id discount_rules: [] PUT /v2/customer_groups/{id}
The script only clears discount_rules on groups where the legacy discount and a price list assignment are both present at once. The price list assignment itself is never touched.

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 Customers (modify) scope so it can read and update customer groups, and Carts and Checkout Content, or the relevant Pricing scope, so it can read /v3/pricelists/assignments. 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 DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to both the V2 and V3 REST APIs

Customer groups live on the V2 API at https://api.bigcommerce.com/stores/{store_hash}/v2/. Price list assignments live on the V3 API at https://api.bigcommerce.com/stores/{store_hash}/v3/, and every V3 list response is wrapped in the standard {data, meta.pagination} envelope. Both use the same X-Auth-Token header. A small helper handles GET and PUT 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_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_BASE_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"

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

def bc_get_v2(path, params=None):
    r = requests.get(f"{API_BASE_V2}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else []

def bc_get_v3(path, params=None):
    r = requests.get(f"{API_BASE_V3}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else {"data": [], "meta": {}}

def bc_put_v2(path, body):
    r = requests.put(f"{API_BASE_V2}{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_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;

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

async function bcGetV2(path, params = {}) {
  const url = new URL(`${API_BASE_V2}${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 bcGetV3(path, params = {}) {
  const url = new URL(`${API_BASE_V3}${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) : { data: [], meta: {} };
}

async function bcPutV2(path, body) {
  const res = await fetch(`${API_BASE_V2}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
3

List every customer group and every price list assignment

Call GET /v2/customer_groups, paginated with ?page and ?limit, to get every group in the store. Each group object includes a discount_rules array whenever legacy discounts are configured. Call GET /v3/pricelists/assignments, paginated through meta.pagination, to get every active assignment. Each row has price_list_id, customer_group_id, and channel_id.

step3.py
def all_customer_groups():
    groups = []
    page = 1
    while True:
        batch = bc_get_v2("/customer_groups", {"page": page, "limit": 250})
        if not batch:
            return groups
        groups.extend(batch)
        page += 1

def all_price_list_assignments():
    assignments = []
    page = 1
    while True:
        result = bc_get_v3("/pricelists/assignments", {"page": page, "limit": 250})
        data = result.get("data") or []
        if not data:
            return assignments
        assignments.extend(data)
        page += 1
step3.js
async function allCustomerGroups() {
  const groups = [];
  let page = 1;
  while (true) {
    const batch = await bcGetV2("/customer_groups", { page, limit: 250 });
    if (!batch.length) return groups;
    groups.push(...batch);
    page += 1;
  }
}

async function allPriceListAssignments() {
  const assignments = [];
  let page = 1;
  while (true) {
    const result = await bcGetV3("/pricelists/assignments", { page, limit: 250 });
    const data = result.data || [];
    if (!data.length) return assignments;
    assignments.push(...data);
    page += 1;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes the already-fetched groups and assignments and returns the list of blocked groups. A group is blocked only when it has a non-empty discount_rules array AND its id shows up as customer_group_id in at least one price list assignment. No network call inside it at all, so it is fully unit-testable.

decide.py
def find_blocked_price_list_groups(customer_groups, price_list_assignments):
    """
    A group is 'blocked' if it has a non-empty legacy discount_rules list
    AND it also appears as customer_group_id in at least one
    price_list_assignments entry. Pure transformation, no I/O.
    """
    assigned_group_ids = {}
    for a in price_list_assignments:
        assigned_group_ids.setdefault(a["customer_group_id"], []).append(a["price_list_id"])

    blocked = []
    for g in customer_groups:
        rules = g.get("discount_rules") or []
        gid = g["id"]
        if rules and gid in assigned_group_ids:
            blocked.append({
                "group_id": gid,
                "group_name": g.get("name"),
                "discount_rules": rules,
                "price_list_ids": assigned_group_ids[gid],
            })
    return blocked
decide.js
export function findBlockedPriceListGroups(customerGroups, priceListAssignments) {
  const assignedGroupIds = new Map();
  for (const a of priceListAssignments) {
    const list = assignedGroupIds.get(a.customer_group_id) || [];
    list.push(a.price_list_id);
    assignedGroupIds.set(a.customer_group_id, list);
  }

  const blocked = [];
  for (const g of customerGroups) {
    const rules = g.discount_rules || [];
    const gid = g.id;
    if (rules.length && assignedGroupIds.has(gid)) {
      blocked.push({
        group_id: gid,
        group_name: g.name,
        discount_rules: rules,
        price_list_ids: assignedGroupIds.get(gid),
      });
    }
  }
  return blocked;
}
5

Clear the legacy discount, never the assignment

For each flagged group, call PUT /v2/customer_groups/{id} with {"discount_rules": []}. The V2 endpoint overwrites discount_rules in bulk on every PUT, so an empty array removes every legacy rule without touching category_access or any other field on the group. The price list assignment itself lives in a separate V3 resource and is never written to by this repair.

apply.py
def clear_discount_rules(group_id):
    return bc_put_v2(f"/customer_groups/{group_id}", {"discount_rules": []})
apply.js
async function clearDiscountRules(groupId) {
  return bcPutV2(`/customer_groups/${groupId}`, { discount_rules: [] });
}
6

Wire it together with a dry run guard, and confirm after writing

The loop ties every piece together. Notice the dry run guard. On the first run, leave DRY_RUN on so the script only logs the group id, name, existing discount_rules, and the colliding price_list_id for each blocked group, with no write at all. Read the output, agree with it, then switch it off. When DRY_RUN is false, the script issues the PUT and then re-fetches GET /v2/customer_groups/{id} to confirm discount_rules is now empty and that the price list assignment for that group is still present in /v3/pricelists/assignments.

Run it safe

Always start with DRY_RUN=true. The PUT to /v2/customer_groups/{id} overwrites discount_rules entirely, so only send {"discount_rules": []} once you have confirmed from the dry run log that the group really is meant to run on the price list instead of the legacy discount.

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 ever clears discount_rules on groups where a price list assignment is already active, leaving every other group untouched.

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

clear_blocking_group_discounts.py
"""Clear legacy customer group discount_rules that are blocking a Price List.

BigCommerce customer groups support two mutually exclusive pricing mechanisms:
legacy discount_rules (store-wide, category, or product percent, fixed, or
price-modifier discounts, set through the V2 Customer Groups API) and V3 Price
List assignments. A group can only run one at a time. If discount_rules is
still non-empty on a group from before Price Lists were adopted, a Price List
assignment created with POST /v3/pricelists/assignments will not visibly apply
at storefront for that group, because the legacy discount takes precedence and
the group's pricing representation reverts to method/amount instead of
price_list_id. This job lists every customer group and every active price list
assignment, flags the groups where both a legacy discount and a price list are
configured at once, and clears the legacy discount_rules on those groups only,
leaving the price list assignment itself untouched. Safe to run again and
again.

Guide: https://www.allanninal.dev/bigcommerce/legacy-group-discount-blocks-price-list/
"""
import os
import logging

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_BASE_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
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_v2(path, params=None):
    r = requests.get(f"{API_BASE_V2}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else []


def bc_get_v3(path, params=None):
    r = requests.get(f"{API_BASE_V3}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else {"data": [], "meta": {}}


def bc_put_v2(path, body):
    r = requests.put(f"{API_BASE_V2}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def find_blocked_price_list_groups(customer_groups, price_list_assignments):
    """Pure decision. No network, no side effects.

    customer_groups: V2 /v2/customer_groups records, each
        {"id": int, "name": str, "discount_rules": list, ...}
    price_list_assignments: V3 /v3/pricelists/assignments 'data' records, each
        {"price_list_id": int, "customer_group_id": int, "channel_id": int}

    A group is 'blocked' if it has a non-empty legacy discount_rules list AND
    it also appears as customer_group_id in at least one price_list_assignments
    entry. Returns one dict per blocked group: {group_id, group_name,
    discount_rules, price_list_ids}.
    """
    assigned_group_ids = {}
    for a in price_list_assignments:
        assigned_group_ids.setdefault(a["customer_group_id"], []).append(a["price_list_id"])

    blocked = []
    for g in customer_groups:
        rules = g.get("discount_rules") or []
        gid = g["id"]
        if rules and gid in assigned_group_ids:
            blocked.append({
                "group_id": gid,
                "group_name": g.get("name"),
                "discount_rules": rules,
                "price_list_ids": assigned_group_ids[gid],
            })
    return blocked


def all_customer_groups():
    groups = []
    page = 1
    while True:
        batch = bc_get_v2("/customer_groups", {"page": page, "limit": 250})
        if not batch:
            return groups
        groups.extend(batch)
        page += 1


def all_price_list_assignments():
    assignments = []
    page = 1
    while True:
        result = bc_get_v3("/pricelists/assignments", {"page": page, "limit": 250})
        data = result.get("data") or []
        if not data:
            return assignments
        assignments.extend(data)
        page += 1


def clear_discount_rules(group_id):
    return bc_put_v2(f"/customer_groups/{group_id}", {"discount_rules": []})


def confirm_cleared(group_id):
    group = bc_get_v2(f"/customer_groups/{group_id}")
    rules = group.get("discount_rules") or []
    return len(rules) == 0


def run():
    groups = all_customer_groups()
    assignments = all_price_list_assignments()
    blocked = find_blocked_price_list_groups(groups, assignments)

    log.info("Found %d group(s) with a legacy discount blocking a price list.", len(blocked))

    cleared = 0
    for entry in blocked:
        log.info(
            "group_id=%s group_name=%s discount_rules=%s price_list_ids=%s (%s)",
            entry["group_id"], entry["group_name"], entry["discount_rules"],
            entry["price_list_ids"], "dry run" if DRY_RUN else "clearing",
        )
        if not DRY_RUN:
            clear_discount_rules(entry["group_id"])
            ok = confirm_cleared(entry["group_id"])
            if not ok:
                log.warning("group_id=%s did not confirm empty discount_rules after PUT.", entry["group_id"])
            cleared += 1

    log.info(
        "Done. %d group(s) %s.",
        len(blocked), "to clear" if DRY_RUN else f"cleared ({cleared} confirmed attempted)",
    )


if __name__ == "__main__":
    run()
clear-blocking-group-discounts.js
/**
 * Clear legacy customer group discount_rules that are blocking a Price List.
 *
 * BigCommerce customer groups support two mutually exclusive pricing
 * mechanisms: legacy discount_rules (store-wide, category, or product
 * percent, fixed, or price-modifier discounts, set through the V2 Customer
 * Groups API) and V3 Price List assignments. A group can only run one at a
 * time. If discount_rules is still non-empty on a group from before Price
 * Lists were adopted, a Price List assignment created with POST
 * /v3/pricelists/assignments will not visibly apply at storefront for that
 * group, because the legacy discount takes precedence and the group's
 * pricing representation reverts to method/amount instead of price_list_id.
 * This job lists every customer group and every active price list
 * assignment, flags the groups where both a legacy discount and a price list
 * are configured at once, and clears the legacy discount_rules on those
 * groups only, leaving the price list assignment itself untouched.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/legacy-group-discount-blocks-price-list/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
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.
 *
 * customerGroups: V2 /v2/customer_groups records, each
 *   { id, name, discount_rules, ... }
 * priceListAssignments: V3 /v3/pricelists/assignments 'data' records, each
 *   { price_list_id, customer_group_id, channel_id }
 *
 * A group is 'blocked' if it has a non-empty legacy discount_rules list AND
 * it also appears as customer_group_id in at least one priceListAssignments
 * entry. Returns one object per blocked group: { group_id, group_name,
 * discount_rules, price_list_ids }.
 */
export function findBlockedPriceListGroups(customerGroups, priceListAssignments) {
  const assignedGroupIds = new Map();
  for (const a of priceListAssignments) {
    const list = assignedGroupIds.get(a.customer_group_id) || [];
    list.push(a.price_list_id);
    assignedGroupIds.set(a.customer_group_id, list);
  }

  const blocked = [];
  for (const g of customerGroups) {
    const rules = g.discount_rules || [];
    const gid = g.id;
    if (rules.length && assignedGroupIds.has(gid)) {
      blocked.push({
        group_id: gid,
        group_name: g.name,
        discount_rules: rules,
        price_list_ids: assignedGroupIds.get(gid),
      });
    }
  }
  return blocked;
}

async function bcGetV2(path, params = {}) {
  const url = new URL(`${API_BASE_V2}${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 bcGetV3(path, params = {}) {
  const url = new URL(`${API_BASE_V3}${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) : { data: [], meta: {} };
}

async function bcPutV2(path, body) {
  const res = await fetch(`${API_BASE_V2}${path}`, {
    method: "PUT",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function allCustomerGroups() {
  const groups = [];
  let page = 1;
  while (true) {
    const batch = await bcGetV2("/customer_groups", { page, limit: 250 });
    if (!batch.length) return groups;
    groups.push(...batch);
    page += 1;
  }
}

async function allPriceListAssignments() {
  const assignments = [];
  let page = 1;
  while (true) {
    const result = await bcGetV3("/pricelists/assignments", { page, limit: 250 });
    const data = result.data || [];
    if (!data.length) return assignments;
    assignments.push(...data);
    page += 1;
  }
}

async function clearDiscountRules(groupId) {
  return bcPutV2(`/customer_groups/${groupId}`, { discount_rules: [] });
}

async function confirmCleared(groupId) {
  const group = await bcGetV2(`/customer_groups/${groupId}`);
  const rules = group.discount_rules || [];
  return rules.length === 0;
}

export async function run() {
  const groups = await allCustomerGroups();
  const assignments = await allPriceListAssignments();
  const blocked = findBlockedPriceListGroups(groups, assignments);

  console.log(`Found ${blocked.length} group(s) with a legacy discount blocking a price list.`);

  let cleared = 0;
  for (const entry of blocked) {
    console.log(
      `group_id=${entry.group_id} group_name=${entry.group_name} discount_rules=${JSON.stringify(entry.discount_rules)} ` +
      `price_list_ids=${JSON.stringify(entry.price_list_ids)} (${DRY_RUN ? "dry run" : "clearing"})`
    );
    if (!DRY_RUN) {
      await clearDiscountRules(entry.group_id);
      const ok = await confirmCleared(entry.group_id);
      if (!ok) console.warn(`group_id=${entry.group_id} did not confirm empty discount_rules after PUT.`);
      cleared += 1;
    }
  }

  console.log(
    `Done. ${blocked.length} group(s) ${DRY_RUN ? "to clear" : `cleared (${cleared} confirmed attempted)`}.`
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The cross-reference rule is the part most worth testing, because it decides which groups get their pricing configuration rewritten. Because find_blocked_price_list_groups takes only plain lists and returns plain dicts, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_legacy_discount_rules.py
from clear_blocking_group_discounts import find_blocked_price_list_groups


def group(id_, name="Wholesale", discount_rules=None):
    return {"id": id_, "name": name, "discount_rules": discount_rules or []}


def assignment(customer_group_id, price_list_id=1, channel_id=1):
    return {"price_list_id": price_list_id, "customer_group_id": customer_group_id, "channel_id": channel_id}


def test_group_with_rules_and_assignment_is_blocked():
    groups = [group(10, discount_rules=[{"type": "product", "method": "percent", "amount": "10.000000"}])]
    assignments = [assignment(10, price_list_id=5)]
    result = find_blocked_price_list_groups(groups, assignments)
    assert result == [{
        "group_id": 10,
        "group_name": "Wholesale",
        "discount_rules": [{"type": "product", "method": "percent", "amount": "10.000000"}],
        "price_list_ids": [5],
    }]


def test_group_with_rules_but_no_assignment_is_not_blocked():
    groups = [group(11, discount_rules=[{"type": "product", "method": "percent", "amount": "10.000000"}])]
    assignments = []
    assert find_blocked_price_list_groups(groups, assignments) == []


def test_group_with_assignment_but_no_rules_is_not_blocked():
    groups = [group(12, discount_rules=[])]
    assignments = [assignment(12, price_list_id=6)]
    assert find_blocked_price_list_groups(groups, assignments) == []


def test_group_with_neither_is_not_blocked():
    groups = [group(13, discount_rules=[])]
    assignments = []
    assert find_blocked_price_list_groups(groups, assignments) == []


def test_multiple_price_lists_on_one_blocked_group_are_all_collected():
    groups = [group(14, discount_rules=[{"type": "storewide", "method": "fixed", "amount": "5.000000"}])]
    assignments = [assignment(14, price_list_id=7), assignment(14, price_list_id=8)]
    result = find_blocked_price_list_groups(groups, assignments)
    assert result[0]["price_list_ids"] == [7, 8]


def test_only_matching_group_is_flagged_among_several():
    groups = [
        group(15, discount_rules=[{"type": "product", "method": "percent", "amount": "10.000000"}]),
        group(16, discount_rules=[]),
    ]
    assignments = [assignment(15, price_list_id=9), assignment(16, price_list_id=9)]
    result = find_blocked_price_list_groups(groups, assignments)
    assert len(result) == 1
    assert result[0]["group_id"] == 15
clear-blocking-group-discounts.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findBlockedPriceListGroups } from "./clear-blocking-group-discounts.js";

const group = (id, name = "Wholesale", discountRules = []) => ({ id, name, discount_rules: discountRules });
const assignment = (customerGroupId, priceListId = 1, channelId = 1) => ({
  price_list_id: priceListId, customer_group_id: customerGroupId, channel_id: channelId,
});

test("group with rules and assignment is blocked", () => {
  const groups = [group(10, "Wholesale", [{ type: "product", method: "percent", amount: "10.000000" }])];
  const assignments = [assignment(10, 5)];
  const result = findBlockedPriceListGroups(groups, assignments);
  assert.deepEqual(result, [{
    group_id: 10,
    group_name: "Wholesale",
    discount_rules: [{ type: "product", method: "percent", amount: "10.000000" }],
    price_list_ids: [5],
  }]);
});

test("group with rules but no assignment is not blocked", () => {
  const groups = [group(11, "Wholesale", [{ type: "product", method: "percent", amount: "10.000000" }])];
  assert.deepEqual(findBlockedPriceListGroups(groups, []), []);
});

test("group with assignment but no rules is not blocked", () => {
  const groups = [group(12, "Wholesale", [])];
  const assignments = [assignment(12, 6)];
  assert.deepEqual(findBlockedPriceListGroups(groups, assignments), []);
});

test("group with neither is not blocked", () => {
  const groups = [group(13, "Wholesale", [])];
  assert.deepEqual(findBlockedPriceListGroups(groups, []), []);
});

test("multiple price lists on one blocked group are all collected", () => {
  const groups = [group(14, "Wholesale", [{ type: "storewide", method: "fixed", amount: "5.000000" }])];
  const assignments = [assignment(14, 7), assignment(14, 8)];
  const result = findBlockedPriceListGroups(groups, assignments);
  assert.deepEqual(result[0].price_list_ids, [7, 8]);
});

test("only matching group is flagged among several", () => {
  const groups = [
    group(15, "Wholesale", [{ type: "product", method: "percent", amount: "10.000000" }]),
    group(16, "Retail", []),
  ];
  const assignments = [assignment(15, 9), assignment(16, 9)];
  const result = findBlockedPriceListGroups(groups, assignments);
  assert.equal(result.length, 1);
  assert.equal(result[0].group_id, 15);
});

Case studies

Migration cleanup missed

The B2B store that moved to Price Lists but kept the old wholesale discount

A store had run a flat 15 percent product discount on its Wholesale customer group for years through the classic Customer Groups screen. When the merchant switched to Price Lists to get per-SKU wholesale pricing, they created the price list, assigned it to the same Wholesale group, and moved on. Every wholesale customer kept seeing the old flat 15 percent, never the new per-SKU prices.

Running the script against the store surfaced exactly one blocked group: Wholesale, with its old discount_rules still intact and the new price list assignment sitting alongside it. Clearing discount_rules on that one group let the price list take over immediately, with no change needed to the price list or the assignment itself.

Two teams, two systems

The store where merchandising and ops configured the same group separately

A larger store had its merchandising team manage Price Lists through the API for seasonal VIP pricing, while an ops contractor still occasionally used the classic discount fields for quick one-off promotions on the same customer groups. Neither team could see what the other had configured, so a VIP price list rollout silently failed to apply for two of the five groups it targeted.

The cross-reference caught both affected groups in one dry run, each with its own leftover percent discount from the ops side. After confirming with both teams that the price list was meant to be authoritative, clearing the two groups' discount_rules resolved the mismatch without any code change to either system.

What good looks like

After this runs, every customer group that is supposed to be driven by a Price List actually is, because no leftover legacy discount is left standing in the way. Groups that were never meant to use Price Lists, and never had one assigned, are left completely untouched. The only groups this ever writes to are the ones where both mechanisms were configured at once, which is never a valid end state.

FAQ

Why does a Price List assignment not apply to my customer group?

BigCommerce customer groups support two mutually exclusive pricing mechanisms: legacy discount_rules (percent, fixed, or price-modifier discounts set through the V2 Customer Groups API) and V3 Price List assignments. If the group still carries a non-empty discount_rules array from before Price Lists were adopted, that legacy config takes precedence in the storefront pricing path, and the group's pricing representation reverts to method and amount instead of price_list_id, so the Price List you assigned never visibly applies.

Is it safe to clear discount_rules with a PUT to the customer group?

Yes, when you send {"discount_rules": []}. The V2 customer_groups PUT endpoint overwrites discount_rules in bulk on every call, so an empty array removes all legacy discounts without touching category_access or any other field on the group. Always run it in a dry run first, and re-fetch the group afterward to confirm discount_rules is empty and the price list assignment is still intact.

How do I find every customer group affected by this collision?

Fetch every group from GET /v2/customer_groups and every active assignment from GET /v3/pricelists/assignments, then cross-reference them: a group is blocked only if it has a non-empty discount_rules array AND its id appears as customer_group_id in at least one price list assignment. Both conditions have to be true at once, otherwise there is no collision to fix.

Related field notes

Citations

On the problem:

  1. BigCommerce Support: Customer Groups and Store wide discounts. support.bigcommerce.com customer groups and store wide discounts
  2. BigCommerce Support Foundations: Customer Groups and Price Lists. support.bigcommerce.com customer groups and price lists
  3. BigCommerce Support: Customer Groups and Discounts. support.bigcommerce.com customer groups and discounts

On the solution:

  1. BigCommerce Developer Center: Price Lists Assignments. developer.bigcommerce.com price lists assignments
  2. BigCommerce Developer Center: Customer Groups (V2 Customers API). developer.bigcommerce.com customer groups
  3. BigCommerce Developer Center: Price Lists Overview. developer.bigcommerce.com price lists overview

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, webhooks, inventory, or pricing 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 get your price list applying again?

If this saved you a confusing hunt through customer group settings, 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