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.
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.
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:
- Pricing is set up through a CSV or bulk import that writes price records onto a price list, but the import never calls the assignments endpoint, so the list is populated but never connected to any group.
- The store migrates off the legacy customer group discount model, where a flat discount percentage lived directly on the group. BigCommerce requires deleting that old discount configuration before a price list can be assigned to the group, and that migration step is easy to leave half-finished.
- A store adds a new sales channel or storefront, and the original assignment was only ever scoped to the old
channel_id. Customers shopping on the new channel match the group but not the channel, so the assignment silently does not apply to them. - An assignment existed once, was deleted during a pricing cleanup, and was never recreated after the price list was reactivated.
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.
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.
Build it step by step
Get a store hash and an API account token
In your BigCommerce control panel, go to Settings, API, Store-level API accounts, and create an account with the 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.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export DRY_RUN="true" // start safe, change to false to write
Talk to the BigCommerce REST API
Every call sends your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper wraps GET, POST, and DELETE, raises on a non-2xx response, and unwraps the V3 {"data": ...} envelope so callers get plain objects back.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
if not r.content:
return None
body = r.json()
return body["data"] if isinstance(body, dict) and "data" in body else body
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
if (!text) return null;
const json = JSON.parse(text);
return json && typeof json === "object" && "data" in json ? json.data : json;
}
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.
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]
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);
}
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.
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"}
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" };
}
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.
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)
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);
}
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.
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.
"""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()
/**
* 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.
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"}
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
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.
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.
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:
- BigCommerce Support Foundations: Customer Groups and Price Lists. support.bigcommerce.com/s/foundations/customer-groups-and-price-lists
- BigCommerce Help Center: Customer Groups. support.bigcommerce.com/s/article/Customer-Groups
- BigCommerce Community: Customer Group Pricing question. support.bigcommerce.com customer-group-pricing
On the solution:
- BigCommerce Developer Center: Price Lists (REST Management). developer.bigcommerce.com/docs/rest-management/price-lists
- BigCommerce Developer Center: Get Price List Assignments. developer.bigcommerce.com/docs/rest-management/price-lists/price-lists-assignments
- 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.
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