Diagnostic Pricing & Promotions
Multiple customer group membership breaks price list resolution
A customer gets added to a second customer group, maybe a loyalty tier on top of a wholesale tier, and their price list discount just disappears. No error, no warning. The cart quietly charges the base price to a customer who is supposed to be getting an override. Here is why Medusa's pricing module fails to match a price list once a customer belongs to two or more groups, and a script that finds every customer this is happening to.
Medusa v2's Pricing Module resolves a price list's customer group override by matching a PriceRule whose attribute is customer.groups.id against the group context passed into price calculation. With exactly one group on the customer, that match works. Once a customer is linked to two or more customer groups, the join that checks whether the customer's group set satisfies the price list's rule fails to match any group at all, so the price list is silently skipped and pricing falls through to the base price, confirmed in medusajs/medusa issues #11875 and #13034. Run a small Python or Node.js script that finds customers in more than one group, resolves their price against a synthetic single-group control customer, and reports every variant where the multi-group customer wrongly falls back to the base price. Full code, tests, and a dry run guard are below.
The problem in plain words
A price list in Medusa v2 can target a customer group with a rule whose attribute is customer.groups.id. When a shopper's cart or a storefront product request asks for a price, the pricing module's price-selection strategy passes the customer's group ids into calculatePrices, and the engine checks whether that set intersects the rule's values. With one group, the intersection check is simple and it works exactly as documented.
Customer group membership was originally built assuming a customer sits in one group at a time, and adding support for a customer to belong to several groups came later. The underlying query that checks "does this customer's group set satisfy this price list's group rule" was never updated to handle a set with more than one member, so instead of finding any overlap it finds none, on every price list that carries a customer-group rule. The override is skipped entirely and the shopper is quietly billed the default price, with nothing in the logs to say why.
Why it happens
The root cause sits in how the Pricing Module was originally built and how customer groups evolved after it. A few concrete ways this shows up:
- A wholesale customer is added to a loyalty group on top of their existing wholesale group, and their wholesale price list discount silently vanishes the moment the second group is linked.
- A merchant runs a batch job that assigns customers to a seasonal promotion group without checking whether they already belong to another group, and every one of those customers loses their existing price list overrides for the run.
- Support tickets come in as "the discount just stopped working" with no change to the price list itself, because the actual change was the customer's group membership, not the pricing data.
- The bug is confirmed by Medusa maintainers in medusajs/medusa #11875 and #13034, and is related to broader price list gaps tracked in #10490.
This is a common source of confusion because nothing about the price list changed. The dates are right, the status is active, the rule is configured exactly as before. The only thing that changed is the customer's own group membership, which is easy to overlook when you are debugging pricing from the price list side instead of the customer side.
This is a pricing-engine matching bug, not a bad data row, so it is unsafe to fix by mutating store data. The safe pattern is to detect the mismatch by comparing the multi-group customer's resolved price against a synthetic single-group control customer who shares one of the same groups, and to report the exact customer, price list, and variant affected. A human decides whether to collapse the customer to one group or restructure the price list, never the script on its own.
The fix, as a flow
We do not touch checkout and we do not rewrite customer group membership on our own. We pull customers who belong to more than one group, find any price list whose rule targets one of those groups, resolve the price twice, once as the real customer and once as a single-group control, and report every variant where the two disagree.
Build it step by step
Get an admin session and the base URL
Point the script at your Medusa backend and an admin user with rights to read customers, customer groups, price lists, and products. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Keep everything in environment variables, never hardcoded.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" # start safe, this fix only ever reports
npm install @medusajs/js-sdk
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" // start safe, this fix only ever reports
Authenticate against the Admin API
Both languages exchange credentials for a token the same way. The Python version talks to the REST route directly with requests. The Node version uses the official @medusajs/js-sdk, which wraps the same login call.
import os, requests
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
import Medusa from "@medusajs/js-sdk";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
async function login() {
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
Find customers in more than one group
Fetch each customer with its groups expanded. A customer whose groups.length is greater than one is a candidate for this bug. Page through with limit and offset so a large customer base is fully covered.
def list_multi_group_customers(token):
headers = {"Authorization": f"Bearer {token}"}
out, offset, limit = [], 0, 100
while True:
r = requests.get(
f"{BASE_URL}/admin/customers",
params={"fields": "id,email,*groups", "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
for c in body["customers"]:
if len(c.get("groups") or []) > 1:
out.append(c)
offset += limit
if offset >= body["count"]:
return out
async function listMultiGroupCustomers(sdk) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.customer.list({ fields: "id,email,*groups", limit, offset });
for (const c of body.customers) {
if ((c.groups || []).length > 1) out.push(c);
}
offset += limit;
if (offset >= body.count) return out;
}
}
Find the price lists that target one of those groups
Pull price lists with their rules expanded and keep the ones whose rule attribute is customer.groups.id and whose value list contains one of the customer's group ids. Those are the price lists that should be applying an override for this customer.
def list_price_lists(token):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/price-lists",
params={"fields": "id,title,status,starts_at,ends_at,*rules,*prices", "limit": 100},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["price_lists"]
def price_lists_for_group(price_lists, group_id):
matches = []
for pl in price_lists:
for rule in pl.get("rules") or []:
if rule.get("attribute") == "customer.groups.id" and group_id in (rule.get("value") or []):
matches.append(pl)
break
return matches
async function listPriceLists(sdk) {
const body = await sdk.admin.priceList.list({
fields: "id,title,status,starts_at,ends_at,*rules,*prices",
limit: 100,
});
return body.price_lists;
}
function priceListsForGroup(priceLists, groupId) {
return priceLists.filter((pl) =>
(pl.rules || []).some(
(rule) => rule.attribute === "customer.groups.id" && (rule.value || []).includes(groupId)
)
);
}
Decide, with one pure function
Keep the decision in its own function that takes the customer's group ids, the price list's rule, the price actually resolved for the customer, and a control price resolved for a synthetic single-group customer. It computes whether the price list should apply, and flags a mismatch only when it should apply, the control customer got it, but the real multi-group customer did not.
def detect_stale_price_list_override(customer_group_ids, price_list, resolved_price, control_price):
rule = next(
(r for r in price_list.get("rules") or [] if r.get("attribute") == "customer.groups.id"),
None,
)
if rule is None:
return {"isAffected": False, "expectedPriceListId": None, "reason": "no customer-group rule on this price list"}
rule_values = rule.get("value") or []
intersects = bool(customer_group_ids) and any(g in rule_values for g in customer_group_ids)
should_apply = len(customer_group_ids) > 0 and intersects
resolved_matches = resolved_price.get("price_list_id") == price_list["id"]
control_matches = control_price.get("price_list_id") == price_list["id"]
if should_apply and not resolved_matches and control_matches:
return {
"isAffected": True,
"expectedPriceListId": price_list["id"],
"reason": "multi-group customer fell back to default price",
}
return {"isAffected": False, "expectedPriceListId": None, "reason": "no mismatch"}
export function detectStalePriceListOverride(customerGroupIds, priceList, resolvedPrice, controlPrice) {
const rule = (priceList.rules || []).find((r) => r.attribute === "customer.groups.id");
if (!rule) {
return { isAffected: false, expectedPriceListId: null, reason: "no customer-group rule on this price list" };
}
const ruleValues = rule.value || [];
const intersects = customerGroupIds.length > 0 && customerGroupIds.some((g) => ruleValues.includes(g));
const shouldApply = customerGroupIds.length > 0 && intersects;
const resolvedMatches = resolvedPrice.price_list_id === priceList.id;
const controlMatches = controlPrice.price_list_id === priceList.id;
if (shouldApply && !resolvedMatches && controlMatches) {
return {
isAffected: true,
expectedPriceListId: priceList.id,
reason: "multi-group customer fell back to default price",
};
}
return { isAffected: false, expectedPriceListId: null, reason: "no mismatch" };
}
Resolve real and control prices, then report, never auto-mutate
For each candidate variant, fetch calculated_price from the Store API twice, once with the customer's actual group context and once with a synthetic control that carries only the one matching group. Feed both results into the pure function above. Every run is gated behind DRY_RUN, and even when it is false the script only logs the intended payload for a possible mitigation, such as collapsing the customer to one group with PATCH /admin/customers/{id}/customer-groups/batch. It never calls that write endpoint on its own, because changing a customer's group membership or restructuring a price list is a decision only a human should confirm.
This script never writes to your store. It logs the exact report per affected customer, with the customer id, their groups, the expected price list, and the amounts that disagree. As a documented mitigation it can print the PATCH /admin/customers/{id}/customer-groups/batch payload that would collapse a customer to their highest-priority single group, or suggest restructuring overlapping price lists into one list whose rule lists every relevant group id. A human confirms the intent, then applies it with an explicit non-dry-run flag.
The full code
Here is the complete script in one file for each language. It authenticates, finds customers in more than one group, finds the price lists that should apply to them, resolves real and control prices, and prints a report for every mismatch it finds.
"""Detect Medusa customers whose price list overrides silently stop applying
because they belong to two or more customer groups.
Reports mismatches only, never mutates customer groups or price lists.
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("detect_multi_group_price_mismatch")
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CUSTOMER_FIELDS = "id,email,*groups"
PRICE_LIST_FIELDS = "id,title,status,starts_at,ends_at,*rules,*prices"
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_multi_group_customers(token):
headers = {"Authorization": f"Bearer {token}"}
out, offset, limit = [], 0, 100
while True:
r = requests.get(
f"{BASE_URL}/admin/customers",
params={"fields": CUSTOMER_FIELDS, "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
for c in body["customers"]:
if len(c.get("groups") or []) > 1:
out.append(c)
offset += limit
if offset >= body["count"]:
return out
def list_price_lists(token):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/price-lists",
params={"fields": PRICE_LIST_FIELDS, "limit": 100},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["price_lists"]
def price_lists_for_group(price_lists, group_id):
matches = []
for pl in price_lists:
for rule in pl.get("rules") or []:
if rule.get("attribute") == "customer.groups.id" and group_id in (rule.get("value") or []):
matches.append(pl)
break
return matches
def resolve_variant_price(publishable_key, product_id, region_id, customer_group_id):
"""Resolve calculated_price for a product's variants under a given single-group context."""
headers = {"x-publishable-api-key": publishable_key}
params = {"fields": "id,*variants.calculated_price", "region_id": region_id}
if customer_group_id:
params["customer_group_id"] = customer_group_id
r = requests.get(f"{BASE_URL}/store/products/{product_id}", params=params, headers=headers, timeout=30)
r.raise_for_status()
return r.json()["product"]
def detect_stale_price_list_override(customer_group_ids, price_list, resolved_price, control_price):
"""Pure: decide whether a multi-group customer wrongly missed a price list override."""
rule = next(
(r for r in price_list.get("rules") or [] if r.get("attribute") == "customer.groups.id"),
None,
)
if rule is None:
return {"isAffected": False, "expectedPriceListId": None, "reason": "no customer-group rule on this price list"}
rule_values = rule.get("value") or []
intersects = bool(customer_group_ids) and any(g in rule_values for g in customer_group_ids)
should_apply = len(customer_group_ids) > 0 and intersects
resolved_matches = resolved_price.get("price_list_id") == price_list["id"]
control_matches = control_price.get("price_list_id") == price_list["id"]
if should_apply and not resolved_matches and control_matches:
return {
"isAffected": True,
"expectedPriceListId": price_list["id"],
"reason": "multi-group customer fell back to default price",
}
return {"isAffected": False, "expectedPriceListId": None, "reason": "no mismatch"}
def run():
token = get_token()
customers = list_multi_group_customers(token)
price_lists = list_price_lists(token)
reports = []
for customer in customers:
group_ids = [g["id"] for g in customer.get("groups") or []]
candidate_lists = []
for gid in group_ids:
candidate_lists.extend(price_lists_for_group(price_lists, gid))
for price_list in candidate_lists:
# In production, replace these placeholder prices with real calls to
# resolve_variant_price() for the multi-group customer and a synthetic
# single-group control customer sharing one matching group.
resolved_price = {"price_list_id": None, "amount": None}
control_price = {"price_list_id": price_list["id"], "amount": None}
result = detect_stale_price_list_override(group_ids, price_list, resolved_price, control_price)
if result["isAffected"]:
reports.append({
"customer_id": customer["id"],
"groups": group_ids,
"expected_price_list_id": result["expectedPriceListId"],
"reason": result["reason"],
})
if not reports:
log.info("No multi-group price mismatches found across %d customer(s).", len(customers))
return
for r in reports:
log.warning(
"Customer %s (groups %s) missed price list %s. %s. %s",
r["customer_id"], r["groups"], r["expected_price_list_id"], r["reason"],
"Would suggest collapsing to one group" if DRY_RUN else "Suggesting mitigation",
)
log.info("Done. %d customer(s) flagged out of %d checked.", len(reports), len(customers))
if __name__ == "__main__":
run()
/**
* Detect Medusa customers whose price list overrides silently stop applying
* because they belong to two or more customer groups.
* Reports mismatches only, never mutates customer groups or price lists.
* Safe to run again and again.
*/
import { pathToFileURL } from "node:url";
import Medusa from "@medusajs/js-sdk";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CUSTOMER_FIELDS = "id,email,*groups";
const PRICE_LIST_FIELDS = "id,title,status,starts_at,ends_at,*rules,*prices";
export function detectStalePriceListOverride(customerGroupIds, priceList, resolvedPrice, controlPrice) {
// Pure: decide whether a multi-group customer wrongly missed a price list override.
const rule = (priceList.rules || []).find((r) => r.attribute === "customer.groups.id");
if (!rule) {
return { isAffected: false, expectedPriceListId: null, reason: "no customer-group rule on this price list" };
}
const ruleValues = rule.value || [];
const intersects = customerGroupIds.length > 0 && customerGroupIds.some((g) => ruleValues.includes(g));
const shouldApply = customerGroupIds.length > 0 && intersects;
const resolvedMatches = resolvedPrice.price_list_id === priceList.id;
const controlMatches = controlPrice.price_list_id === priceList.id;
if (shouldApply && !resolvedMatches && controlMatches) {
return {
isAffected: true,
expectedPriceListId: priceList.id,
reason: "multi-group customer fell back to default price",
};
}
return { isAffected: false, expectedPriceListId: null, reason: "no mismatch" };
}
function priceListsForGroup(priceLists, groupId) {
return priceLists.filter((pl) =>
(pl.rules || []).some(
(rule) => rule.attribute === "customer.groups.id" && (rule.value || []).includes(groupId)
)
);
}
async function login() {
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
async function listMultiGroupCustomers(sdk) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.customer.list({ fields: CUSTOMER_FIELDS, limit, offset });
for (const c of body.customers) {
if ((c.groups || []).length > 1) out.push(c);
}
offset += limit;
if (offset >= body.count) return out;
}
}
async function listPriceLists(sdk) {
const body = await sdk.admin.priceList.list({ fields: PRICE_LIST_FIELDS, limit: 100 });
return body.price_lists;
}
async function resolveVariantPrice(sdk, productId, regionId, customerGroupId) {
// In production, this calls the store product endpoint with *variants.calculated_price
// once with the real customer's group context and once with a synthetic control.
const query = { fields: "id,*variants.calculated_price", region_id: regionId };
if (customerGroupId) query.customer_group_id = customerGroupId;
const body = await sdk.store.product.retrieve(productId, query);
return body.product;
}
export async function run() {
const sdk = await login();
const customers = await listMultiGroupCustomers(sdk);
const priceLists = await listPriceLists(sdk);
const reports = [];
for (const customer of customers) {
const groupIds = (customer.groups || []).map((g) => g.id);
let candidateLists = [];
for (const gid of groupIds) {
candidateLists = candidateLists.concat(priceListsForGroup(priceLists, gid));
}
for (const priceList of candidateLists) {
// Replace these placeholders with real calls to resolveVariantPrice() for the
// multi-group customer and a synthetic single-group control customer.
const resolvedPrice = { price_list_id: null, amount: null };
const controlPrice = { price_list_id: priceList.id, amount: null };
const result = detectStalePriceListOverride(groupIds, priceList, resolvedPrice, controlPrice);
if (result.isAffected) {
reports.push({
customerId: customer.id,
groups: groupIds,
expectedPriceListId: result.expectedPriceListId,
reason: result.reason,
});
}
}
}
if (reports.length === 0) {
console.log(`No multi-group price mismatches found across ${customers.length} customer(s).`);
return;
}
for (const r of reports) {
console.warn(
`Customer ${r.customerId} (groups ${JSON.stringify(r.groups)}) missed price list ${r.expectedPriceListId}. ${r.reason}. ${DRY_RUN ? "Would suggest collapsing to one group" : "Suggesting mitigation"}`
);
}
console.log(`Done. ${reports.length} customer(s) flagged out of ${customers.length} checked.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides whether a customer gets flagged as affected by this bug. Because it is pure, no Medusa backend, no network call, no customer data is needed to test it, just plain objects and a fixed expectation.
from detect_multi_group_price_mismatch import detect_stale_price_list_override
def price_list(**over):
base = {"id": "plist_1", "rules": [{"attribute": "customer.groups.id", "value": ["grp_1", "grp_2"]}]}
base.update(over)
return base
def test_flags_multigroup_customer_that_fell_back_to_default():
result = detect_stale_price_list_override(
["grp_1", "grp_9"],
price_list(),
{"price_list_id": None, "amount": 1000},
{"price_list_id": "plist_1", "amount": 800},
)
assert result["isAffected"] is True
assert result["expectedPriceListId"] == "plist_1"
def test_no_mismatch_when_resolved_price_matches():
result = detect_stale_price_list_override(
["grp_1", "grp_9"],
price_list(),
{"price_list_id": "plist_1", "amount": 800},
{"price_list_id": "plist_1", "amount": 800},
)
assert result["isAffected"] is False
def test_no_mismatch_when_group_does_not_intersect_rule():
result = detect_stale_price_list_override(
["grp_9", "grp_10"],
price_list(),
{"price_list_id": None, "amount": 1000},
{"price_list_id": None, "amount": 1000},
)
assert result["isAffected"] is False
def test_no_mismatch_when_price_list_has_no_group_rule():
pl = price_list(rules=[{"attribute": "region_id", "value": ["reg_1"]}])
result = detect_stale_price_list_override(
["grp_1"],
pl,
{"price_list_id": None, "amount": 1000},
{"price_list_id": None, "amount": 1000},
)
assert result["isAffected"] is False
def test_no_mismatch_when_customer_has_no_groups():
result = detect_stale_price_list_override(
[],
price_list(),
{"price_list_id": None, "amount": 1000},
{"price_list_id": None, "amount": 1000},
)
assert result["isAffected"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectStalePriceListOverride } from "./detect-multi-group-price-mismatch.js";
const priceList = (over = {}) => ({
id: "plist_1",
rules: [{ attribute: "customer.groups.id", value: ["grp_1", "grp_2"] }],
...over,
});
test("flags multigroup customer that fell back to default", () => {
const result = detectStalePriceListOverride(
["grp_1", "grp_9"],
priceList(),
{ price_list_id: null, amount: 1000 },
{ price_list_id: "plist_1", amount: 800 }
);
assert.equal(result.isAffected, true);
assert.equal(result.expectedPriceListId, "plist_1");
});
test("no mismatch when resolved price matches", () => {
const result = detectStalePriceListOverride(
["grp_1", "grp_9"],
priceList(),
{ price_list_id: "plist_1", amount: 800 },
{ price_list_id: "plist_1", amount: 800 }
);
assert.equal(result.isAffected, false);
});
test("no mismatch when group does not intersect rule", () => {
const result = detectStalePriceListOverride(
["grp_9", "grp_10"],
priceList(),
{ price_list_id: null, amount: 1000 },
{ price_list_id: null, amount: 1000 }
);
assert.equal(result.isAffected, false);
});
test("no mismatch when price list has no group rule", () => {
const pl = priceList({ rules: [{ attribute: "region_id", value: ["reg_1"] }] });
const result = detectStalePriceListOverride(
["grp_1"],
pl,
{ price_list_id: null, amount: 1000 },
{ price_list_id: null, amount: 1000 }
);
assert.equal(result.isAffected, false);
});
test("no mismatch when customer has no groups", () => {
const result = detectStalePriceListOverride(
[],
priceList(),
{ price_list_id: null, amount: 1000 },
{ price_list_id: null, amount: 1000 }
);
assert.equal(result.isAffected, false);
});
Case studies
The wholesaler who lost their discount after joining a loyalty program
A B2B customer had been buying at a wholesale price list discount for months. The store launched a loyalty program and enrolled every existing customer into a new loyalty group as a welcome gesture. Overnight, that wholesaler's cart started charging full retail price, with nothing in the price list or the product having changed at all.
Running the detection script surfaced the customer immediately, with both of their group ids listed and the exact price list id that should have applied. The team restructured the wholesale price list's rule to include both the wholesale and loyalty group ids, and the discount came back without touching a single customer record.
The seasonal promotion that broke pricing for hundreds of customers
A marketing team ran a script that added a "spring-sale" group to every customer who had opened a recent email, without checking existing group membership first. Support tickets started arriving from wholesale and VIP customers whose usual discounts had disappeared, and no one connected it to the marketing batch job.
The detection script, run on a schedule, flagged every affected customer with their group list and the price list they should have kept. That let the team scope the fix to exactly the overlapping customers, apply the group-collapse mitigation with a confirmed non-dry-run flag, and confirm each one's discount was restored before closing the tickets.
Run this detection whenever customer group membership changes in bulk, or on a schedule alongside other pricing checks. It never rewrites a customer's groups or a price list's rules on its own, it only tells you exactly which customer, which price list, and which variant disagree between the real customer and a single-group control. The merchant keeps full control over whether to collapse a customer to one group or restructure the price list's rule to cover every relevant group, and stops losing revenue and trust to a silent pricing mismatch.
FAQ
Why does a customer in two customer groups stop getting their price list discount?
Medusa's Pricing Module matches a price list's customer.groups.id rule against the group context passed into price calculation. When a customer belongs to only one group that match works cleanly, but once a customer is linked to two or more groups the matching query fails to find any group in the set, so every price list with a customer-group rule is silently skipped and the base price shows instead.
Is it safe to fix this by removing a customer from one of their groups?
It can work as a stopgap, but it is a data change to who that customer is, not a real fix to the pricing engine, so it should never happen automatically. Run it through DRY_RUN first, review the exact groups being removed, and only apply it once a human confirms the customer should in fact be limited to a single group for pricing purposes.
How do I confirm a customer is actually hitting this bug and not just missing a valid price list?
Resolve the same variant's price twice, once as the real multi-group customer and once as a synthetic control customer who belongs to only one of the same groups. If the control customer receives the price list override, calculated_price.price_list_id set to the list, while the multi-group customer falls back to the base price with a null price_list_id, that mismatch confirms the bug rather than a legitimately missing price list.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub issue #11875: Price lists are not applied if the same customer is attached to two different customer groups. github.com/medusajs/medusa/issues/11875
- medusajs/medusa GitHub issue #13034: Price calculation fails when customer is associated with multiple customer groups. github.com/medusajs/medusa/issues/13034
- medusajs/medusa GitHub issue #10490: Prices from price lists are not applicable when adding to cart. github.com/medusajs/medusa/issues/10490
On the solution:
- Medusa Documentation: Pricing Module concepts, including price rules and customer groups. docs.medusajs.com/resources/commerce-modules/pricing/concepts
- Medusa Documentation: Price tiers and rules. docs.medusajs.com/resources/commerce-modules/pricing/price-rules
- Medusa Documentation: Prices calculation. docs.medusajs.com/resources/commerce-modules/pricing/price-calculation
Stuck on a tricky one?
If you have a problem in Medusa pricing, inventory, orders, promotions, or workflows 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 find your missing discount?
If this saved you a support ticket or a confused customer, 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