Diagnostic Promotions and campaigns

Medusa promotion not applying

The promotion is active, the code is correct, the campaign has budget left, and the cart still shows full price. No error, no warning, nothing in the logs. Somewhere in the promotion's rules or its target rules, one condition does not match this cart, and Medusa treats that as a normal, silent no-op. Here is why a single rule mismatch can hide a promotion in plain sight, and a script that diffs every rule against a real cart and points at the exact one that is wrong.

Python and Node.js Medusa Admin API Report only, no auto-mutate
The short answer

A Medusa promotion applies only when every PromotionRule on it matches. The promotion's own rules decide who and where it applies (customer group, region, currency), and its application_method.target_rules or buy_rules decide what it discounts (product, variant, collection). Each rule is attribute operator values checked against the live cart. If an attribute path is wrong, the operator is too strict, or a value references an id that no longer exists, that one rule evaluates to false, the promotion is skipped, and the API still returns 200 with an unchanged cart. Run a small Python or Node.js script that fetches the promotion's full rule graph and the target cart, diffs each rule against the cart's actual fields, and reports the exact mismatched rule and value instead of guessing.

The problem in plain words

In Medusa v2, a promotion is not one switch you flip on. It is a set of conditions, and the cart has to satisfy all of them at the same moment before any discount is computed. The promotion itself carries rules that describe who the promotion is for and where it is valid, things like customer_group_id, region_id, or currency_code. Separately, its application_method carries target_rules and, for buy-get promotions, buy_rules, which describe what gets discounted, things like items.product_id or a collection id.

Every one of those rules is evaluated the same way: an attribute, an operator, and a list of values, checked against whatever the live cart and its context actually contain when computeActions runs. If the attribute path does not point at the field Medusa actually uses internally, if the operator is stricter than the data allows, or if the values array names an id that used to exist but does not anymore, that rule quietly evaluates to false. There is no partial credit. One failed rule and the whole promotion is skipped, and the checkout carries on as if the promotion were never attached.

Promotion rules + target_rules status: active computeActions checks each rule vs cart one rule fails no error raised No actions promotion skipped Cart stays full price
The promotion looks active in the admin, but one rule does not match this cart. Nothing errors, the API still returns 200, and the discount just never appears.

Why it happens

Medusa's rule engine is strict about matching, but it has no way to tell you which rule failed or why. A few concrete ways a promotion ends up silently skipped:

This is a common source of confusion because status: "active" in the admin says nothing about whether the rules underneath it can ever be satisfied. Medusa's own issue tracker has reports of automatic promotions that appear correctly configured but never trigger, and of evaluation results that flipped entirely after an upgrade. See the citations at the end for the exact threads and docs.

The key insight

A promotion rule is a merchant decision about who qualifies and what gets discounted. A script should never guess at that and silently rewrite it. The safe pattern is to pull the promotion's real rule graph, pull the target cart's real fields, diff one against the other, and report the exact rule id, the exact attribute, and the exact value that does not resolve. A human who owns the promotion decides whether to fix the rule, the campaign, or the underlying data.

The fix, as a flow

We do not touch checkout and we do not rewrite promotion rules on our own. We fetch the promotion with its full rule graph, confirm its status and campaign window are actually live, pull the target cart with its items, region, currency, and customer, then diff every rule and target rule against that cart. Anything that cannot possibly match gets reported with the reason and the exact fix a human can apply.

Fetch promotion rules, target_rules, campaign Fetch target cart items, region, currency, customer Diff each rule attribute, operator, values Every rule matches? no yes, promotion is fine Report mismatch and exact rule fix
The script only reports. It never rewrites a rule's attribute, operator, or values on its own, because eligibility is a merchant decision.

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 promotions, carts, customers, 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

Fetch the promotion's full rule graph

Ask for status, is_automatic, the eligibility rules, the application_method with its target_rules and buy_rules, and the campaign with its budget and dates. This is everything computeActions actually looks at.

step3.py
PROMOTION_FIELDS = (
    "id,code,is_automatic,status,*rules,*application_method,"
    "*application_method.target_rules,*application_method.buy_rules,*campaign"
)

