Reconciler Inventory and catalog

Price list not applied to a group

A wholesale group is supposed to see special prices. The price list exists, the records are there, everything looks configured. But the group's customers still check out at the regular catalog price, and nothing in the admin says why. Here is why a BigCommerce price list can sit right next to a customer group and never actually apply to it, and a small script that finds the missing link and reconnects it safely.

Python and Node.js V3 Price Lists API Safe by default (dry run)
The short answer

In BigCommerce, a price list's custom prices only take effect for a customer group when an explicit Price List Assignment row links price_list_id and customer_group_id, and optionally channel_id, through the V3 Price Lists Assignments API. Creating price records on a list, or setting a group discount percentage in the old v2 configuration, does not create that link. Run a small Python or Node.js script that checks every group with active customers for a matching assignment, creates the assignment when it is simply missing, fixes it when it points at the wrong sales channel, and flags the price list when it exists and is assigned but has no record for the variant being bought. Full code, tests, and a dry run guard are below.

The problem in plain words

A BigCommerce Price List is just a container of custom prices for specific products or variants. On its own it does nothing. Nothing sees it, nothing uses it, until something tells BigCommerce which customer group, and which storefront channel, should read from it instead of the default catalog.

That connection is a separate object called a Price List Assignment. It is a small record with a price_list_id, a customer_group_id, and a channel_id. Without a row like that, the price list can be full of correct prices and the group can be full of real customers, and the two will never meet. The customer group quietly falls back to whatever the default catalog price is, with no error, no warning, and nothing wrong-looking in either the price list screen or the group screen when viewed alone.

Price list records look correct Customer group no assignment row links them No Price List Assignment Default price shown
The price list and the customer group can each look fully configured on their own. Without a Price List Assignment row, BigCommerce never connects them, and the group silently reverts to default pricing.

Why it happens

The Price Lists Assignments API is its own endpoint, separate from the price list and separate from the customer group, so it is easy to skip it entirely. A few common ways stores end up here:

This is a common source of confusion because both halves look fine in isolation. The price list shows real prices when you open it. The customer group shows real, active customers. Nobody sees an error, because there is not one. BigCommerce just never had a reason to connect the two. See the citations at the end for the exact support articles and API docs.

The key insight

Creating a missing assignment is safe and additive. It only turns on pricing that was already meant for that group, it does not change a single price. Fixing a wrong channel_id is also a repair, since assignments have no update endpoint, so it is a delete and recreate with the corrected channel. But a price list that is assigned correctly and simply does not have a record for the variant a customer is buying is a different problem. That needs a real pricing decision, so the script only flags it. It never invents a price.

The fix, as a flow

We do not touch prices. We add a job that checks every customer group with active customers, resolves the price list that should apply to it, and looks for a matching Price List Assignment. If none exists, it creates one. If one exists but points at a channel the group's customers do not shop on, it deletes and recreates it with the right channel. If the assignment is correct but the price list has gaps for the variants being purchased, it flags those variant ids for a merchandiser instead of guessing a price.

Scan groups with active customers Resolve price list GET /v3/pricelists/assignments Assignment exists? no CREATE_ASSIGNMENT POST assignments yes Channel matches? no FIX_CHANNEL delete, recreate yes Records cover variant? flag if not
The script only ever creates or repairs the link between a group and a price list. A gap in the price records themselves is flagged for a merchandiser, never guessed.

Build it step by step

1

Get a store hash and an API account token

In your BigCommerce control panel, go to Settings, API, Store-level API accounts, and create an account with the Customers and the Products scopes set to modify, since price lists live under the catalog scope. Note the store hash from your control panel URL and the access token from the API account. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
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="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the BigCommerce REST API

Every call sends your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper wraps GET, POST, and DELETE, raises on a non-2xx response, and unwraps the V3 {"data": ...} envelope so callers get plain objects back.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"

