Skip to content

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.

Python and Node.js Medusa Admin API Report only, no auto-mutate
People working in an open office
Photo by Arlington Research on Unsplash
The short answer

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.

Customer in 2+ groups Match group rule customer.groups.id vs price list rule no group matches List skipped falls back to base Cart charges base price
One group matches fine. The moment a second group is added, the join finds no overlap at all, and the whole price list is skipped without an error.

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:

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.

The key insight

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.

List multi-group customers Find matching price lists customer.groups.id rule Resolve real vs control multi-group vs single-group Prices agree? no yes, pricing is fine Report mismatch customer, list, variant
The script only reports. It never removes a customer's group or rewrites a price list on its own, because those are decisions only a human should confirm.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
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
2

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.

step2.py
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"]
step2.js
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;
}
3

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.

step3.py
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
step3.js
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;
  }
}
4

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.

step4.py
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
step4.js
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)
    )
  );
}
5

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.

decide.py
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"}
decide.js
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" };
}
6

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
detect_multi_group_price_mismatch.py
"""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-multi-group-price-mismatch.js
/**
 * 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.

test_multigroup_mismatch.py
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
multigroup-mismatch.test.js
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

Wholesale plus loyalty

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.

Batch group assignment

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.

What good looks like

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:

  1. 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
  2. medusajs/medusa GitHub issue #13034: Price calculation fails when customer is associated with multiple customer groups. github.com/medusajs/medusa/issues/13034
  3. 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:

  1. Medusa Documentation: Pricing Module concepts, including price rules and customer groups. docs.medusajs.com/resources/commerce-modules/pricing/concepts
  2. Medusa Documentation: Price tiers and rules. docs.medusajs.com/resources/commerce-modules/pricing/price-rules
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes