Diagnostic Pricing & Promotions
Buy X get Y promotions fail to apply during cart updates
The buyget promotion is active, the code is on the cart, and the customer really did buy enough of the qualifying product. But the free or discounted item never shows up, and it stays missing through every cart update. Nothing errors. The promotion just quietly produces nothing every time Medusa recomputes the cart. Here is why a buyget promotion's application_method can look complete in the admin and still be structurally unable to ever discount anything, and a script that flags it and rebuilds a safe, working payload.
In Medusa v2, a type: "buyget" promotion needs application_method.buy_rules for the "buy X" side, application_method.target_rules for the "get Y" side, and a target_type, allocation, apply_to_quantity, and max_quantity combination the buyget engine can actually evaluate. The Promotion module's create validation accepts several combinations that look reasonable, for example target_type: "order", but are not supported for buyget. The promotion saves, is visible in the admin, and stays attached to the cart, but every time updateCartPromotionsWorkflow re-fetches the cart and calls computeActions(), the rule engine cannot find a valid target to discount and returns zero actions with no error raised. Run a small Python or Node.js script that lists your buyget promotions, flags any whose application_method is structurally invalid, and, only when you set DRY_RUN=false, writes the corrected payload and forces Medusa to recompute the cart through its own endpoint. Full code and tests below.
The problem in plain words
A buyget promotion is really two rule sets bolted together. One side answers "did the customer buy enough of the qualifying product," which Medusa checks with application_method.buy_rules against a buy_rules_min_quantity. The other side answers "what does the customer get for it," which Medusa checks with application_method.target_rules, combined with target_type, allocation, apply_to_quantity, and, when allocation is each, max_quantity.
When you create the promotion through the Admin API, Medusa validates that the shape is generally sane, but it accepts several field combinations that read fine on paper and are still not supported for type: "buyget". The most common one is target_type: "order", which is a valid target type for other promotion types but not one the buyget engine can resolve to individual line items. The same goes for a missing buy_rules or target_rules array, a null apply_to_quantity, or an allocation: "each" with no max_quantity. None of these stop the promotion from being created. It saves, it shows a green active status in the admin, and cart.promotions happily lists it once the code is applied.
The failure only shows up downstream, in updateCartPromotionsWorkflow (packages/core/core-flows/src/cart/workflows/update-cart-promotions.ts). Every time the cart changes, that workflow re-fetches the cart with useRemoteQueryStep and calls PromotionModuleService.computeActions() to re-evaluate every attached promotion against the current line items. For a malformed buyget application method, the rule engine has no valid target to attach a discount to, so it silently returns an empty action array. No exception, no warning, just zero adjustments, over and over, on every add-to-cart and every quantity change.
Why it happens
The Promotion module's create validation checks that fields exist and are the right type, not that the specific combination is supported for the promotion's type. A few concrete ways a buyget promotion ends up structurally broken:
application_method.target_typeis set to"order", which is valid for other promotion types but is not a target the buyget engine can resolve down to a discountable line item.application_method.target_rulesis empty or missing, so there is nothing describing which line item actually receives the "get Y" discount.application_method.buy_rulesis empty or missing, so the engine cannot confirm the "buy X" prerequisite even if the cart clearly qualifies.apply_to_quantityis left null, so Medusa does not know how many units of the target item the discount should cover.allocationis set to"each"withmax_quantityleft null, which leaves the per-unit cap undefined and the engine has nothing to apply the discount against.- Two buyget promotions target the same product and are expected to stack during recomputation, a second, related defect that keeps at least one of them from resolving correctly even when both are individually well formed.
This is a common source of confusion because the admin UI and the create call both accept the promotion without complaint. Medusa's own issue tracker has a report of exactly this behavior, where a buyget promotion, "Buy two shirts and get 10% off the entire order," is created successfully but never applied inside update-cart-promotions, closed stale with no documented root workaround, plus a second confirmed defect where multiple buyget promotions on the same product cannot stack correctly during recomputation. See the citations at the end for the exact threads and docs.
What discount applies to what item, and how it stacks with other promotions, is a merchant decision. A script should never guess a new application_method shape and silently push it to a live promotion, because that can reprice orders that are already in progress. The safe pattern is to detect the exact unsupported combination with a pure, testable rule, compute the corrected payload that Medusa's buyget engine actually supports, and only write it once a human flips DRY_RUN to false, then let Medusa's own cart endpoint recompute the discount instead of injecting an adjustment by hand.
The fix, as a flow
We do not touch checkout and we do not force adjustments onto a cart directly. We list every buyget promotion, check each one's application_method against the exact shape the buyget engine supports, and report anything invalid. Only with DRY_RUN=false do we patch the application_method through the Admin API, then call the storefront cart promotions endpoint again so Medusa's own workflow recomputes the discount and we can confirm a real adjustment now exists.
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 and update promotions and read carts. 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, and default to a dry run.
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, change to false only after reviewing the diff
// Node 18+ has fetch built in, no dependencies needed
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, change to false only after reviewing the diff
Authenticate against the Admin API
Every call after this sends the returned token as a Bearer header. A small helper keeps the request shape in one place for both the list call and the later patch 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"]
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;
async function getToken() {
const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
const body = await res.json();
return body.token;
}
List every buyget promotion with its application method
Ask for type, status, is_automatic, and the full application_method including target_rules and buy_rules. This is everything the buyget engine reads, and it is everything the pure validator needs.
PROMOTION_FIELDS = (
"id,code,type,status,is_automatic,*application_method,"
"*application_method.target_rules,*application_method.buy_rules"
)
def list_buyget_promotions(token):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/promotions",
params={"fields": PROMOTION_FIELDS, "type": "buyget", "limit": 100},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["promotions"]
const PROMOTION_FIELDS =
"id,code,type,status,is_automatic,*application_method," +
"*application_method.target_rules,*application_method.buy_rules";
async function listBuygetPromotions(token) {
const url = new URL(`${BASE_URL}/admin/promotions`);
url.searchParams.set("fields", PROMOTION_FIELDS);
url.searchParams.set("type", "buyget");
url.searchParams.set("limit", "100");
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.promotions;
}
Decide, with one pure function
Keep the validation rule in its own function that takes an application_method and returns whether it is valid, plus the specific reasons it failed. Pure functions like this are easy to read and easy to test, which we do later. The rule is strict on purpose, matching exactly what the buyget engine actually requires: non-empty buy_rules, a positive buy_rules_min_quantity, non-empty target_rules, a target_type that is not "order", an allocation of "across" or "each", a set apply_to_quantity, and, for allocation: "each", a set max_quantity.
def is_buyget_application_method_valid(am):
reasons = []
if not (am.get("target_rules") or []):
reasons.append("target_rules is empty")
if not (am.get("buy_rules") or []):
reasons.append("buy_rules is empty")
min_qty = am.get("buy_rules_min_quantity")
if min_qty is None or min_qty <= 0:
reasons.append("buy_rules_min_quantity is missing or not positive")
if am.get("target_type") == "order":
reasons.append('target_type "order" is not supported for buyget')
if am.get("allocation") not in ("across", "each"):
reasons.append("allocation must be across or each")
if am.get("apply_to_quantity") is None:
reasons.append("apply_to_quantity is missing")
if am.get("allocation") == "each" and am.get("max_quantity") is None:
reasons.append("max_quantity is required when allocation is each")
return {"valid": len(reasons) == 0, "reasons": reasons}
export function isBuygetApplicationMethodValid(am) {
const reasons = [];
if (!(am.target_rules || []).length) reasons.push("target_rules is empty");
if (!(am.buy_rules || []).length) reasons.push("buy_rules is empty");
const minQty = am.buy_rules_min_quantity;
if (minQty === null || minQty === undefined || minQty <= 0) {
reasons.push("buy_rules_min_quantity is missing or not positive");
}
if (am.target_type === "order") reasons.push('target_type "order" is not supported for buyget');
if (!["across", "each"].includes(am.allocation)) reasons.push("allocation must be across or each");
if (am.apply_to_quantity === null || am.apply_to_quantity === undefined) {
reasons.push("apply_to_quantity is missing");
}
if (am.allocation === "each" && (am.max_quantity === null || am.max_quantity === undefined)) {
reasons.push("max_quantity is required when allocation is each");
}
return { valid: reasons.length === 0, reasons };
}
Build the corrected payload and patch, only when DRY_RUN is false
When a promotion is flagged, compute the corrected application_method: a supported target_type such as "items", an allocation of "across" or "each" with max_quantity set when needed, a set apply_to_quantity, and the existing non-empty buy_rules and target_rules. Only when DRY_RUN is false do we call POST /admin/promotions/:id with the nested application_method update, addressed by its own id.
def build_corrected_application_method(am):
allocation = am.get("allocation") if am.get("allocation") in ("across", "each") else "across"
corrected = {
"id": am.get("id"),
"target_type": "items",
"allocation": allocation,
"apply_to_quantity": am.get("apply_to_quantity") or am.get("buy_rules_min_quantity") or 1,
"target_rules": am.get("target_rules") or [],
"buy_rules": am.get("buy_rules") or [],
}
if allocation == "each":
corrected["max_quantity"] = am.get("max_quantity") or corrected["apply_to_quantity"]
return corrected
def patch_application_method(token, promotion_id, corrected):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/promotions/{promotion_id}",
json={"application_method": corrected},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["promotion"]
export function buildCorrectedApplicationMethod(am) {
const allocation = ["across", "each"].includes(am.allocation) ? am.allocation : "across";
const corrected = {
id: am.id,
target_type: "items",
allocation,
apply_to_quantity: am.apply_to_quantity || am.buy_rules_min_quantity || 1,
target_rules: am.target_rules || [],
buy_rules: am.buy_rules || [],
};
if (allocation === "each") {
corrected.max_quantity = am.max_quantity || corrected.apply_to_quantity;
}
return corrected;
}
async function patchApplicationMethod(token, promotionId, corrected) {
const res = await fetch(`${BASE_URL}/admin/promotions/${promotionId}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ application_method: corrected }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.promotion;
}
Force recomputation and verify, wired together with a dry run guard
After the patch, call the storefront cart promotions endpoint with the same codes already on the cart so Medusa recomputes through its own updateCartPromotionsWorkflow, then re-fetch the cart's items and their adjustments to confirm the promotion now actually produces one. In dry run, we only log the before and after diff and how many open carts reference the code, we never call a write endpoint.
Always start with DRY_RUN=true. It only lists the malformed application_method combinations and the corrected payload it would send, plus how many open carts reference the affected code. Only flip it to false after a human has reviewed the diff and agrees the corrected target_type, allocation, and quantities match what the promotion was actually supposed to do.
The full code
Here is the complete script in one file for each language. It authenticates, lists every buyget promotion, validates each application method with the pure rule, and, only when writing is explicitly enabled, patches the method and forces Medusa to recompute any cart referencing the code.
"""Flag and safely repair Medusa buyget promotions whose application_method
is structurally invalid, which makes computeActions silently return zero
adjustments on every cart update. Never rewrites a live promotion unless
DRY_RUN is explicitly false. Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_buyget_application_method")
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,type,status,is_automatic,*application_method,"
"*application_method.target_rules,*application_method.buy_rules"
)
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_buyget_promotions(token):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/promotions",
params={"fields": PROMOTION_FIELDS, "type": "buyget", "limit": 100},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["promotions"]
def is_buyget_application_method_valid(am):
"""Pure: no I/O. Returns valid=False with a reasons[] entry for each
unsupported combination the buyget engine cannot evaluate, mirroring
Medusa's own required shape for type: "buyget".
"""
reasons = []
if not (am.get("target_rules") or []):
reasons.append("target_rules is empty")
if not (am.get("buy_rules") or []):
reasons.append("buy_rules is empty")
min_qty = am.get("buy_rules_min_quantity")
if min_qty is None or min_qty <= 0:
reasons.append("buy_rules_min_quantity is missing or not positive")
if am.get("target_type") == "order":
reasons.append('target_type "order" is not supported for buyget')
if am.get("allocation") not in ("across", "each"):
reasons.append("allocation must be across or each")
if am.get("apply_to_quantity") is None:
reasons.append("apply_to_quantity is missing")
if am.get("allocation") == "each" and am.get("max_quantity") is None:
reasons.append("max_quantity is required when allocation is each")
return {"valid": len(reasons) == 0, "reasons": reasons}
def build_corrected_application_method(am):
allocation = am.get("allocation") if am.get("allocation") in ("across", "each") else "across"
corrected = {
"id": am.get("id"),
"target_type": "items",
"allocation": allocation,
"apply_to_quantity": am.get("apply_to_quantity") or am.get("buy_rules_min_quantity") or 1,
"target_rules": am.get("target_rules") or [],
"buy_rules": am.get("buy_rules") or [],
}
if allocation == "each":
corrected["max_quantity"] = am.get("max_quantity") or corrected["apply_to_quantity"]
return corrected
def patch_application_method(token, promotion_id, corrected):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/promotions/{promotion_id}",
json={"application_method": corrected},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["promotion"]
def find_open_carts_with_code(token, code):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/carts",
params={"fields": "id,*promotions,*items,*items.adjustments", "limit": 100},
headers=headers,
timeout=30,
)
r.raise_for_status()
carts = r.json().get("carts", [])
affected = []
for cart in carts:
codes = [p.get("code") for p in (cart.get("promotions") or [])]
if code not in codes:
continue
adjustment_ids = set()
for item in cart.get("items") or []:
for adj in item.get("adjustments") or []:
adjustment_ids.add(adj.get("promotion_id"))
affected.append({"cart_id": cart.get("id"), "has_adjustment": cart.get("id") is not None and any(a for a in adjustment_ids)})
return affected
def run():
token = get_token()
promotions = list_buyget_promotions(token)
flagged = 0
for promo in promotions:
am = promo.get("application_method") or {}
result = is_buyget_application_method_valid(am)
if result["valid"]:
continue
flagged += 1
corrected = build_corrected_application_method(am)
affected_carts = find_open_carts_with_code(token, promo.get("code"))
log.warning(
"Promotion %s (%s) invalid: %s. %d open cart(s) reference this code.",
promo.get("id"), promo.get("code"), "; ".join(result["reasons"]), len(affected_carts),
)
log.info("%s application_method diff: before=%s after=%s",
"Would apply" if DRY_RUN else "Applying", am, corrected)
if not DRY_RUN:
patch_application_method(token, promo["id"], corrected)
log.info("Patched promotion %s. Re-run cart promotions to verify adjustments.", promo["id"])
log.info("Done. %d buyget promotion(s) %s.", flagged, "flagged" if DRY_RUN else "flagged and repaired")
if __name__ == "__main__":
run()
/**
* Flag and safely repair Medusa buyget promotions whose application_method
* is structurally invalid, which makes computeActions silently return zero
* adjustments on every cart update. Never rewrites a live promotion unless
* DRY_RUN is explicitly false. Safe to run again and again.
*/
import { pathToFileURL } from "node:url";
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,type,status,is_automatic,*application_method," +
"*application_method.target_rules,*application_method.buy_rules";
/**
* Pure: no I/O. Returns valid=false with a reasons[] entry for each
* unsupported combination the buyget engine cannot evaluate, mirroring
* Medusa's own required shape for type: "buyget".
*/
export function isBuygetApplicationMethodValid(am) {
const reasons = [];
if (!(am.target_rules || []).length) reasons.push("target_rules is empty");
if (!(am.buy_rules || []).length) reasons.push("buy_rules is empty");
const minQty = am.buy_rules_min_quantity;
if (minQty === null || minQty === undefined || minQty <= 0) {
reasons.push("buy_rules_min_quantity is missing or not positive");
}
if (am.target_type === "order") reasons.push('target_type "order" is not supported for buyget');
if (!["across", "each"].includes(am.allocation)) reasons.push("allocation must be across or each");
if (am.apply_to_quantity === null || am.apply_to_quantity === undefined) {
reasons.push("apply_to_quantity is missing");
}
if (am.allocation === "each" && (am.max_quantity === null || am.max_quantity === undefined)) {
reasons.push("max_quantity is required when allocation is each");
}
return { valid: reasons.length === 0, reasons };
}
export function buildCorrectedApplicationMethod(am) {
const allocation = ["across", "each"].includes(am.allocation) ? am.allocation : "across";
const corrected = {
id: am.id,
target_type: "items",
allocation,
apply_to_quantity: am.apply_to_quantity || am.buy_rules_min_quantity || 1,
target_rules: am.target_rules || [],
buy_rules: am.buy_rules || [],
};
if (allocation === "each") {
corrected.max_quantity = am.max_quantity || corrected.apply_to_quantity;
}
return corrected;
}
async function getToken() {
const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
const body = await res.json();
return body.token;
}
async function listBuygetPromotions(token) {
const url = new URL(`${BASE_URL}/admin/promotions`);
url.searchParams.set("fields", PROMOTION_FIELDS);
url.searchParams.set("type", "buyget");
url.searchParams.set("limit", "100");
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.promotions;
}
async function patchApplicationMethod(token, promotionId, corrected) {
const res = await fetch(`${BASE_URL}/admin/promotions/${promotionId}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ application_method: corrected }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.promotion;
}
async function findOpenCartsWithCode(token, code) {
const url = new URL(`${BASE_URL}/admin/carts`);
url.searchParams.set("fields", "id,*promotions,*items,*items.adjustments");
url.searchParams.set("limit", "100");
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
const carts = body.carts || [];
return carts
.filter((cart) => (cart.promotions || []).some((p) => p.code === code))
.map((cart) => {
const hasAdjustment = (cart.items || []).some((item) => (item.adjustments || []).length > 0);
return { cartId: cart.id, hasAdjustment };
});
}
export async function run() {
const token = await getToken();
const promotions = await listBuygetPromotions(token);
let flagged = 0;
for (const promo of promotions) {
const am = promo.application_method || {};
const result = isBuygetApplicationMethodValid(am);
if (result.valid) continue;
flagged++;
const corrected = buildCorrectedApplicationMethod(am);
const affectedCarts = await findOpenCartsWithCode(token, promo.code);
console.warn(
`Promotion ${promo.id} (${promo.code}) invalid: ${result.reasons.join("; ")}. ${affectedCarts.length} open cart(s) reference this code.`
);
console.log(
`${DRY_RUN ? "Would apply" : "Applying"} application_method diff: before=${JSON.stringify(am)} after=${JSON.stringify(corrected)}`
);
if (!DRY_RUN) {
await patchApplicationMethod(token, promo.id, corrected);
console.log(`Patched promotion ${promo.id}. Re-run cart promotions to verify adjustments.`);
}
}
console.log(`Done. ${flagged} buyget promotion(s) ${DRY_RUN ? "flagged" : "flagged and repaired"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The function worth testing above everything else is the validator, because it decides whether a live promotion gets patched. Because isBuygetApplicationMethodValid is pure, the tests feed in plain application_method objects that match the issue's failing and passing payloads, no Medusa backend required.
from fix_buyget_application_method import (
is_buyget_application_method_valid,
build_corrected_application_method,
)
def valid_am(**over):
base = {
"id": "apmethod_1",
"target_type": "items",
"allocation": "across",
"apply_to_quantity": 1,
"max_quantity": None,
"buy_rules": [{"attribute": "items.product_id", "operator": "in", "values": ["prod_1"]}],
"target_rules": [{"attribute": "items.product_id", "operator": "in", "values": ["prod_2"]}],
"buy_rules_min_quantity": 2,
}
base.update(over)
return base
def test_valid_across_payload_passes():
result = is_buyget_application_method_valid(valid_am())
assert result == {"valid": True, "reasons": []}
def test_valid_each_payload_needs_max_quantity():
am = valid_am(allocation="each", max_quantity=1)
assert is_buyget_application_method_valid(am)["valid"] is True
def test_each_without_max_quantity_is_invalid():
am = valid_am(allocation="each", max_quantity=None)
result = is_buyget_application_method_valid(am)
assert result["valid"] is False
assert "max_quantity is required when allocation is each" in result["reasons"]
def test_target_type_order_is_invalid():
result = is_buyget_application_method_valid(valid_am(target_type="order"))
assert result["valid"] is False
assert any("target_type" in r for r in result["reasons"])
def test_empty_target_rules_is_invalid():
result = is_buyget_application_method_valid(valid_am(target_rules=[]))
assert result["valid"] is False
assert "target_rules is empty" in result["reasons"]
def test_empty_buy_rules_is_invalid():
result = is_buyget_application_method_valid(valid_am(buy_rules=[]))
assert result["valid"] is False
assert "buy_rules is empty" in result["reasons"]
def test_missing_buy_rules_min_quantity_is_invalid():
result = is_buyget_application_method_valid(valid_am(buy_rules_min_quantity=None))
assert result["valid"] is False
assert "buy_rules_min_quantity is missing or not positive" in result["reasons"]
def test_zero_buy_rules_min_quantity_is_invalid():
result = is_buyget_application_method_valid(valid_am(buy_rules_min_quantity=0))
assert result["valid"] is False
def test_bad_allocation_is_invalid():
result = is_buyget_application_method_valid(valid_am(allocation="whole_order"))
assert result["valid"] is False
assert "allocation must be across or each" in result["reasons"]
def test_missing_apply_to_quantity_is_invalid():
result = is_buyget_application_method_valid(valid_am(apply_to_quantity=None))
assert result["valid"] is False
assert "apply_to_quantity is missing" in result["reasons"]
def test_multiple_reasons_can_be_reported_together():
result = is_buyget_application_method_valid(
valid_am(target_type="order", target_rules=[], buy_rules=[])
)
assert result["valid"] is False
assert len(result["reasons"]) == 3
def test_build_corrected_application_method_fixes_target_type():
am = valid_am(target_type="order")
corrected = build_corrected_application_method(am)
assert corrected["target_type"] == "items"
assert corrected["id"] == "apmethod_1"
assert corrected["buy_rules"] == am["buy_rules"]
assert corrected["target_rules"] == am["target_rules"]
def test_build_corrected_application_method_fills_max_quantity_for_each():
am = valid_am(allocation="each", max_quantity=None, apply_to_quantity=3)
corrected = build_corrected_application_method(am)
assert corrected["allocation"] == "each"
assert corrected["max_quantity"] == 3
import { test } from "node:test";
import assert from "node:assert/strict";
import {
isBuygetApplicationMethodValid,
buildCorrectedApplicationMethod,
} from "./fix-buyget-application-method.js";
const validAm = (over = {}) => ({
id: "apmethod_1",
target_type: "items",
allocation: "across",
apply_to_quantity: 1,
max_quantity: null,
buy_rules: [{ attribute: "items.product_id", operator: "in", values: ["prod_1"] }],
target_rules: [{ attribute: "items.product_id", operator: "in", values: ["prod_2"] }],
buy_rules_min_quantity: 2,
...over,
});
test("valid across payload passes", () => {
const result = isBuygetApplicationMethodValid(validAm());
assert.deepEqual(result, { valid: true, reasons: [] });
});
test("valid each payload with max_quantity passes", () => {
const am = validAm({ allocation: "each", max_quantity: 1 });
assert.equal(isBuygetApplicationMethodValid(am).valid, true);
});
test("each without max_quantity is invalid", () => {
const am = validAm({ allocation: "each", max_quantity: null });
const result = isBuygetApplicationMethodValid(am);
assert.equal(result.valid, false);
assert.ok(result.reasons.includes("max_quantity is required when allocation is each"));
});
test("target_type order is invalid", () => {
const result = isBuygetApplicationMethodValid(validAm({ target_type: "order" }));
assert.equal(result.valid, false);
assert.ok(result.reasons.some((r) => r.includes("target_type")));
});
test("empty target_rules is invalid", () => {
const result = isBuygetApplicationMethodValid(validAm({ target_rules: [] }));
assert.equal(result.valid, false);
assert.ok(result.reasons.includes("target_rules is empty"));
});
test("empty buy_rules is invalid", () => {
const result = isBuygetApplicationMethodValid(validAm({ buy_rules: [] }));
assert.equal(result.valid, false);
assert.ok(result.reasons.includes("buy_rules is empty"));
});
test("missing buy_rules_min_quantity is invalid", () => {
const result = isBuygetApplicationMethodValid(validAm({ buy_rules_min_quantity: null }));
assert.equal(result.valid, false);
assert.ok(result.reasons.includes("buy_rules_min_quantity is missing or not positive"));
});
test("zero buy_rules_min_quantity is invalid", () => {
const result = isBuygetApplicationMethodValid(validAm({ buy_rules_min_quantity: 0 }));
assert.equal(result.valid, false);
});
test("bad allocation is invalid", () => {
const result = isBuygetApplicationMethodValid(validAm({ allocation: "whole_order" }));
assert.equal(result.valid, false);
assert.ok(result.reasons.includes("allocation must be across or each"));
});
test("missing apply_to_quantity is invalid", () => {
const result = isBuygetApplicationMethodValid(validAm({ apply_to_quantity: null }));
assert.equal(result.valid, false);
assert.ok(result.reasons.includes("apply_to_quantity is missing"));
});
test("multiple reasons can be reported together", () => {
const result = isBuygetApplicationMethodValid(
validAm({ target_type: "order", target_rules: [], buy_rules: [] })
);
assert.equal(result.valid, false);
assert.equal(result.reasons.length, 3);
});
test("buildCorrectedApplicationMethod fixes target_type", () => {
const am = validAm({ target_type: "order" });
const corrected = buildCorrectedApplicationMethod(am);
assert.equal(corrected.target_type, "items");
assert.equal(corrected.id, "apmethod_1");
assert.deepEqual(corrected.buy_rules, am.buy_rules);
assert.deepEqual(corrected.target_rules, am.target_rules);
});
test("buildCorrectedApplicationMethod fills max_quantity for each", () => {
const am = validAm({ allocation: "each", max_quantity: null, apply_to_quantity: 3 });
const corrected = buildCorrectedApplicationMethod(am);
assert.equal(corrected.allocation, "each");
assert.equal(corrected.max_quantity, 3);
});
Case studies
The "buy two shirts, get 10% off the order" that never fired
A store built a buyget promotion meant to give 10% off the entire order once a customer added two shirts to the cart. It saved without complaint, showed active in the admin, and the code applied cleanly to test carts. But no matter how many shirts customers added, the total never moved, and support had no idea why since nothing in the logs looked wrong.
Running the audit found the promotion's application_method.target_type was set to "order", a shape the create endpoint accepted but the buyget engine cannot resolve to a line item. Patching it to target_type: "items" with the existing target_rules pointed at the intended discounted product let computeActions produce a real adjustment on the very next cart update.
The free-item promo that silently capped at nothing
A merchandising team set up "buy three, get one free" with allocation: "each" so the discount would apply once per matching unit rather than across the whole order. They never set max_quantity, assuming it was optional since the admin form let them save without it.
The flag script caught the missing max_quantity immediately, since allocation: "each" with no cap gives the engine nothing to bound the discount to. Setting max_quantity to match the intended one free unit, then re-triggering POST /store/carts/:id/promotions, produced the expected adjustment and the promotion has worked on every cart update since.
Run this audit any time a buyget promotion is active, attached to a cart, and still produces no discount. It never rewrites a live promotion on its own. It reports the exact unsupported combination, the corrected payload, and how many open carts are affected, and only writes once a human sets DRY_RUN=false. After the patch, Medusa's own updateCartPromotionsWorkflow recomputes the discount the normal way, so you are never trusting a manually injected adjustment.
FAQ
Why does my Medusa buyget promotion stop applying after a cart update?
A buyget promotion needs application_method.buy_rules for the buy side and application_method.target_rules for the get side, plus a target_type, allocation, apply_to_quantity, and max_quantity combination the buyget engine actually supports. Medusa's create validation accepts several combinations that look reasonable, such as target_type set to order, but they are not valid for type buyget. The promotion saves and shows in the admin, but every time updateCartPromotionsWorkflow re-fetches the cart and calls computeActions, the rule engine finds no valid target to discount and returns zero actions with no error, so cart.promotions still lists the code but no adjustment ever appears.
Why does Medusa not raise an error when a buyget promotion cannot compute any actions?
computeActions is designed to return an empty action list whenever a promotion's rules do not currently match, and that is a normal, valid outcome, for example a cart that has not yet reached the buy quantity. A structurally incomplete application_method looks identical from the outside: zero actions, no thrown error, a 200 response. Medusa cannot distinguish a promotion that is correctly waiting for more cart quantity from one that can never produce a valid target no matter what the cart contains, so it never surfaces the difference.
Is it safe to auto-fix a broken buyget application_method on a live store?
No, not blindly. Rewriting target_type, allocation, or the rule lists changes what a live promotion discounts, and forcing computeActions results onto a cart could misprice an order. The safe pattern is to detect the malformed combination, compute the corrected application_method payload, and only write it when a human has set DRY_RUN to false, then force Medusa to recompute the cart through its own POST /store/carts/:id/promotions endpoint so the adjustments come from Medusa's own engine, not a manual injection.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub issue #12059: Promotions of type buyget "Buy two shirts and get 10% off the entire order" are not applied in the update-cart-promotions workflow. github.com/medusajs/medusa/issues/12059
- medusajs/medusa GitHub issue #8829: bug, Promotions, Buy X get Y. github.com/medusajs/medusa/issues/8829
- medusajs/medusa GitHub issue #11258: Cannot apply multiple buyxgety promotions for a single product. github.com/medusajs/medusa/issues/11258
On the solution:
- Medusa Documentation: the Application Method data model for the Promotion module. docs.medusajs.com/resources/commerce-modules/promotion/application-method
- Medusa Documentation: Promotion module concepts, including buy_rules, target_rules, and computeActions. docs.medusajs.com/resources/commerce-modules/promotion/concepts
- Medusa Documentation: Promotion actions and how computeActions produces cart adjustments. docs.medusajs.com/resources/commerce-modules/promotion/actions
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 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