def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    if not r.content:
        return None
    body = r.json()
    return body["data"] if isinstance(body, dict) and "data" in body else body
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  if (!text) return null;
  const json = JSON.parse(text);
  return json && typeof json === "object" && "data" in json ? json.data : json;
}
3

Collect groups, price lists, assignments, and channels

Read GET /v2/customer_groups for the groups, GET /v3/customers?customer_group_id=in:{ids}&limit=250 paginated by meta.pagination to find which groups actually have active customers, GET /v3/pricelists?limit=250 for the lists, GET /v3/pricelists/assignments?customer_group_id={id} per group, and GET /v3/channels for the active storefronts. All of this is read only.

step3.py
def customer_groups():
    return bc("GET", "/v2/customer_groups") or []

def groups_with_customers(group_ids):
    ids = ",".join(str(g) for g in group_ids)
    counts = {}
    page = 1
    while True:
        result = bc("GET", f"/v3/customers?customer_group_id=in:{ids}&limit=250&page={page}")
        if not result:
            return counts
        for customer in result:
            gid = customer.get("customer_group_id")
            counts[gid] = counts.get(gid, 0) + 1
        if len(result) < 250:
            return counts
        page += 1

def price_list_assignments(customer_group_id):
    return bc("GET", f"/v3/pricelists/assignments?customer_group_id={customer_group_id}") or []

def active_channel_ids():
    channels = bc("GET", "/v3/channels?available=true") or []
    return [c["id"] for c in channels]
step3.js
async function customerGroups() {
  return (await bc("GET", "/v2/customer_groups")) || [];
}

async function groupsWithCustomers(groupIds) {
  const ids = groupIds.join(",");
  const counts = {};
  let page = 1;
  while (true) {
    const result = await bc("GET", `/v3/customers?customer_group_id=in:${ids}&limit=250&page=${page}`);
    if (!result || !result.length) return counts;
    for (const customer of result) {
      const gid = customer.customer_group_id;
      counts[gid] = (counts[gid] || 0) + 1;
    }
    if (result.length < 250) return counts;
    page += 1;
  }
}

async function priceListAssignments(customerGroupId) {
  return (await bc("GET", `/v3/pricelists/assignments?customer_group_id=${customerGroupId}`)) || [];
}

async function activeChannelIds() {
  const channels = (await bc("GET", "/v3/channels?available=true")) || [];
  return channels.map((c) => c.id);
}
4

Decide, with one pure function

Keep the decision in its own function that takes a group, its resolved price list, the existing assignments, the store's active channel ids, and the variant ids a customer needs a price for versus the ones the price list actually has records for. It returns exactly one of four outcomes. Nothing is written from inside this function, it only decides.

decide.py
def decide_reassignment(group, price_list, assignments, active_channel_ids,
                         variant_ids_needing_price, existing_record_variant_ids):
    if not price_list or not price_list.get("active"):
        return {"action": "NONE"}

    group_assignments = [
        a for a in assignments
        if a["price_list_id"] == price_list["id"] and a["customer_group_id"] == group["id"]
    ]

    if not group_assignments:
        channel_id = active_channel_ids[0]
        return {
            "action": "CREATE_ASSIGNMENT",
            "priceListId": price_list["id"],
            "customerGroupId": group["id"],
            "channelId": channel_id,
        }

    mismatched = next((a for a in group_assignments if a["channel_id"] not in active_channel_ids), None)
    if mismatched:
        return {
            "action": "FIX_CHANNEL",
            "assignmentId": mismatched["id"],
            "fromChannelId": mismatched["channel_id"],
            "toChannelId": active_channel_ids[0],
        }

    missing = [v for v in variant_ids_needing_price if v not in existing_record_variant_ids]
    if missing:
        return {"action": "FLAG_MISSING_RECORDS", "priceListId": price_list["id"], "missingVariantIds": missing}

    return {"action": "NONE"}
