Reconciler Pricing / Price Lists
Price list has no entry for a variant so it falls back to catalog price
Every other variant of the product shows the discounted price. One SKU quietly charges full catalog price instead, and nothing in the admin tells you why. BigCommerce price lists store overrides as flat per-variant records, not product-level rules that cascade to children, so a CSV import or API upsert that misses one variant leaves a silent gap. Here is why that gap opens up and a small script that finds every variant a price list forgot.
BigCommerce price lists store overrides as flat per-variant records, each keyed by variant_id and currency, not as product-level rules that cascade to every child variant. When a merchant builds a price list through a CSV import, the admin UI, or an API batch upsert, it is easy to cover only some of a product's variants, for example just the base variant, or only the SKUs that existed at the time of the last export, and miss newly added variants entirely. Because the pricing engine looks up a record for the exact variant_id being viewed and simply falls through to standard catalog pricing when nothing matches, the gap is silent: no admin warning, no validation error, no webhook. Run a small Python or Node.js script that enumerates every active variant with GET /v3/catalog/products/{product_id}/variants, pulls every record from each active price list with GET /v3/pricelists/{price_list_id}/records, and diffs the two sets to report exactly which variant_id, sku, and customer_group_ids are affected. Full code, tests, and citations are below.
The problem in plain words
A BigCommerce price list is not a rule attached to a product. It is a flat table of individual records, one per variant per currency, that a customer group's active price list can look up. There is no inheritance from parent product to child variant built into that lookup. If a variant has no record in the price list, BigCommerce does not average, does not inherit from a sibling, and does not warn. It simply reads the standard catalog price for that variant instead, as if the price list did not exist for that one SKU.
That gap opens up in ordinary, everyday ways. A merchandiser exports a price list to CSV, edits it in a spreadsheet, and re-imports it, but the export happened before three new variants were added to the product, so the re-import never touches them. Or an integration does a batch upsert of price list records for "all current variants" at the time the job ran, and a variant added an hour later is never covered until the job runs again. Or someone builds the price list by hand in the admin UI and only adds the handful of SKUs a customer asked about, assuming the rest will follow the same rule, when nothing forces that to happen.
Why it happens
Price lists were built as a flat, per-variant override table, not as a rules engine that understands a product's variant tree. A few common ways that flatness turns into a coverage gap:
- A CSV export/edit/re-import cycle where the export was taken before new variants existed on the product, so the re-import never adds records for them.
- An API batch upsert that writes records for "the variants that exist right now," run once, with no follow-up job to catch variants added afterward.
- An admin UI edit where a merchandiser adds records for the specific SKUs a customer asked about and assumes the rest of the product's variants are already covered, or will inherit the same rule.
- A price list built for one customer group's active list, then a second customer group is pointed at the same price list later, without anyone re-checking that every variant the second group actually sells is covered.
Whatever the cause, BigCommerce's pricing engine looks up a record for the exact variant_id in the customer group's active price list and simply falls through to standard catalog pricing when nothing matches. There is no admin warning, no validation error, and no webhook, so the gap is invisible until a customer or a merchandiser notices one SKU pricing differently from its siblings. See the citations at the end for the exact support threads and docs.
A price list "having records" for a product tells you nothing about whether it covers every variant of that product. The only way to know is to enumerate every active variant for every product, enumerate every record in every price list a customer group is actually assigned to, and diff the two sets. A variant present in the first set and absent from the second is a silent pricing gap, full stop, regardless of how the price list was built or edited.
The fix, as a flow
We do not change how price lists are built or edited. We add a reconciliation job that enumerates every active variant storewide, pulls every record from every price list that is actually assigned to a customer group, and reports the variants that fall in the first set but not the second, one row per gap, so merchandising can decide what price belongs there.
Build it step by step
Get a store hash and an API access token
Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Products (read-only) and Price Lists (read-only) scope so it can enumerate variants and price list records. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true" # start safe, change to false only if you supply a fallback rule
// 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 only if you supply a fallback rule
Talk to the V3 Catalog and Price Lists REST API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. A small helper handles GET and paginates using meta.pagination.total_pages. We reuse it to list variants, price lists, price list assignments, and price list records.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def bc_get_all_pages(path, params=None):
page = 1
items = []
while True:
body = bc_get(path, {**(params or {}), "limit": 250, "page": page})
items.extend(body.get("data", []))
pagination = body.get("meta", {}).get("pagination", {})
if page >= pagination.get("total_pages", 1):
return items
page += 1
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcGetAllPages(path, params = {}) {
let page = 1;
const items = [];
while (true) {
const body = await bcGet(path, { ...params, limit: 250, page });
items.push(...(body.data || []));
const pagination = (body.meta || {}).pagination || {};
if (page >= (pagination.total_pages || 1)) return items;
page += 1;
}
}
Enumerate active variants and every price list a group actually uses
Call GET /v3/catalog/variants?limit=250&page=n (or per-product GET /v3/catalog/products/{product_id}/variants) and cross-check product.is_visible via GET /v3/catalog/products?include_fields=is_visible,availability so you only track purchasable, visible products. Separately, call GET /v3/pricelists for active price lists and GET /v3/pricelists/assignments?customer_group_id={id} to find which price_list_id is actually live for each customer group. Only price lists reachable through an assignment matter, an orphaned price list with no group pointed at it cannot cause a checkout mismatch.
def active_variants():
variants = bc_get_all_pages("/catalog/variants")
visible_product_ids = {
p["id"] for p in bc_get_all_pages(
"/catalog/products", {"include_fields": "is_visible,availability"}
)
if p.get("is_visible")
}
return [v for v in variants if v["product_id"] in visible_product_ids]
def group_to_price_list(customer_group_ids):
mapping = {}
for group_id in customer_group_ids:
assignments = bc_get_all_pages(
"/pricelists/assignments", {"customer_group_id": group_id}
)
for a in assignments:
if a.get("price_list_id"):
mapping[group_id] = a["price_list_id"]
return mapping
def price_list_records(price_list_id):
return bc_get_all_pages(f"/pricelists/{price_list_id}/records")
async function activeVariants() {
const variants = await bcGetAllPages("/catalog/variants");
const products = await bcGetAllPages("/catalog/products", {
include_fields: "is_visible,availability",
});
const visibleProductIds = new Set(
products.filter((p) => p.is_visible).map((p) => p.id)
);
return variants.filter((v) => visibleProductIds.has(v.product_id));
}
async function groupToPriceList(customerGroupIds) {
const mapping = {};
for (const groupId of customerGroupIds) {
const assignments = await bcGetAllPages("/pricelists/assignments", {
customer_group_id: groupId,
});
for (const a of assignments) {
if (a.price_list_id) mapping[groupId] = a.price_list_id;
}
}
return mapping;
}
async function priceListRecords(priceListId) {
return bcGetAllPages(`/pricelists/${priceListId}/records`);
}
Diff, with one pure function
Keep the diff in its own function that takes plain sets and dicts, no network, and returns the list of gaps. It builds the set of variant_ids that already have a record, subtracts that from the set of all active variant_ids, and for every price_list_id that a customer group actually points to, reports every missing variant_id along with which customer groups are affected.
def find_variant_price_gaps(active_variant_ids, price_list_records, group_to_price_list):
covered = {r["variant_id"] for r in price_list_records}
gaps = active_variant_ids - covered
results = []
price_list_ids = set(group_to_price_list.values())
for price_list_id in price_list_ids:
affected_groups = [
g for g, pl in group_to_price_list.items() if pl == price_list_id
]
for variant_id in gaps:
results.append({
"price_list_id": price_list_id,
"variant_id": variant_id,
"affected_customer_groups": affected_groups,
})
return sorted(results, key=lambda r: r["variant_id"])
export function findVariantPriceGaps(activeVariantIds, priceListRecords, groupToPriceList) {
const covered = new Set(priceListRecords.map((r) => r.variant_id));
const gaps = [...activeVariantIds].filter((id) => !covered.has(id));
const priceListIds = new Set(Object.values(groupToPriceList));
const results = [];
for (const priceListId of priceListIds) {
const affectedGroups = Object.entries(groupToPriceList)
.filter(([, pl]) => pl === priceListId)
.map(([g]) => Number(g));
for (const variantId of gaps) {
results.push({
price_list_id: priceListId,
variant_id: variantId,
affected_customer_groups: affectedGroups,
});
}
}
return results.sort((a, b) => a.variant_id - b.variant_id);
}
Resolve sku and product_id for each gap, then emit a report
The diff only knows variant_ids. Join that back against the variant list you already fetched to attach sku and product_id, so the report is something a merchandiser can act on without another lookup. Write it as CSV or JSON, whichever your team already reviews price lists in.
def enrich_gaps(gaps, variants_by_id):
enriched = []
for gap in gaps:
variant = variants_by_id.get(gap["variant_id"], {})
enriched.append({
**gap,
"product_id": variant.get("product_id"),
"sku": variant.get("sku"),
})
return enriched
function enrichGaps(gaps, variantsById) {
return gaps.map((gap) => {
const variant = variantsById[gap.variant_id] || {};
return {
...gap,
product_id: variant.product_id,
sku: variant.sku,
};
});
}
Wire it together, report only, write only behind a guard
The loop fetches active variants, resolves each customer group's live price list, pulls records for each one, and runs the diff. By default it only logs and writes a report, it never touches a price. If a caller supplies an explicit fallback rule, the corrective write is a DRY_RUN guarded call to PUT /v3/pricelists/{price_list_id}/records/batch with up to 1000 records per call, capped at 2 concurrent batch requests per store to avoid 429s. Re-run the diff afterward to confirm coverage reaches 100% for that price list.
This is a pricing-data gap, not a deterministic bug. The script cannot know the intended sale or contract price for a missing variant, so the default behavior is always to report the gap, never to invent a number. Only send the batch upsert PUT if a human has supplied the exact fallback rule and DRY_RUN=false.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, defaults to a report-only dry run, and only performs a write if you both supply a fallback price rule and explicitly set DRY_RUN=false.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find BigCommerce price list variant coverage gaps.
BigCommerce price lists store overrides as flat per-variant records, each keyed
by variant_id and currency, not as product-level rules that cascade to child
variants. A CSV import, an admin UI edit, or an API batch upsert can easily
cover only some of a product's variants and miss newly added ones. Because the
pricing engine looks up a record for the exact variant_id being viewed and
falls through to standard catalog pricing when nothing matches, the gap is
silent: no admin warning, no validation error, no webhook. This job enumerates
every active variant storewide, pulls every record from every price list that
is actually assigned to a customer group, and reports every variant missing
from an active price list. It never guesses a price. It only reports, unless
a caller supplies an explicit fallback rule and DRY_RUN=false.
Guide: https://www.allanninal.dev/bigcommerce/price-list-missing-variant-entry/
"""
import os
import json
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("price_list_variant_gaps")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = 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(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def bc_put(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def bc_get_all_pages(path, params=None):
page = 1
items = []
while True:
body = bc_get(path, {**(params or {}), "limit": 250, "page": page})
items.extend(body.get("data", []))
pagination = body.get("meta", {}).get("pagination", {})
if page >= pagination.get("total_pages", 1):
return items
page += 1
def find_variant_price_gaps(active_variant_ids, price_list_records, group_to_price_list):
"""Pure decision. No network, no side effects.
covered = variant_ids that already have a record in some price list.
gaps = active_variant_ids not in covered.
For every price_list_id referenced by group_to_price_list, emit one row
per gap variant with the customer_group_ids that reference that list.
Returns a list of dicts sorted by variant_id.
"""
covered = {r["variant_id"] for r in price_list_records}
gaps = active_variant_ids - covered
results = []
price_list_ids = set(group_to_price_list.values())
for price_list_id in price_list_ids:
affected_groups = [
g for g, pl in group_to_price_list.items() if pl == price_list_id
]
for variant_id in gaps:
results.append({
"price_list_id": price_list_id,
"variant_id": variant_id,
"affected_customer_groups": affected_groups,
})
return sorted(results, key=lambda r: r["variant_id"])
def active_variants():
"""Every variant belonging to a visible, purchasable product."""
variants = bc_get_all_pages("/catalog/variants")
visible_product_ids = {
p["id"] for p in bc_get_all_pages(
"/catalog/products", {"include_fields": "is_visible,availability"}
)
if p.get("is_visible")
}
return [v for v in variants if v["product_id"] in visible_product_ids]
def all_price_lists():
return [pl for pl in bc_get_all_pages("/pricelists") if pl.get("active")]
def group_to_price_list(customer_group_ids):
mapping = {}
for group_id in customer_group_ids:
assignments = bc_get_all_pages(
"/pricelists/assignments", {"customer_group_id": group_id}
)
for a in assignments:
if a.get("price_list_id"):
mapping[group_id] = a["price_list_id"]
return mapping
def price_list_records(price_list_id):
return bc_get_all_pages(f"/pricelists/{price_list_id}/records")
def enrich_gaps(gaps, variants_by_id):
enriched = []
for gap in gaps:
variant = variants_by_id.get(gap["variant_id"], {})
enriched.append({
**gap,
"product_id": variant.get("product_id"),
"sku": variant.get("sku"),
})
return enriched
def apply_fallback(price_list_id, records_to_write):
"""Only called when a caller supplies an explicit fallback rule.
records_to_write: list of {variant_id, currency, price, sale_price, retail_price}.
Up to 1000 records per call. Respect DRY_RUN.
"""
batch_size = 1000
for i in range(0, len(records_to_write), batch_size):
batch = records_to_write[i:i + batch_size]
log.info(
"%s %d record(s) to price_list_id=%s",
"Would write" if DRY_RUN else "Writing", len(batch), price_list_id,
)
if not DRY_RUN:
bc_put(f"/pricelists/{price_list_id}/records/batch", batch)
def run(customer_group_ids=None, fallback_rule=None):
customer_group_ids = customer_group_ids or []
variants = active_variants()
variants_by_id = {v["id"]: v for v in variants}
active_ids = set(variants_by_id.keys())
mapping = group_to_price_list(customer_group_ids)
price_list_ids = set(mapping.values())
all_records = []
for price_list_id in price_list_ids:
all_records.extend(price_list_records(price_list_id))
gaps = find_variant_price_gaps(active_ids, all_records, mapping)
enriched = enrich_gaps(gaps, variants_by_id)
log.info("Found %d variant price gap(s) across %d price list(s).", len(enriched), len(price_list_ids))
print(json.dumps(enriched, indent=2))
if fallback_rule is not None:
by_price_list = {}
for gap in enriched:
by_price_list.setdefault(gap["price_list_id"], []).append(gap)
for price_list_id, gap_rows in by_price_list.items():
records_to_write = [fallback_rule(row, variants_by_id) for row in gap_rows]
apply_fallback(price_list_id, records_to_write)
return enriched
if __name__ == "__main__":
run()
/**
* Find BigCommerce price list variant coverage gaps.
*
* BigCommerce price lists store overrides as flat per-variant records, each
* keyed by variant_id and currency, not as product-level rules that cascade
* to child variants. A CSV import, an admin UI edit, or an API batch upsert
* can easily cover only some of a product's variants and miss newly added
* ones. Because the pricing engine looks up a record for the exact variant_id
* being viewed and falls through to standard catalog pricing when nothing
* matches, the gap is silent: no admin warning, no validation error, no
* webhook. This job enumerates every active variant storewide, pulls every
* record from every price list actually assigned to a customer group, and
* reports every variant missing from an active price list. It never guesses
* a price. It only reports, unless a caller supplies an explicit fallback
* rule and DRY_RUN=false.
*
* Guide: https://www.allanninal.dev/bigcommerce/price-list-missing-variant-entry/
*/
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 = `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.
*
* covered = variant_ids that already have a record in some price list.
* gaps = activeVariantIds not in covered.
* For every price_list_id referenced by groupToPriceList, emit one row per
* gap variant with the customer_group_ids that reference that list.
* Returns an array of objects sorted by variant_id.
*/
export function findVariantPriceGaps(activeVariantIds, priceListRecords, groupToPriceList) {
const covered = new Set(priceListRecords.map((r) => r.variant_id));
const gaps = [...activeVariantIds].filter((id) => !covered.has(id));
const priceListIds = new Set(Object.values(groupToPriceList));
const results = [];
for (const priceListId of priceListIds) {
const affectedGroups = Object.entries(groupToPriceList)
.filter(([, pl]) => pl === priceListId)
.map(([g]) => Number(g));
for (const variantId of gaps) {
results.push({
price_list_id: priceListId,
variant_id: variantId,
affected_customer_groups: affectedGroups,
});
}
}
return results.sort((a, b) => a.variant_id - b.variant_id);
}
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPut(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcGetAllPages(path, params = {}) {
let page = 1;
const items = [];
while (true) {
const body = await bcGet(path, { ...params, limit: 250, page });
items.push(...(body.data || []));
const pagination = (body.meta || {}).pagination || {};
if (page >= (pagination.total_pages || 1)) return items;
page += 1;
}
}
async function activeVariants() {
const variants = await bcGetAllPages("/catalog/variants");
const products = await bcGetAllPages("/catalog/products", {
include_fields: "is_visible,availability",
});
const visibleProductIds = new Set(
products.filter((p) => p.is_visible).map((p) => p.id)
);
return variants.filter((v) => visibleProductIds.has(v.product_id));
}
async function groupToPriceList(customerGroupIds) {
const mapping = {};
for (const groupId of customerGroupIds) {
const assignments = await bcGetAllPages("/pricelists/assignments", {
customer_group_id: groupId,
});
for (const a of assignments) {
if (a.price_list_id) mapping[groupId] = a.price_list_id;
}
}
return mapping;
}
async function priceListRecords(priceListId) {
return bcGetAllPages(`/pricelists/${priceListId}/records`);
}
function enrichGaps(gaps, variantsById) {
return gaps.map((gap) => {
const variant = variantsById[gap.variant_id] || {};
return {
...gap,
product_id: variant.product_id,
sku: variant.sku,
};
});
}
async function applyFallback(priceListId, recordsToWrite) {
const batchSize = 1000;
for (let i = 0; i < recordsToWrite.length; i += batchSize) {
const batch = recordsToWrite.slice(i, i + batchSize);
console.log(
`${DRY_RUN ? "Would write" : "Writing"} ${batch.length} record(s) to price_list_id=${priceListId}`
);
if (!DRY_RUN) await bcPut(`/pricelists/${priceListId}/records/batch`, batch);
}
}
export async function run(customerGroupIds = [], fallbackRule = null) {
const variants = await activeVariants();
const variantsById = Object.fromEntries(variants.map((v) => [v.id, v]));
const activeIds = new Set(Object.keys(variantsById).map(Number));
const mapping = await groupToPriceList(customerGroupIds);
const priceListIds = new Set(Object.values(mapping));
let allRecords = [];
for (const priceListId of priceListIds) {
allRecords = allRecords.concat(await priceListRecords(priceListId));
}
const gaps = findVariantPriceGaps(activeIds, allRecords, mapping);
const enriched = enrichGaps(gaps, variantsById);
console.log(`Found ${enriched.length} variant price gap(s) across ${priceListIds.size} price list(s).`);
console.log(JSON.stringify(enriched, null, 2));
if (fallbackRule) {
const byPriceList = {};
for (const gap of enriched) {
(byPriceList[gap.price_list_id] ||= []).push(gap);
}
for (const [priceListId, gapRows] of Object.entries(byPriceList)) {
const recordsToWrite = gapRows.map((row) => fallbackRule(row, variantsById));
await applyFallback(priceListId, recordsToWrite);
}
}
return enriched;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The diff rule is the part most worth testing, because it decides who is flagged as missing a price. Because find_variant_price_gaps takes only plain sets, lists, and dicts and returns a plain list, the test needs no network and no BigCommerce store. It just feeds in synthetic ids and checks the answer.
from price_list_variant_gaps import find_variant_price_gaps
def test_variant_present_in_both_shows_no_gap():
active_variant_ids = {1, 2}
records = [{"variant_id": 1}, {"variant_id": 2}]
group_to_price_list = {10: 500}
assert find_variant_price_gaps(active_variant_ids, records, group_to_price_list) == []
def test_variant_missing_from_records_is_reported():
active_variant_ids = {1, 2, 3}
records = [{"variant_id": 1}, {"variant_id": 2}]
group_to_price_list = {10: 500}
result = find_variant_price_gaps(active_variant_ids, records, group_to_price_list)
assert result == [
{"price_list_id": 500, "variant_id": 3, "affected_customer_groups": [10]}
]
def test_multiple_groups_on_same_price_list_are_all_listed():
active_variant_ids = {3}
records = []
group_to_price_list = {10: 500, 20: 500}
result = find_variant_price_gaps(active_variant_ids, records, group_to_price_list)
assert len(result) == 1
assert set(result[0]["affected_customer_groups"]) == {10, 20}
def test_results_are_sorted_by_variant_id():
active_variant_ids = {3, 1, 2}
records = []
group_to_price_list = {10: 500}
result = find_variant_price_gaps(active_variant_ids, records, group_to_price_list)
assert [r["variant_id"] for r in result] == [1, 2, 3]
def test_no_gaps_when_no_active_variants():
assert find_variant_price_gaps(set(), [], {10: 500}) == []
def test_gap_reported_separately_per_distinct_price_list():
active_variant_ids = {7}
records = []
group_to_price_list = {10: 500, 20: 600}
result = find_variant_price_gaps(active_variant_ids, records, group_to_price_list)
price_list_ids = {r["price_list_id"] for r in result}
assert price_list_ids == {500, 600}
import { test } from "node:test";
import assert from "node:assert/strict";
import { findVariantPriceGaps } from "./price-list-variant-gaps.js";
test("variant present in both shows no gap", () => {
const activeVariantIds = new Set([1, 2]);
const records = [{ variant_id: 1 }, { variant_id: 2 }];
const groupToPriceList = { 10: 500 };
assert.deepEqual(findVariantPriceGaps(activeVariantIds, records, groupToPriceList), []);
});
test("variant missing from records is reported", () => {
const activeVariantIds = new Set([1, 2, 3]);
const records = [{ variant_id: 1 }, { variant_id: 2 }];
const groupToPriceList = { 10: 500 };
const result = findVariantPriceGaps(activeVariantIds, records, groupToPriceList);
assert.deepEqual(result, [
{ price_list_id: 500, variant_id: 3, affected_customer_groups: [10] },
]);
});
test("multiple groups on same price list are all listed", () => {
const activeVariantIds = new Set([3]);
const records = [];
const groupToPriceList = { 10: 500, 20: 500 };
const result = findVariantPriceGaps(activeVariantIds, records, groupToPriceList);
assert.equal(result.length, 1);
assert.deepEqual(new Set(result[0].affected_customer_groups), new Set([10, 20]));
});
test("results are sorted by variant_id", () => {
const activeVariantIds = new Set([3, 1, 2]);
const records = [];
const groupToPriceList = { 10: 500 };
const result = findVariantPriceGaps(activeVariantIds, records, groupToPriceList);
assert.deepEqual(result.map((r) => r.variant_id), [1, 2, 3]);
});
test("no gaps when no active variants", () => {
assert.deepEqual(findVariantPriceGaps(new Set(), [], { 10: 500 }), []);
});
test("gap reported separately per distinct price list", () => {
const activeVariantIds = new Set([7]);
const records = [];
const groupToPriceList = { 10: 500, 20: 600 };
const result = findVariantPriceGaps(activeVariantIds, records, groupToPriceList);
const priceListIds = new Set(result.map((r) => r.price_list_id));
assert.deepEqual(priceListIds, new Set([500, 600]));
});
Case studies
The apparel store where new sizes never got the wholesale price
A wholesale price list was built from a CSV export of a product's variants. Two months later, the merchandiser added three new sizes to that same product. The wholesale customer group kept ordering, and every size that existed at export time was correctly discounted, but the three new sizes silently charged full catalog price at checkout. No one noticed until a wholesale customer asked why one size was priced differently.
Running the reconciler against every active price list surfaced the exact three variant_ids, their skus, and the wholesale customer_group_id in one report. Merchandising cloned the existing discount ratio onto the new sizes and the gap closed on the next run.
The integration that upserted records before a product finished syncing
An ERP integration pushed a batch of price list records for a product's variants right after creating the product, but the product's variant generation job hadn't finished writing all of its SKU combinations yet. The batch covered whatever existed at that instant, and the remaining variants were never revisited.
The gap sat there for weeks, invisible, because nothing about the price list, the product, or the checkout flagged it. The reconciliation report caught it on the first run, listing every uncovered variant_id against the price_list_id the ERP had written to, so the integration team could add a follow-up sync step.
After this runs on a schedule, every active price list's coverage is checked against the product catalog's real, current variant list, not against whatever existed the last time someone edited a CSV. Every gap shows up as a specific price_list_id, product_id, variant_id, sku, and the customer_group_ids it affects, so a merchandiser can fix the actual number instead of hunting for which SKU is quietly wrong.
FAQ
Why does one variant charge full price while its siblings get the price list discount?
BigCommerce price lists store overrides as flat per-variant records keyed by variant_id and currency, not as a product-level rule that cascades to every child variant. If a CSV import, admin edit, or API batch upsert only wrote records for some of a product's variants, for example the ones that existed at the time of the last export, any variant left out has no record in the price list, and the pricing engine falls straight through to standard catalog pricing for that one variant.
Does BigCommerce warn me when a price list is missing variant coverage?
No. There is no admin warning, no validation error, and no webhook when a price list has a gap. The checkout simply charges the normal catalog price for the uncovered variant while its siblings in the same product are correctly discounted, so the gap is only visible if someone compares the price list's records against the product's full variant list.
Can a script safely fill in the missing price for me?
Not automatically. A missing record is a data gap, not a bug with one deterministic correct value, so the script cannot know what the intended sale or contract price should be. It should report the gap, product_id, variant_id, sku, price_list_id, and the affected customer groups, for a merchandiser to review. It can only write a price if you supply an explicit fallback rule, such as cloning the parent variant's price ratio, and even then only behind a DRY_RUN guard.
Related field notes
Citations
On the problem:
- BigCommerce Support: variant pricing questions and gaps. support.bigcommerce.com variant pricing
- BigCommerce Support: price does not change for different variant prices. support.bigcommerce.com price does not change for different variant prices
- BigCommerce Support: customer group pricing by SKU/Variant. support.bigcommerce.com customer group pricing by sku/variant
On the solution:
- BigCommerce Developer Center: Price Lists. developer.bigcommerce.com price lists
- BigCommerce Developer Center: Price Lists Records. developer.bigcommerce.com price lists records
- BigCommerce Developer Center: Product Variants. developer.bigcommerce.com product variants
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.
Did this catch a pricing gap for you?
If this saved you from a customer noticing a mispriced SKU before you did, 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