def get_promotion(token, promotion_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/promotions/{promotion_id}",
        params={"fields": PROMOTION_FIELDS},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["promotion"]
step3.js
const PROMOTION_FIELDS =
  "id,code,is_automatic,status,*rules,*application_method," +
  "*application_method.target_rules,*application_method.buy_rules,*campaign";

async function getPromotion(sdk, promotionId) {
  const body = await sdk.admin.promotion.retrieve(promotionId, { fields: PROMOTION_FIELDS });
  return body.promotion;
}
4

Fetch the target cart and the customer's real groups

Pull the cart with its currency, region, customer, and items, plus the customer's actual groups. These are the live values every rule gets compared against, so this is the other half of the diff.

step4.py
CART_FIELDS = "id,currency_code,region_id,customer_id,*items,*items.product_id"

def get_cart(token, cart_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/carts/{cart_id}",
        params={"fields": CART_FIELDS},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["cart"]

def get_customer_groups(token, customer_id):
    if not customer_id:
        return []
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/customers/{customer_id}",
        params={"fields": "id,*groups"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    groups = r.json()["customer"].get("groups") or []
    return [g["id"] for g in groups]
step4.js
const CART_FIELDS = "id,currency_code,region_id,customer_id,*items,*items.product_id";

async function getCart(sdk, cartId) {
  const body = await sdk.admin.cart.retrieve(cartId, { fields: CART_FIELDS });
  return body.cart;
}

async function getCustomerGroups(sdk, customerId) {
  if (!customerId) return [];
  const body = await sdk.admin.customer.retrieve(customerId, { fields: "id,*groups" });
  return (body.customer.groups || []).map((g) => g.id);
}
5

Build the cart context and decide, with one pure function

Assemble a flat context object that mirrors what the cart actually offers, then check each rule against it with a single pure function. The attribute is a dot path into the context. eq and in both mean intersect with the resolved value, ne means no intersection, and the comparison operators do a numeric check. Anything that cannot resolve returns false, never throws, mirroring Medusa's real fail closed behavior.

decide.py
def _resolve_path(attribute, cart_context):
    node = cart_context
    for part in attribute.split("."):
        if isinstance(node, list):
            node = [item.get(part) if isinstance(item, dict) else None for item in node]
        elif isinstance(node, dict):
            node = node.get(part)
        else:
            return None
    return node

def _as_list(value):
    if value is None:
        return []
    if isinstance(value, list):
        out = []
        for v in value:
            out.extend(_as_list(v)) if isinstance(v, list) else out.append(v)
        return out
    return [value]

def rule_matches_cart(rule, cart_context):
    attribute = rule.get("attribute")
    operator = rule.get("operator")
    values = rule.get("values") or []
    if not attribute or not operator or not values:
        return False

    resolved = _as_list(_resolve_path(attribute, cart_context))
    if not resolved:
        return False

    if operator in ("eq", "in"):
        return any(v in values for v in resolved)
    if operator == "ne":
        return not any(v in values for v in resolved)
    if operator in ("gt", "gte", "lt", "lte"):
        if len(resolved) != 1:
            return False
        try:
            left, right = float(resolved[0]), float(values[0])
        except (TypeError, ValueError):
            return False
        if operator == "gt":
            return left > right
        if operator == "gte":
            return left >= right
        if operator == "lt":
            return left < right
        return left <= right
    return False
decide.js
function resolvePath(attribute, cartContext) {
  let node = cartContext;
  for (const part of attribute.split(".")) {
    if (Array.isArray(node)) {
      node = node.map((item) => (item && typeof item === "object" ? item[part] : null));
    } else if (node && typeof node === "object") {
      node = node[part];
    } else {
      return null;
    }
  }
  return node;
}

function asList(value) {
  if (value === null || value === undefined) return [];
  if (Array.isArray(value)) return value.flatMap((v) => asList(v));
  return [value];
}

export function ruleMatchesCart(rule, cartContext) {
  const { attribute, operator, values } = rule;
  if (!attribute || !operator || !values || values.length === 0) return false;

  const resolved = asList(resolvePath(attribute, cartContext));
  if (resolved.length === 0) return false;

  if (operator === "eq" || operator === "in") {
    return resolved.some((v) => values.includes(v));
  }
  if (operator === "ne") {
    return !resolved.some((v) => values.includes(v));
  }
  if (["gt", "gte", "lt", "lte"].includes(operator)) {
    if (resolved.length !== 1) return false;
    const left = Number(resolved[0]);
    const right = Number(values[0]);
    if (Number.isNaN(left) || Number.isNaN(right)) return false;
    if (operator === "gt") return left > right;
    if (operator === "gte") return left >= right;
    if (operator === "lt") return left < right;
    return left <= right;
  }
  return false;
}
6

Diff every rule and report, never auto-mutate

Run rule_matches_cart against every entry in rules, target_rules, and buy_rules, and separately confirm the promotion's status is active and, if it has a campaign, that today falls inside starts_at and ends_at with budget left. Report the first rule that fails and why. Every run is gated behind DRY_RUN, and even when it is false the script only logs the corrected rule payload, it never calls /admin/promotions/:id/rules/batch or /target-rules/batch on its own, because eligibility is a merchant decision only a human should confirm.

Run it safe

This script never writes to your store. It logs the exact POST /admin/promotions/:id/rules/batch or /admin/promotions/:id/target-rules/batch payload that would fix each mismatch, whether that means replacing a stale product id, widening an operator from eq to in, or correcting a currency_code. A human confirms the intended eligibility, then applies it and re-runs the diff against a sample cart before closing the flag.

The full code

Here is the complete script in one file for each language. It authenticates, fetches the promotion's rule graph and the target cart, builds the cart context, diffs every rule, and prints a report with the exact fix for anything that does not line up.

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

audit_promotion_rules.py
"""Audit a Medusa promotion for why it is not applying to a cart.
Diffs the promotion's rules, target_rules, and buy_rules against a real cart's
context and reports the first mismatched rule, never mutates the promotion.
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("audit_promotion_rules")

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"

PROMOTION_FIELDS = (
    "id,code,is_automatic,status,*rules,*application_method,"
    "*application_method.target_rules,*application_method.buy_rules,*campaign"
)
CART_FIELDS = "id,currency_code,region_id,customer_id,*items,*items.product_id"


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 get_promotion(token, promotion_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/promotions/{promotion_id}",
        params={"fields": PROMOTION_FIELDS},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["promotion"]


def get_cart(token, cart_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/carts/{cart_id}",
        params={"fields": CART_FIELDS},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["cart"]


def get_customer_groups(token, customer_id):
    if not customer_id:
        return []
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/customers/{customer_id}",
        params={"fields": "id,*groups"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    groups = r.json()["customer"].get("groups") or []
    return [g["id"] for g in groups]


def build_cart_context(cart, customer_group_ids):
    return {
        "currency_code": cart.get("currency_code"),
        "region": {"id": cart.get("region_id")},
        "region_id": cart.get("region_id"),
        "customer": {"groups": [{"id": gid} for gid in customer_group_ids]},
        "items": {"product": {"id": [item.get("product_id") for item in (cart.get("items") or [])]}},
    }


def _resolve_path(attribute, cart_context):
    node = cart_context
    for part in attribute.split("."):
        if isinstance(node, list):
            node = [item.get(part) if isinstance(item, dict) else None for item in node]
        elif isinstance(node, dict):
            node = node.get(part)
        else:
            return None
    return node


def _as_list(value):
    if value is None:
        return []
    if isinstance(value, list):
        out = []
        for v in value:
            out.extend(_as_list(v)) if isinstance(v, list) else out.append(v)
        return out
    return [value]


def rule_matches_cart(rule, cart_context):
    """Pure: resolves rule.attribute as a dot-path into cart_context and applies
    rule.operator against rule.values. Returns False (never throws) on any
    unresolved path, empty values, or incompatible types, mirroring Medusa's
    real fail-closed rule evaluation.
    """
    attribute = rule.get("attribute")
    operator = rule.get("operator")
    values = rule.get("values") or []
    if not attribute or not operator or not values:
        return False

    resolved = _as_list(_resolve_path(attribute, cart_context))
    if not resolved:
        return False

    if operator in ("eq", "in"):
        return any(v in values for v in resolved)
    if operator == "ne":
        return not any(v in values for v in resolved)
    if operator in ("gt", "gte", "lt", "lte"):
        if len(resolved) != 1:
            return False
        try:
            left, right = float(resolved[0]), float(values[0])
        except (TypeError, ValueError):
            return False
        if operator == "gt":
            return left > right
        if operator == "gte":
            return left >= right
        if operator == "lt":
            return left < right
        return left <= right
    return False


def build_fix_payload(rule):
    if rule.get("operator") == "eq" and len(_as_list(rule.get("values"))) > 0:
        return {"id": rule.get("id"), "operator": "in", "values": rule.get("values")}
    return {"id": rule.get("id"), "attribute": rule.get("attribute"), "values": rule.get("values")}


def audit_promotion(promotion, cart_context):
    """Pure: returns a list of report dicts, one per rule that fails to match."""
    reports = []

    if promotion.get("status") != "active":
        reports.append({"promotion_id": promotion["id"], "reason": f"status is {promotion.get('status')!r}, not active", "rule_id": None, "fix": {"status": "active"}})

    for rule in promotion.get("rules") or []:
        if not rule_matches_cart(rule, cart_context):
            reports.append({
                "promotion_id": promotion["id"],
                "reason": f"eligibility rule {rule.get('attribute')} {rule.get('operator')} does not match the cart",
                "rule_id": rule.get("id"),
                "fix": build_fix_payload(rule),
            })

    method = promotion.get("application_method") or {}
    for rule in method.get("target_rules") or []:
        if not rule_matches_cart(rule, cart_context):
            reports.append({
                "promotion_id": promotion["id"],
                "reason": f"target rule {rule.get('attribute')} {rule.get('operator')} does not match any cart item",
                "rule_id": rule.get("id"),
                "fix": build_fix_payload(rule),
            })
    for rule in method.get("buy_rules") or []:
        if not rule_matches_cart(rule, cart_context):
            reports.append({
                "promotion_id": promotion["id"],
                "reason": f"buy rule {rule.get('attribute')} {rule.get('operator')} does not match any cart item",
                "rule_id": rule.get("id"),
                "fix": build_fix_payload(rule),
            })

    return reports


def run(promotion_id, cart_id):
    token = get_token()
    promotion = get_promotion(token, promotion_id)
    cart = get_cart(token, cart_id)
    customer_group_ids = get_customer_groups(token, cart.get("customer_id"))
    cart_context = build_cart_context(cart, customer_group_ids)

    reports = audit_promotion(promotion, cart_context)
    if not reports:
        log.info("Promotion %s matches this cart on every rule.", promotion_id)
        return

    for r in reports:
        log.info(
            "Promotion %s: %s. %s payload: %s",
            r["promotion_id"], r["reason"],
            "Would send" if DRY_RUN else "Suggested",
            r["fix"],
        )
    log.info("Done. %d rule(s) flagged for promotion %s.", len(reports), promotion_id)


if __name__ == "__main__":
    run(os.environ["PROMOTION_ID"], os.environ["CART_ID"])
audit-promotion-rules.js
/**
 * Audit a Medusa promotion for why it is not applying to a cart.
 * Diffs the promotion's rules, target_rules, and buy_rules against a real cart's
 * context and reports the first mismatched rule, never mutates the promotion.
 * 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 PROMOTION_FIELDS =
  "id,code,is_automatic,status,*rules,*application_method," +
  "*application_method.target_rules,*application_method.buy_rules,*campaign";
const CART_FIELDS = "id,currency_code,region_id,customer_id,*items,*items.product_id";

function resolvePath(attribute, cartContext) {
  let node = cartContext;
  for (const part of attribute.split(".")) {
    if (Array.isArray(node)) {
      node = node.map((item) => (item && typeof item === "object" ? item[part] : null));
    } else if (node && typeof node === "object") {
      node = node[part];
    } else {
      return null;
    }
  }
  return node;
}

function asList(value) {
  if (value === null || value === undefined) return [];
  if (Array.isArray(value)) return value.flatMap((v) => asList(v));
  return [value];
}

/**
 * Pure: resolves rule.attribute as a dot-path into cartContext and applies
 * rule.operator against rule.values. Returns false (never throws) on any
 * unresolved path, empty values, or incompatible types, mirroring Medusa's
 * real fail-closed rule evaluation.
 */
export function ruleMatchesCart(rule, cartContext) {
  const { attribute, operator, values } = rule;
  if (!attribute || !operator || !values || values.length === 0) return false;

  const resolved = asList(resolvePath(attribute, cartContext));
  if (resolved.length === 0) return false;

  if (operator === "eq" || operator === "in") {
    return resolved.some((v) => values.includes(v));
  }
  if (operator === "ne") {
    return !resolved.some((v) => values.includes(v));
  }
  if (["gt", "gte", "lt", "lte"].includes(operator)) {
    if (resolved.length !== 1) return false;
    const left = Number(resolved[0]);
    const right = Number(values[0]);
    if (Number.isNaN(left) || Number.isNaN(right)) return false;
    if (operator === "gt") return left > right;
    if (operator === "gte") return left >= right;
    if (operator === "lt") return left < right;
    return left <= right;
  }
  return false;
}

function buildFixPayload(rule) {
  if (rule.operator === "eq" && asList(rule.values).length > 0) {
    return { id: rule.id, operator: "in", values: rule.values };
  }
  return { id: rule.id, attribute: rule.attribute, values: rule.values };
}

export function buildCartContext(cart, customerGroupIds) {
  return {
    currency_code: cart.currency_code,
    region: { id: cart.region_id },
    region_id: cart.region_id,
    customer: { groups: customerGroupIds.map((id) => ({ id })) },
    items: { product: { id: (cart.items || []).map((item) => item.product_id) } },
  };
}

export function auditPromotion(promotion, cartContext) {
  // Pure: returns a list of report objects, one per rule that fails to match.
  const reports = [];

  if (promotion.status !== "active") {
    reports.push({
      promotionId: promotion.id,
      reason: `status is "${promotion.status}", not active`,
      ruleId: null,
      fix: { status: "active" },
    });
  }

  for (const rule of promotion.rules || []) {
    if (!ruleMatchesCart(rule, cartContext)) {
      reports.push({
        promotionId: promotion.id,
        reason: `eligibility rule ${rule.attribute} ${rule.operator} does not match the cart`,
        ruleId: rule.id,
        fix: buildFixPayload(rule),
      });
    }
  }

  const method = promotion.application_method || {};
  for (const rule of method.target_rules || []) {
    if (!ruleMatchesCart(rule, cartContext)) {
      reports.push({
        promotionId: promotion.id,
        reason: `target rule ${rule.attribute} ${rule.operator} does not match any cart item`,
        ruleId: rule.id,
        fix: buildFixPayload(rule),
      });
    }
  }
  for (const rule of method.buy_rules || []) {
    if (!ruleMatchesCart(rule, cartContext)) {
      reports.push({
        promotionId: promotion.id,
        reason: `buy rule ${rule.attribute} ${rule.operator} does not match any cart item`,
        ruleId: rule.id,
        fix: buildFixPayload(rule),
      });
    }
  }

  return reports;
}

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 getPromotion(sdk, promotionId) {
  const body = await sdk.admin.promotion.retrieve(promotionId, { fields: PROMOTION_FIELDS });
  return body.promotion;
}

async function getCart(sdk, cartId) {
  const body = await sdk.admin.cart.retrieve(cartId, { fields: CART_FIELDS });
  return body.cart;
}

async function getCustomerGroups(sdk, customerId) {
  if (!customerId) return [];
  const body = await sdk.admin.customer.retrieve(customerId, { fields: "id,*groups" });
  return (body.customer.groups || []).map((g) => g.id);
}

export async function run(promotionId, cartId) {
  const sdk = await login();
  const promotion = await getPromotion(sdk, promotionId);
  const cart = await getCart(sdk, cartId);
  const customerGroupIds = await getCustomerGroups(sdk, cart.customer_id);
  const cartContext = buildCartContext(cart, customerGroupIds);

  const reports = auditPromotion(promotion, cartContext);
  if (reports.length === 0) {
    console.log(`Promotion ${promotionId} matches this cart on every rule.`);
    return;
  }

  for (const r of reports) {
    console.log(
      `Promotion ${r.promotionId}: ${r.reason}. ${DRY_RUN ? "Would send" : "Suggested"} payload: ${JSON.stringify(r.fix)}`
    );
  }
  console.log(`Done. ${reports.length} rule(s) flagged for promotion ${promotionId}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const promotionId = process.env.PROMOTION_ID;
  const cartId = process.env.CART_ID;
  run(promotionId, cartId).catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The function worth testing above everything else is the rule matcher, because it decides whether a real discount ever shows up. Because rule_matches_cart is pure, the tests feed in plain rule and context objects, no Medusa backend required.

test_promotion_rule_match.py
from audit_promotion_rules import rule_matches_cart, build_cart_context, audit_promotion

CONTEXT = {
    "currency_code": "eur",
    "region": {"id": "reg_eu"},
    "region_id": "reg_eu",
    "customer": {"groups": [{"id": "pcgrp_vip"}]},
    "items": {"product": {"id": ["prod_1", "prod_2"]}},
}


def rule(**over):
    base = {"id": "prule_1", "attribute": "currency_code", "operator": "eq", "values": ["eur"]}
    base.update(over)
    return base


def test_eq_matches_when_value_present():
    assert rule_matches_cart(rule(), CONTEXT) is True


def test_eq_fails_on_currency_mismatch():
    assert rule_matches_cart(rule(values=["usd"]), CONTEXT) is False


def test_in_matches_any_of_multiple_values():
    r = rule(attribute="customer.groups.id", operator="in", values=["pcgrp_vip", "pcgrp_wholesale"])
    assert rule_matches_cart(r, CONTEXT) is True


def test_wrong_attribute_path_never_matches():
    r = rule(attribute="customer_group_id", operator="eq", values=["pcgrp_vip"])
    assert rule_matches_cart(r, CONTEXT) is False


def test_ne_true_when_no_intersection():
    r = rule(attribute="currency_code", operator="ne", values=["usd"])
    assert rule_matches_cart(r, CONTEXT) is True


def test_target_rule_deleted_product_never_matches():
    r = rule(attribute="items.product.id", operator="in", values=["prod_deleted"])
    assert rule_matches_cart(r, CONTEXT) is False


def test_target_rule_matches_existing_cart_item():
    r = rule(attribute="items.product.id", operator="in", values=["prod_1"])
    assert rule_matches_cart(r, CONTEXT) is True


def test_empty_values_never_matches():
    assert rule_matches_cart(rule(values=[]), CONTEXT) is False


def test_unresolved_path_returns_false_not_throws():
    r = rule(attribute="does.not.exist", operator="eq", values=["x"])
    assert rule_matches_cart(r, CONTEXT) is False


def test_gte_numeric_comparison():
    ctx = {**CONTEXT, "item_total": 100}
    r = rule(attribute="item_total", operator="gte", values=[50])
    assert rule_matches_cart(r, ctx) is True
    assert rule_matches_cart(rule(attribute="item_total", operator="gte", values=[150]), ctx) is False


def test_audit_flags_target_rule_on_deleted_product():
    promotion = {
        "id": "promo_1",
        "status": "active",
        "rules": [],
        "application_method": {"target_rules": [rule(attribute="items.product.id", operator="in", values=["prod_deleted"])]},
    }
    reports = audit_promotion(promotion, CONTEXT)
    assert len(reports) == 1
    assert "target rule" in reports[0]["reason"]


def test_audit_reports_nothing_when_all_rules_match():
    promotion = {
        "id": "promo_2",
        "status": "active",
        "rules": [rule()],
        "application_method": {"target_rules": [rule(attribute="items.product.id", operator="in", values=["prod_1"])]},
    }
    assert audit_promotion(promotion, CONTEXT) == []


def test_build_cart_context_shapes_product_ids():
    cart = {"currency_code": "eur", "region_id": "reg_eu", "items": [{"product_id": "prod_1"}, {"product_id": "prod_2"}]}
    ctx = build_cart_context(cart, ["pcgrp_vip"])
    assert ctx["items"]["product"]["id"] == ["prod_1", "prod_2"]
    assert ctx["customer"]["groups"] == [{"id": "pcgrp_vip"}]
promotion-rule-match.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { ruleMatchesCart, buildCartContext, auditPromotion } from "./audit-promotion-rules.js";

const CONTEXT = {
  currency_code: "eur",
  region: { id: "reg_eu" },
  region_id: "reg_eu",
  customer: { groups: [{ id: "pcgrp_vip" }] },
  items: { product: { id: ["prod_1", "prod_2"] } },
};

const rule = (over = {}) => ({ id: "prule_1", attribute: "currency_code", operator: "eq", values: ["eur"], ...over });

test("eq matches when value present", () => {
  assert.equal(ruleMatchesCart(rule(), CONTEXT), true);
});

test("eq fails on currency mismatch", () => {
  assert.equal(ruleMatchesCart(rule({ values: ["usd"] }), CONTEXT), false);
});

test("in matches any of multiple values", () => {
  const r = rule({ attribute: "customer.groups.id", operator: "in", values: ["pcgrp_vip", "pcgrp_wholesale"] });
  assert.equal(ruleMatchesCart(r, CONTEXT), true);
});

test("wrong attribute path never matches", () => {
  const r = rule({ attribute: "customer_group_id", operator: "eq", values: ["pcgrp_vip"] });
  assert.equal(ruleMatchesCart(r, CONTEXT), false);
});

test("ne true when no intersection", () => {
  const r = rule({ attribute: "currency_code", operator: "ne", values: ["usd"] });
  assert.equal(ruleMatchesCart(r, CONTEXT), true);
});

test("target rule on deleted product never matches", () => {
  const r = rule({ attribute: "items.product.id", operator: "in", values: ["prod_deleted"] });
  assert.equal(ruleMatchesCart(r, CONTEXT), false);
});

test("target rule matches existing cart item", () => {
  const r = rule({ attribute: "items.product.id", operator: "in", values: ["prod_1"] });
  assert.equal(ruleMatchesCart(r, CONTEXT), true);
});

test("empty values never matches", () => {
  assert.equal(ruleMatchesCart(rule({ values: [] }), CONTEXT), false);
});

test("unresolved path returns false not throws", () => {
  const r = rule({ attribute: "does.not.exist", operator: "eq", values: ["x"] });
  assert.equal(ruleMatchesCart(r, CONTEXT), false);
});

test("gte numeric comparison", () => {
  const ctx = { ...CONTEXT, item_total: 100 };
  assert.equal(ruleMatchesCart(rule({ attribute: "item_total", operator: "gte", values: [50] }), ctx), true);
  assert.equal(ruleMatchesCart(rule({ attribute: "item_total", operator: "gte", values: [150] }), ctx), false);
});

test("audit flags target rule on deleted product", () => {
  const promotion = {
    id: "promo_1",
    status: "active",
    rules: [],
    application_method: { target_rules: [rule({ attribute: "items.product.id", operator: "in", values: ["prod_deleted"] })] },
  };
  const reports = auditPromotion(promotion, CONTEXT);
  assert.equal(reports.length, 1);
  assert.match(reports[0].reason, /target rule/);
});

test("audit reports nothing when all rules match", () => {
  const promotion = {
    id: "promo_2",
    status: "active",
    rules: [rule()],
    application_method: { target_rules: [rule({ attribute: "items.product.id", operator: "in", values: ["prod_1"] })] },
  };
  assert.deepEqual(auditPromotion(promotion, CONTEXT), []);
});

test("buildCartContext shapes product ids", () => {
  const cart = { currency_code: "eur", region_id: "reg_eu", items: [{ product_id: "prod_1" }, { product_id: "prod_2" }] };
  const ctx = buildCartContext(cart, ["pcgrp_vip"]);
  assert.deepEqual(ctx.items.product.id, ["prod_1", "prod_2"]);
  assert.deepEqual(ctx.customer.groups, [{ id: "pcgrp_vip" }]);
});

Case studies

Deleted product

The bundle discount that quietly died with the product

A store ran a promotion targeting a specific hero product with a target_rules entry pinned to its product_id. Months later the merchandising team retired that product and launched a near-identical replacement under a new id. The promotion's status stayed active, the campaign still had budget, and support started getting tickets that the discount code did nothing.

The audit script fetched the promotion's target_rules, tried to resolve the old product_id against the live cart, and reported it as a rule referencing an id no cart item could ever match. Swapping in the replacement product's id in target-rules/batch fixed it in one call.

Strict operator

The VIP discount that only worked for half the VIPs

A merchant built a customer group discount and set the eligibility rule to customer_group_id eq pcgrp_vip. It worked fine in testing with an account that belonged to only that one group. Once real VIP customers, who each belonged to two or three groups over time, tried the code, half of them got no discount and nobody could explain the pattern.

Running the diff against a few real customer carts showed the rule's attribute pointed at a path Medusa does not expose directly, and that eq against a single group id could never account for customers in multiple groups. Correcting the attribute and switching the operator to in against the full list of qualifying group ids resolved it for every VIP, not just the lucky ones.

What good looks like

Run this audit any time a promotion should apply and does not, pointed at one real cart that should qualify. It never touches your promotion configuration, it only tells you which rule, which attribute, and which value is the actual blocker, plus the exact admin call to fix it. The merchant keeps full control over who qualifies and what gets discounted, and stops losing conversions to a silent rule mismatch nobody can see from the storefront.

FAQ

Why is my Medusa promotion not applying to the cart?

A Medusa promotion is only used when every rule on it matches the live cart. Eligibility rules on the promotion (customer group, region, currency) and target or buy rules on its application method (product, variant, collection) are each checked as an attribute, an operator, and a list of values against the cart context. If one attribute path is wrong, one operator is too strict, or one value points at an id that no longer exists, that single rule evaluates to false and Medusa skips the whole promotion with no error and a normal 200 response.

Why does Medusa not throw an error when a promotion rule fails to match?

Rule evaluation in the Promotion module is boolean matching with no partial-match feedback. computeActions either returns actions for a promotion or it returns none, and returning none is a completely valid, expected outcome for a promotion whose conditions are not met right now. There is nothing wrong from the API's point of view, so it never raises, which is exactly why a rule mismatch looks like the promotion silently does nothing.

How do I find which exact rule is blocking a Medusa promotion?

Fetch the promotion with its full rule graph using fields=*rules,*application_method,*application_method.target_rules,*application_method.buy_rules,*campaign, then fetch the target cart with its items, region, currency, and customer. Diff each rule's attribute, operator, and values against the matching cart field one at a time. The first rule whose values reference a deleted product, a renamed customer group, or a currency the cart's region does not use is your answer.

Related field notes

Citations

On the problem:

  1. Medusa Documentation: Promotion module concepts, including rules, application methods, and computeActions. docs.medusajs.com/resources/commerce-modules/promotion/concepts
  2. medusajs/medusa GitHub issue #10539: Automatic promotion percent off on a product seems to not be working. github.com/medusajs/medusa/issues/10539
  3. medusajs/medusa GitHub issue #11548: promotion evaluation result may be completely reversed after v2.4.0. github.com/medusajs/medusa/issues/11548

On the solution:

  1. Medusa Documentation: the PromotionRule data model reference. docs.medusajs.com/resources/references/promotion/models/PromotionRule
  2. Medusa Documentation: the Promotion module. docs.medusajs.com/resources/commerce-modules/promotion
  3. Medusa Documentation: Promotions adjustments in carts. docs.medusajs.com/resources/commerce-modules/cart/promotions

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 lost conversion, 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