decide.js
export function decideReassignment(
  group, priceList, assignments, activeChannelIds,
  variantIdsNeedingPrice, existingRecordVariantIds
) {
  if (!priceList || !priceList.active) return { action: "NONE" };

  const groupAssignments = assignments.filter(
    (a) => a.price_list_id === priceList.id && a.customer_group_id === group.id
  );

  if (groupAssignments.length === 0) {
    const channelId = activeChannelIds[0];
    return { action: "CREATE_ASSIGNMENT", priceListId: priceList.id, customerGroupId: group.id, channelId };
  }

  const mismatched = groupAssignments.find((a) => !activeChannelIds.includes(a.channel_id));
  if (mismatched) {
    return {
      action: "FIX_CHANNEL",
      assignmentId: mismatched.id,
      fromChannelId: mismatched.channel_id,
      toChannelId: activeChannelIds[0],
    };
  }

  const missing = variantIdsNeedingPrice.filter((v) => !existingRecordVariantIds.includes(v));
  if (missing.length > 0) {
    return { action: "FLAG_MISSING_RECORDS", priceListId: priceList.id, missingVariantIds: missing };
  }

  return { action: "NONE" };
}
5

Apply the decision

CREATE_ASSIGNMENT is one bulk-shaped POST, since the endpoint accepts an array body even for a single row. FIX_CHANNEL has no update endpoint, so it is a DELETE by the exact combination of ids followed by the same POST with the corrected channel. FLAG_MISSING_RECORDS never writes, it only surfaces the price list id and the variant ids that need a merchandiser's attention.

apply.py
def create_assignment(price_list_id, customer_group_id, channel_id):
    payload = [{"price_list_id": price_list_id, "customer_group_id": customer_group_id, "channel_id": channel_id}]
    return bc("POST", "/v3/pricelists/assignments", json=payload)

def delete_assignment(price_list_id, customer_group_id, channel_id):
    path = (
        f"/v3/pricelists/assignments?price_list_id={price_list_id}"
        f"&customer_group_id={customer_group_id}&channel_id={channel_id}"
    )
    return bc("DELETE", path)
apply.js
async function createAssignment(priceListId, customerGroupId, channelId) {
  const payload = [{ price_list_id: priceListId, customer_group_id: customerGroupId, channel_id: channelId }];
  return bc("POST", "/v3/pricelists/assignments", payload);
}

async function deleteAssignment(priceListId, customerGroupId, channelId) {
  const path =
    `/v3/pricelists/assignments?price_list_id=${priceListId}` +
    `&customer_group_id=${customerGroupId}&channel_id=${channelId}`;
  return bc("DELETE", path);
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs what it would create, fix, or flag. Read the flagged price lists yourself, since those need a person to decide an actual price. Run it on a schedule, for example nightly or right after a pricing import.

Run it safe

Always start with DRY_RUN=true. Creating or fixing an assignment is additive and reversible, but never let the script write a price. A missing price record is a merchandiser decision, so those always go to the flag pile, never to an automatic write.

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 is safe to run again and again because it only creates or repairs an assignment row and never invents a price.

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

fix_price_list_assignment.py
"""Find and safely repair BigCommerce customer groups whose price list is not applied.

A Price List by itself is only a container of custom prices. It has no effect on a
customer group until a Price List Assignment row links price_list_id and
customer_group_id, and optionally channel_id, through the V3 Price Lists
Assignments API. Building prices through a CSV import, migrating off the legacy
v2 group discount model, or adding a new sales channel commonly leaves that row
missing or scoped to the wrong channel, and the group silently falls back to
default catalog pricing with no error surfaced anywhere.

This checks every customer group that has active customers, resolves the price
list, and decides with a pure function whether to create a missing assignment,
fix one scoped to a channel the group's customers do not use, or flag the price
list when it is correctly assigned but missing records for the variants being
bought. Guarded by DRY_RUN. Never writes a price. Safe to run again and again.
"""
import os
import logging

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    if not r.content:
        return None
    body = r.json()
    return body["data"] if isinstance(body, dict) and "data" in body else body


def decide_reassignment(group, price_list, assignments, active_channel_ids,
                         variant_ids_needing_price, existing_record_variant_ids):
    """Pure decision. No network calls.

    group: {"id": int, "name": str}
    price_list: {"id": int, "active": bool} | None
    assignments: [{"id", "price_list_id", "customer_group_id", "channel_id"}]
    active_channel_ids: [int]
    variant_ids_needing_price: [int]
    existing_record_variant_ids: [int]

    Returns one of NONE, CREATE_ASSIGNMENT, FIX_CHANNEL, FLAG_MISSING_RECORDS.
    """
    if not price_list or not price_list.get("active"):
        return {"action": "NONE"}

    group_assignments = [
        a for a in assignments
        if a["price_list_id"] == price_list["id"] and a["customer_group_id"] == group["id"]
    ]

    if not group_assignments:
        channel_id = active_channel_ids[0]
        return {
            "action": "CREATE_ASSIGNMENT",
            "priceListId": price_list["id"],
            "customerGroupId": group["id"],
            "channelId": channel_id,
        }

    mismatched = next((a for a in group_assignments if a["channel_id"] not in active_channel_ids), None)
    if mismatched:
        return {
            "action": "FIX_CHANNEL",
            "assignmentId": mismatched["id"],
            "fromChannelId": mismatched["channel_id"],
            "toChannelId": active_channel_ids[0],
        }

    missing = [v for v in variant_ids_needing_price if v not in existing_record_variant_ids]
    if missing:
        return {"action": "FLAG_MISSING_RECORDS", "priceListId": price_list["id"], "missingVariantIds": missing}

    return {"action": "NONE"}


def customer_groups():
    return bc("GET", "/v2/customer_groups") or []


def groups_with_customers(group_ids):
    ids = ",".join(str(g) for g in group_ids)
    counts = {}
    page = 1
    while True:
        result = bc("GET", f"/v3/customers?customer_group_id=in:{ids}&limit=250&page={page}")
        if not result:
            return counts
        for customer in result:
            gid = customer.get("customer_group_id")
            counts[gid] = counts.get(gid, 0) + 1
        if len(result) < 250:
            return counts
        page += 1


def price_lists():
    return bc("GET", "/v3/pricelists?limit=250") or []


def price_list_assignments(customer_group_id):
    return bc("GET", f"/v3/pricelists/assignments?customer_group_id={customer_group_id}") or []


def price_list_records(price_list_id):
    return bc("GET", f"/v3/pricelists/{price_list_id}/records?limit=250") or []


def active_channel_ids():
    channels = bc("GET", "/v3/channels?available=true") or []
    return [c["id"] for c in channels]


def create_assignment(price_list_id, customer_group_id, channel_id):
    payload = [{"price_list_id": price_list_id, "customer_group_id": customer_group_id, "channel_id": channel_id}]
    return bc("POST", "/v3/pricelists/assignments", json=payload)


def delete_assignment(price_list_id, customer_group_id, channel_id):
    path = (
        f"/v3/pricelists/assignments?price_list_id={price_list_id}"
        f"&customer_group_id={customer_group_id}&channel_id={channel_id}"
    )
    return bc("DELETE", path)


def run():
    created = 0
    fixed = 0
    flagged = 0

    groups = customer_groups()
    group_ids = [g["id"] for g in groups]
    active_counts = groups_with_customers(group_ids) if group_ids else {}
    lists = price_lists()
    channel_ids = active_channel_ids()

    for group in groups:
        if not active_counts.get(group["id"]):
            continue

        price_list = next((pl for pl in lists if pl.get("active")), None)
        assignments = price_list_assignments(group["id"])
        variant_ids_needing_price = []
        existing_record_variant_ids = []
        if price_list:
            records = price_list_records(price_list["id"])
            existing_record_variant_ids = [r["variant_id"] for r in records]

        decision = decide_reassignment(
            group, price_list, assignments, channel_ids,
            variant_ids_needing_price, existing_record_variant_ids,
        )

        if decision["action"] == "NONE":
            continue

        if decision["action"] == "CREATE_ASSIGNMENT":
            log.info(
                "Group %s missing assignment to price list %s. %s",
                group["name"], decision["priceListId"],
                "would create" if DRY_RUN else "creating",
            )
            if not DRY_RUN:
                create_assignment(decision["priceListId"], decision["customerGroupId"], decision["channelId"])
            created += 1

        elif decision["action"] == "FIX_CHANNEL":
            log.warning(
                "Group %s assignment %s scoped to channel %s, not an active channel. %s",
                group["name"], decision["assignmentId"], decision["fromChannelId"],
                "would fix" if DRY_RUN else "fixing",
            )
            if not DRY_RUN:
                delete_assignment(price_list["id"], group["id"], decision["fromChannelId"])
                create_assignment(price_list["id"], group["id"], decision["toChannelId"])
            fixed += 1

        elif decision["action"] == "FLAG_MISSING_RECORDS":
            log.warning(
                "Price list %s assigned to group %s but missing records for variants %s. Flagging for review.",
                decision["priceListId"], group["name"], decision["missingVariantIds"],
            )
            flagged += 1

    log.info(
        "Done. %d assignment(s) %s, %d channel fix(es) %s, %d price list(s) flagged for review.",
        created, "to create" if DRY_RUN else "created",
        fixed, "to apply" if DRY_RUN else "applied",
        flagged,
    )


if __name__ == "__main__":
    run()
fix-price-list-assignment.js
/**
 * Find and safely repair BigCommerce customer groups whose price list is not applied.
 *
 * A Price List by itself is only a container of custom prices. It has no effect on a
 * customer group until a Price List Assignment row links price_list_id and
 * customer_group_id, and optionally channel_id, through the V3 Price Lists
 * Assignments API. Building prices through a CSV import, migrating off the legacy
 * v2 group discount model, or adding a new sales channel commonly leaves that row
 * missing or scoped to the wrong channel, and the group silently falls back to
 * default catalog pricing with no error surfaced anywhere.
 *
 * This checks every customer group that has active customers, resolves the price
 * list, and decides with a pure function whether to create a missing assignment,
 * fix one scoped to a channel the group's customers do not use, or flag the price
 * list when it is correctly assigned but missing records for the variants being
 * bought. Guarded by DRY_RUN. Never writes a price. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/price-list-not-applied-to-a-group/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example-store-hash";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy-token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

/**
 * Pure decision. No network calls.
 *
 * group: { id, name }
 * priceList: { id, active } | null
 * assignments: [{ id, price_list_id, customer_group_id, channel_id }]
 * activeChannelIds: [number]
 * variantIdsNeedingPrice: [number]
 * existingRecordVariantIds: [number]
 *
 * Returns one of NONE, CREATE_ASSIGNMENT, FIX_CHANNEL, FLAG_MISSING_RECORDS.
 */
export function decideReassignment(
  group, priceList, assignments, activeChannelIds,
  variantIdsNeedingPrice, existingRecordVariantIds
) {
  if (!priceList || !priceList.active) return { action: "NONE" };

  const groupAssignments = assignments.filter(
    (a) => a.price_list_id === priceList.id && a.customer_group_id === group.id
  );

  if (groupAssignments.length === 0) {
    const channelId = activeChannelIds[0];
    return { action: "CREATE_ASSIGNMENT", priceListId: priceList.id, customerGroupId: group.id, channelId };
  }

  const mismatched = groupAssignments.find((a) => !activeChannelIds.includes(a.channel_id));
  if (mismatched) {
    return {
      action: "FIX_CHANNEL",
      assignmentId: mismatched.id,
      fromChannelId: mismatched.channel_id,
      toChannelId: activeChannelIds[0],
    };
  }

  const missing = variantIdsNeedingPrice.filter((v) => !existingRecordVariantIds.includes(v));
  if (missing.length > 0) {
    return { action: "FLAG_MISSING_RECORDS", priceListId: priceList.id, missingVariantIds: missing };
  }

  return { action: "NONE" };
}

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  if (!text) return null;
  const json = JSON.parse(text);
  return json && typeof json === "object" && "data" in json ? json.data : json;
}

async function customerGroups() {
  return (await bc("GET", "/v2/customer_groups")) || [];
}

async function groupsWithCustomers(groupIds) {
  const ids = groupIds.join(",");
  const counts = {};
  let page = 1;
  while (true) {
    const result = await bc("GET", `/v3/customers?customer_group_id=in:${ids}&limit=250&page=${page}`);
    if (!result || !result.length) return counts;
    for (const customer of result) {
      const gid = customer.customer_group_id;
      counts[gid] = (counts[gid] || 0) + 1;
    }
    if (result.length < 250) return counts;
    page += 1;
  }
}

async function priceLists() {
  return (await bc("GET", "/v3/pricelists?limit=250")) || [];
}

async function priceListAssignments(customerGroupId) {
  return (await bc("GET", `/v3/pricelists/assignments?customer_group_id=${customerGroupId}`)) || [];
}

async function priceListRecords(priceListId) {
  return (await bc("GET", `/v3/pricelists/${priceListId}/records?limit=250`)) || [];
}

async function activeChannelIds() {
  const channels = (await bc("GET", "/v3/channels?available=true")) || [];
  return channels.map((c) => c.id);
}

async function createAssignment(priceListId, customerGroupId, channelId) {
  const payload = [{ price_list_id: priceListId, customer_group_id: customerGroupId, channel_id: channelId }];
  return bc("POST", "/v3/pricelists/assignments", payload);
}

async function deleteAssignment(priceListId, customerGroupId, channelId) {
  const path =
    `/v3/pricelists/assignments?price_list_id=${priceListId}` +
    `&customer_group_id=${customerGroupId}&channel_id=${channelId}`;
  return bc("DELETE", path);
}

export async function run() {
  let created = 0;
  let fixed = 0;
  let flagged = 0;

  const groups = await customerGroups();
  const groupIds = groups.map((g) => g.id);
  const activeCounts = groupIds.length ? await groupsWithCustomers(groupIds) : {};
  const lists = await priceLists();
  const channelIds = await activeChannelIds();

  for (const group of groups) {
    if (!activeCounts[group.id]) continue;

    const priceList = lists.find((pl) => pl.active) || null;
    const assignments = await priceListAssignments(group.id);
    const variantIdsNeedingPrice = [];
    let existingRecordVariantIds = [];
    if (priceList) {
      const records = await priceListRecords(priceList.id);
      existingRecordVariantIds = records.map((r) => r.variant_id);
    }

    const decision = decideReassignment(
      group, priceList, assignments, channelIds,
      variantIdsNeedingPrice, existingRecordVariantIds
    );

    if (decision.action === "NONE") continue;

    if (decision.action === "CREATE_ASSIGNMENT") {
      console.log(
        `Group ${group.name} missing assignment to price list ${decision.priceListId}. ${DRY_RUN ? "would create" : "creating"}`
      );
      if (!DRY_RUN) await createAssignment(decision.priceListId, decision.customerGroupId, decision.channelId);
      created++;
    } else if (decision.action === "FIX_CHANNEL") {
      console.warn(
        `Group ${group.name} assignment ${decision.assignmentId} scoped to channel ${decision.fromChannelId}, not an active channel. ${DRY_RUN ? "would fix" : "fixing"}`
      );
      if (!DRY_RUN) {
        await deleteAssignment(priceList.id, group.id, decision.fromChannelId);
        await createAssignment(priceList.id, group.id, decision.toChannelId);
      }
      fixed++;
    } else if (decision.action === "FLAG_MISSING_RECORDS") {
      console.warn(
        `Price list ${decision.priceListId} assigned to group ${group.name} but missing records for variants ${decision.missingVariantIds}. Flagging for review.`
      );
      flagged++;
    }
  }

  console.log(
    `Done. ${created} assignment(s) ${DRY_RUN ? "to create" : "created"}, ${fixed} channel fix(es) ${DRY_RUN ? "to apply" : "applied"}, ${flagged} price list(s) flagged for review.`
  );
}

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 a wholesale group ever sees its own prices. Because we kept decide_reassignment pure, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_price_list_reassignment.py
from fix_price_list_assignment import decide_reassignment


GROUP = {"id": 7, "name": "Wholesale"}
PRICE_LIST = {"id": 42, "active": True}
CHANNELS = [1, 2]


def test_create_assignment_when_none_exists():
    decision = decide_reassignment(GROUP, PRICE_LIST, [], CHANNELS, [], [])
    assert decision == {
        "action": "CREATE_ASSIGNMENT",
        "priceListId": 42,
        "customerGroupId": 7,
        "channelId": 1,
    }


def test_fix_channel_when_assignment_on_wrong_channel():
    assignments = [{"id": 900, "price_list_id": 42, "customer_group_id": 7, "channel_id": 99}]
    decision = decide_reassignment(GROUP, PRICE_LIST, assignments, CHANNELS, [], [])
    assert decision == {
        "action": "FIX_CHANNEL",
        "assignmentId": 900,
        "fromChannelId": 99,
        "toChannelId": 1,
    }


def test_flag_missing_records_when_assignment_correct_but_sparse():
    assignments = [{"id": 901, "price_list_id": 42, "customer_group_id": 7, "channel_id": 1}]
    decision = decide_reassignment(GROUP, PRICE_LIST, assignments, CHANNELS, [501, 502], [501])
    assert decision == {"action": "FLAG_MISSING_RECORDS", "priceListId": 42, "missingVariantIds": [502]}


def test_none_when_fully_healthy():
    assignments = [{"id": 902, "price_list_id": 42, "customer_group_id": 7, "channel_id": 1}]
    decision = decide_reassignment(GROUP, PRICE_LIST, assignments, CHANNELS, [501, 502], [501, 502])
    assert decision == {"action": "NONE"}


def test_none_when_no_active_price_list():
    decision = decide_reassignment(GROUP, None, [], CHANNELS, [], [])
    assert decision == {"action": "NONE"}
    decision = decide_reassignment(GROUP, {"id": 42, "active": False}, [], CHANNELS, [], [])
    assert decision == {"action": "NONE"}
price-list-reassignment.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideReassignment } from "./fix-price-list-assignment.js";

const GROUP = { id: 7, name: "Wholesale" };
const PRICE_LIST = { id: 42, active: true };
const CHANNELS = [1, 2];

test("create assignment when none exists", () => {
  const decision = decideReassignment(GROUP, PRICE_LIST, [], CHANNELS, [], []);
  assert.deepEqual(decision, {
    action: "CREATE_ASSIGNMENT",
    priceListId: 42,
    customerGroupId: 7,
    channelId: 1,
  });
});

test("fix channel when assignment on wrong channel", () => {
  const assignments = [{ id: 900, price_list_id: 42, customer_group_id: 7, channel_id: 99 }];
  const decision = decideReassignment(GROUP, PRICE_LIST, assignments, CHANNELS, [], []);
  assert.deepEqual(decision, {
    action: "FIX_CHANNEL",
    assignmentId: 900,
    fromChannelId: 99,
    toChannelId: 1,
  });
});

test("flag missing records when assignment correct but sparse", () => {
  const assignments = [{ id: 901, price_list_id: 42, customer_group_id: 7, channel_id: 1 }];
  const decision = decideReassignment(GROUP, PRICE_LIST, assignments, CHANNELS, [501, 502], [501]);
  assert.deepEqual(decision, { action: "FLAG_MISSING_RECORDS", priceListId: 42, missingVariantIds: [502] });
});

test("none when fully healthy", () => {
  const assignments = [{ id: 902, price_list_id: 42, customer_group_id: 7, channel_id: 1 }];
  const decision = decideReassignment(GROUP, PRICE_LIST, assignments, CHANNELS, [501, 502], [501, 502]);
  assert.deepEqual(decision, { action: "NONE" });
});

test("none when no active price list", () => {
  assert.deepEqual(decideReassignment(GROUP, null, [], CHANNELS, [], []), { action: "NONE" });
  assert.deepEqual(
    decideReassignment(GROUP, { id: 42, active: false }, [], CHANNELS, [], []),
    { action: "NONE" }
  );
});

Case studies

CSV import

The wholesale prices that never left the spreadsheet

A hardware distributor uploaded a wholesale price list by CSV, mapping SKUs to discounted prices for a Wholesale customer group. The import wrote every price record correctly. Nobody realized the import tool never called the assignments endpoint, so the price list and the group sat side by side, fully configured, and never connected.

Wholesale accounts kept checking out at retail price for weeks. The scan found the group had active customers, resolved the price list, found zero matching assignments, and created the missing row. Wholesale pricing started applying on the very next order.

New storefront

The second channel that lost its pricing

A brand launched a second storefront for a regional market, reusing the same customer groups. The original price list assignment had been created scoped only to the first channel's channel_id. Customers on the new storefront matched the group perfectly, but the assignment's channel did not match where they were actually shopping.

The script flagged the mismatch, deleted the old channel-scoped assignment, and recreated it against the active channel list. Pricing on the new storefront matched the original within one run, and the old channel's assignment was never disturbed for shoppers still using it.

What good looks like

After this runs on a schedule, a customer group with real pricing configured cannot quietly fall back to the default catalog. Every group that should have special pricing gets its assignment created or corrected automatically, and every price list with a real gap gets a flag for a merchandiser instead of a guess. The only thing a human ever needs to decide is an actual price, never a missing link.

FAQ

Why does a BigCommerce price list not apply to a customer group?

Because a Price List by itself is just a set of custom prices. It only takes effect for a group when a Price List Assignment row exists linking price_list_id and customer_group_id, and optionally channel_id, through the V3 Price Lists Assignments API. Creating price records or setting an old v2 group discount percentage does not create that link, so the group silently falls back to default catalog pricing.

Is it safe to auto-create a missing price list assignment?

Yes, creating an assignment is additive. It only turns on pricing that was already configured for that group and does not change any price, so a script can safely create it once it confirms no assignment already exists for that price list, group, and channel.

What should a script do if the price list has no record for the variant a customer is buying?

It should not guess a price. Missing or incomplete price records need a real pricing decision from a merchandiser, so the safe move is to flag the price list and the affected variant ids for review rather than writing anything automatically.

Related field notes

Citations

On the problem:

  1. BigCommerce Support Foundations: Customer Groups and Price Lists. support.bigcommerce.com/s/foundations/customer-groups-and-price-lists
  2. BigCommerce Help Center: Customer Groups. support.bigcommerce.com/s/article/Customer-Groups
  3. BigCommerce Community: Customer Group Pricing question. support.bigcommerce.com customer-group-pricing

On the solution:

  1. BigCommerce Developer Center: Price Lists (REST Management). developer.bigcommerce.com/docs/rest-management/price-lists
  2. BigCommerce Developer Center: Get Price List Assignments. developer.bigcommerce.com/docs/rest-management/price-lists/price-lists-assignments
  3. BigCommerce Developer Center: Customers V3. developer.bigcommerce.com/docs/rest-management/customers

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, inventory, webhooks, 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 reconnect some missing pricing?

If this got a wholesale group its real prices back, 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