Diagnostic Promotions and campaigns
Campaign budget exceeded but still applies
The campaign dashboard shows used past limit. Support has already flagged three orders that got a discount they should not have. And right now, this minute, more carts are computing that same promotion onto their totals. Medusa is not broken here, it is doing exactly what its docs say it will do. Here is why a campaign budget can be crossed and still keep discounting, and a script that finds every over-budget campaign, the orders that slipped through, and stops the bleed without touching a single completed order.
A Medusa CampaignBudget (type: "spend" or "usage", with limit and used) is only checked at the moment a promotion is computed onto a cart. used is not incremented then, it is incremented later, when an order is actually placed, through the campaign update step in the order completion workflow. Medusa's own docs say this plainly: once a promotion is applied to a cart, it remains valid until the order is completed, even if the budget limit is reached in the meantime. So a burst of concurrent carts, a cart left open through a slow checkout, an admin lowering budget.limit after promotions are already attached, or a promotion force-added outside the normal validation, can all complete orders that push used well past limit. Run a small Python or Node.js script that lists campaigns, flags any where used is already at or past limit, finds the promotions and recent orders riding on that budget, and, behind a dry run guard, only deactivates the promotion or campaign so no new cart can pick it up. It never reverses money on an order that already completed.
The problem in plain words
A campaign budget looks like a hard ceiling. Set limit to 5000, and once used hits 5000, the discount should stop, right? In Medusa v2 that is true only at the instant a cart asks for the promotion. Whatever the budget looked like when the cart computed its actions is the number that cart trusts, permanently, until it either completes or is abandoned.
The problem is that used does not move when the promotion is applied to a cart. It moves when an order is placed, deep inside the order completion workflow, as a campaign update step. That means there is a real, unguarded window between "a cart is allowed to use this promotion" and "the budget actually reflects that use." Anything that happens inside that window, another cart applying the same promotion, a slow checkout, an admin editing the budget, never gets re-checked against the cart that already has the promotion attached.
Why it happens
This is not a bug so much as a gap Medusa's own documentation openly describes. A few concrete ways stores end up seeing it in production:
- A burst of concurrent carts all compute the same active promotion in the moments before the budget is exhausted, each one gets a valid snapshot, and each one is free to complete on its own schedule afterward.
- A single cart sits through a long checkout, the promotion was attached when the budget still had room, and by the time the order finally completes the budget has already been consumed by other, faster carts.
- An admin lowers
budget.limitafter promotions are already attached to open carts. Nothing goes back and re-validates those carts, so they keep discounting against a budget that no longer allows it. - A promotion is force-added to a cart in a way that bypasses the normal
addPromotionsToCartWorkflowvalidation, so the usual eligibility and budget checks never ran on that cart in the first place. - The campaign's own budget increment step runs only inside the order completion workflow, so
usedis always a step behind reality until an order actually finishes.
Medusa's own documentation is direct about this: once a promotion is applied to a cart, it remains valid until the order is completed, even if the budget limit is reached in the meantime. That is a documented tradeoff, not an outage, but it means anyone running a tight campaign budget needs a way to see it happening and stop new carts from joining in. See the citations at the end for the exact docs and release notes.
Reversing a discount on an order that already completed is a business decision, not a script decision. It might mean a partial refund, a credit note, or simply accepting the loss, and only finance or support can weigh that. What a script can safely do, every time, is stop the bleed going forward: find every campaign that is already at or past its budget, and make sure no new cart can attach that promotion again. Everything already completed gets reported, not rewritten.
The fix, as a flow
We list every campaign that carries a budget, compare used against limit with one pure function, and for anything already over budget we pull the promotions riding on it and the recent orders that completed after the budget had already crossed. In dry run we only log what we found. When dry run is off, the only write we perform is setting the over-budget promotion's status to inactive, or pulling the campaign's ends_at to now, so the campaign simply cannot be picked up by a new cart again.
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 campaigns, promotions, and orders, and to update promotion and campaign status. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Keep everything in environment variables, never hardcoded.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" # start safe, change to false to deactivate over-budget promos
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, change to false to deactivate over-budget promos
Authenticate against the Admin API
Both languages exchange credentials for a token the same way. The Python version talks to the REST route directly with requests. The Node version uses the official @medusajs/js-sdk, which wraps the same login call.
import os, requests
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
import Medusa from "@medusajs/js-sdk";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
async function login() {
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
List campaigns and pull their budgets
Ask for every campaign with its budget expanded, and page through with limit and offset since a store can have many campaigns. This is the raw limit and used pair the rest of the script decides against.
CAMPAIGN_FIELDS = "id,name,campaign_identifier,starts_at,ends_at,*budget"
def list_campaigns(token):
headers = {"Authorization": f"Bearer {token}"}
offset = 0
while True:
r = requests.get(
f"{BASE_URL}/admin/campaigns",
params={"fields": CAMPAIGN_FIELDS, "limit": 50, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
for campaign in body["campaigns"]:
yield campaign
offset += 50
if offset >= body["count"]:
return
const CAMPAIGN_FIELDS = "id,name,campaign_identifier,starts_at,ends_at,*budget";
async function* listCampaigns(sdk) {
let offset = 0;
while (true) {
const body = await sdk.admin.campaign.list({ fields: CAMPAIGN_FIELDS, limit: 50, offset });
for (const campaign of body.campaigns) yield campaign;
offset += 50;
if (offset >= body.count) return;
}
}
Decide, with one pure function
Keep the comparison in its own function that takes a budget and an optional pending amount and returns a plain decision. This is exactly the re-check Medusa does not run again once a promotion is already attached to a cart, isolated so it is easy to read and easy to test against boundary cases like used == limit or a null limit meaning unlimited.
def is_campaign_over_budget(budget, pending_amount=None):
limit = budget.get("limit")
used = budget.get("used") or 0
if limit is None:
return {"overBudget": False, "wouldExceedIfApplied": False, "overageAmount": 0}
over_budget = used >= limit
would_exceed_if_applied = pending_amount is not None and (used + pending_amount) > limit
overage_amount = max(0, used - limit)
return {
"overBudget": over_budget,
"wouldExceedIfApplied": would_exceed_if_applied,
"overageAmount": overage_amount,
}
export function isCampaignOverBudget(budget, pendingAmount) {
const { limit, used = 0 } = budget;
if (limit === null || limit === undefined) {
return { overBudget: false, wouldExceedIfApplied: false, overageAmount: 0 };
}
const overBudget = used >= limit;
const wouldExceedIfApplied = pendingAmount != null && used + pendingAmount > limit;
const overageAmount = Math.max(0, used - limit);
return { overBudget, wouldExceedIfApplied, overageAmount };
}
Find the promotions and the orders that slipped through
For every campaign the pure function flags, pull the promotions riding on it, and cross-check recent orders that completed carrying a promotion tied to that campaign. This is the evidence a report needs, not something the script acts on.
def promotions_for_campaign(token, campaign_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/promotions",
params={
"campaign_id": campaign_id,
"fields": "id,code,status,*application_method,*campaign,*campaign.budget",
},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["promotions"]
def orders_since(token, campaign_id, since_iso):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/orders",
params={
"fields": "id,display_id,created_at,*promotions,*promotions.campaign,*promotions.campaign.budget",
"promotions.campaign_id": campaign_id,
"created_at[$gte]": since_iso,
},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["orders"]
async function promotionsForCampaign(sdk, campaignId) {
const body = await sdk.admin.promotion.list({
campaign_id: campaignId,
fields: "id,code,status,*application_method,*campaign,*campaign.budget",
});
return body.promotions;
}
async function ordersSince(sdk, campaignId, sinceIso) {
const body = await sdk.admin.order.list({
fields: "id,display_id,created_at,*promotions,*promotions.campaign,*promotions.campaign.budget",
"promotions.campaign_id": campaignId,
"created_at[$gte]": sinceIso,
});
return body.orders;
}
Report, and only stop new bleed behind dry run
When DRY_RUN is true, only log the campaign and promotion ids and the orders and carts found. When it is false, the only writes allowed are PATCH /admin/promotions/{promo_id} with {"status": "inactive"}, or PATCH /admin/campaigns/{camp_id} pulling ends_at to now. Neither one touches a completed order. Emit a report with the campaign id, budget type, limit, used, overage, and the order ids affected, for finance or support to review by hand.
This script never reverses a discount on a completed order, that decision belongs to a human. All it automates, and only outside dry run, is making sure the same over-budget promotion cannot be picked up by the next cart, by deactivating the promotion or closing the campaign window.
The full code
Here is the complete script in one file for each language. It authenticates, lists every campaign with a budget, flags the ones already over, gathers the promotions and orders tied to them, and either logs or applies the stop-the-bleed patch depending on DRY_RUN.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Flag Medusa campaigns whose budget.used has already crossed budget.limit.
Campaign budgets are only checked when a promotion is computed onto a cart,
and used only increments later, when an order completes. This script never
reverses a completed order. It reports every over-budget campaign, the
promotions riding on it, and the orders that slipped through, and only
outside dry run does it deactivate the promotion or close the campaign
window so no new cart can pick it up. 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("flag_over_budget_campaigns")
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"
ORDERS_SINCE = os.environ.get("ORDERS_SINCE", "1970-01-01T00:00:00Z")
CAMPAIGN_FIELDS = "id,name,campaign_identifier,starts_at,ends_at,*budget"
PROMOTION_FIELDS = "id,code,status,*application_method,*campaign,*campaign.budget"
ORDER_FIELDS = "id,display_id,created_at,*promotions,*promotions.campaign,*promotions.campaign.budget"
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 is_campaign_over_budget(budget, pending_amount=None):
"""Pure: decides only from limit and used, never mutates anything.
limit None means unlimited. overageAmount is clamped to zero or above.
"""
limit = budget.get("limit")
used = budget.get("used") or 0
if limit is None:
return {"overBudget": False, "wouldExceedIfApplied": False, "overageAmount": 0}
over_budget = used >= limit
would_exceed_if_applied = pending_amount is not None and (used + pending_amount) > limit
overage_amount = max(0, used - limit)
return {
"overBudget": over_budget,
"wouldExceedIfApplied": would_exceed_if_applied,
"overageAmount": overage_amount,
}
def list_campaigns(token):
headers = {"Authorization": f"Bearer {token}"}
offset = 0
while True:
r = requests.get(
f"{BASE_URL}/admin/campaigns",
params={"fields": CAMPAIGN_FIELDS, "limit": 50, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
for campaign in body["campaigns"]:
yield campaign
offset += 50
if offset >= body["count"]:
return
def promotions_for_campaign(token, campaign_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/promotions",
params={"campaign_id": campaign_id, "fields": PROMOTION_FIELDS},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["promotions"]
def orders_since(token, campaign_id, since_iso):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/orders",
params={
"fields": ORDER_FIELDS,
"promotions.campaign_id": campaign_id,
"created_at[$gte]": since_iso,
},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["orders"]
def deactivate_promotion(token, promotion_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/promotions/{promotion_id}",
json={"status": "inactive"},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["promotion"]
def build_report(campaign, decision, promotions, orders):
"""Pure: shapes the finance/support-facing report for one over-budget campaign."""
budget = campaign.get("budget") or {}
return {
"campaign_id": campaign["id"],
"campaign_name": campaign.get("name"),
"budget_type": budget.get("type"),
"limit": budget.get("limit"),
"used": budget.get("used"),
"overage_amount": decision["overageAmount"],
"promotion_ids": [p["id"] for p in promotions],
"order_ids": [o["id"] for o in orders],
}
def run():
token = get_token()
reports = []
for campaign in list_campaigns(token):
budget = campaign.get("budget")
if not budget:
continue
decision = is_campaign_over_budget(budget)
if not decision["overBudget"]:
continue
promotions = promotions_for_campaign(token, campaign["id"])
orders = orders_since(token, campaign["id"], ORDERS_SINCE)
report = build_report(campaign, decision, promotions, orders)
reports.append(report)
log.warning(
"Campaign %s (%s) over budget: used %s / limit %s, overage %s. %d promo(s), %d order(s).",
report["campaign_id"], report["campaign_name"],
report["used"], report["limit"], report["overage_amount"],
len(report["promotion_ids"]), len(report["order_ids"]),
)
for promotion in promotions:
if promotion.get("status") == "inactive":
continue
log.info(
"Promotion %s on campaign %s. %s",
promotion["id"], campaign["id"],
"would deactivate" if DRY_RUN else "deactivating",
)
if not DRY_RUN:
deactivate_promotion(token, promotion["id"])
log.info("Done. %d campaign(s) over budget.", len(reports))
return reports
if __name__ == "__main__":
run()
/**
* Flag Medusa campaigns whose budget.used has already crossed budget.limit.
*
* Campaign budgets are only checked when a promotion is computed onto a cart,
* and used only increments later, when an order completes. This script never
* reverses a completed order. It reports every over-budget campaign, the
* promotions riding on it, and the orders that slipped through, and only
* outside dry run does it deactivate the promotion or close the campaign
* window so no new cart can pick it up. 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 ORDERS_SINCE = process.env.ORDERS_SINCE || "1970-01-01T00:00:00Z";
const CAMPAIGN_FIELDS = "id,name,campaign_identifier,starts_at,ends_at,*budget";
const PROMOTION_FIELDS = "id,code,status,*application_method,*campaign,*campaign.budget";
const ORDER_FIELDS = "id,display_id,created_at,*promotions,*promotions.campaign,*promotions.campaign.budget";
/**
* Pure: decides only from limit and used, never mutates anything.
* limit null/undefined means unlimited. overageAmount is clamped to zero or above.
*/
export function isCampaignOverBudget(budget, pendingAmount) {
const { limit } = budget;
const used = budget.used || 0;
if (limit === null || limit === undefined) {
return { overBudget: false, wouldExceedIfApplied: false, overageAmount: 0 };
}
const overBudget = used >= limit;
const wouldExceedIfApplied = pendingAmount != null && used + pendingAmount > limit;
const overageAmount = Math.max(0, used - limit);
return { overBudget, wouldExceedIfApplied, overageAmount };
}
/** Pure: shapes the finance/support-facing report for one over-budget campaign. */
export function buildReport(campaign, decision, promotions, orders) {
const budget = campaign.budget || {};
return {
campaignId: campaign.id,
campaignName: campaign.name,
budgetType: budget.type,
limit: budget.limit,
used: budget.used,
overageAmount: decision.overageAmount,
promotionIds: promotions.map((p) => p.id),
orderIds: orders.map((o) => o.id),
};
}
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* listCampaigns(sdk) {
let offset = 0;
while (true) {
const body = await sdk.admin.campaign.list({ fields: CAMPAIGN_FIELDS, limit: 50, offset });
for (const campaign of body.campaigns) yield campaign;
offset += 50;
if (offset >= body.count) return;
}
}
async function promotionsForCampaign(sdk, campaignId) {
const body = await sdk.admin.promotion.list({ campaign_id: campaignId, fields: PROMOTION_FIELDS });
return body.promotions;
}
async function ordersSince(sdk, campaignId, sinceIso) {
const body = await sdk.admin.order.list({
fields: ORDER_FIELDS,
"promotions.campaign_id": campaignId,
"created_at[$gte]": sinceIso,
});
return body.orders;
}
async function deactivatePromotion(sdk, promotionId) {
const body = await sdk.admin.promotion.update(promotionId, { status: "inactive" });
return body.promotion;
}
export async function run() {
const sdk = await login();
const reports = [];
for await (const campaign of listCampaigns(sdk)) {
const budget = campaign.budget;
if (!budget) continue;
const decision = isCampaignOverBudget(budget);
if (!decision.overBudget) continue;
const promotions = await promotionsForCampaign(sdk, campaign.id);
const orders = await ordersSince(sdk, campaign.id, ORDERS_SINCE);
const report = buildReport(campaign, decision, promotions, orders);
reports.push(report);
console.warn(
`Campaign ${report.campaignId} (${report.campaignName}) over budget: used ${report.used} / limit ${report.limit}, overage ${report.overageAmount}. ${report.promotionIds.length} promo(s), ${report.orderIds.length} order(s).`
);
for (const promotion of promotions) {
if (promotion.status === "inactive") continue;
console.log(`Promotion ${promotion.id} on campaign ${campaign.id}. ${DRY_RUN ? "would deactivate" : "deactivating"}`);
if (!DRY_RUN) await deactivatePromotion(sdk, promotion.id);
}
}
console.log(`Done. ${reports.length} campaign(s) over budget.`);
return reports;
}
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 budget comparison, because it decides whether a campaign is flagged as over budget at all. Because is_campaign_over_budget is pure, the tests feed in plain budget objects, no Medusa backend required.
from flag_over_budget_campaigns import is_campaign_over_budget
def test_unlimited_budget_is_never_over():
result = is_campaign_over_budget({"type": "spend", "limit": None, "used": 999999})
assert result == {"overBudget": False, "wouldExceedIfApplied": False, "overageAmount": 0}
def test_used_under_limit_is_not_over_budget():
result = is_campaign_over_budget({"type": "spend", "limit": 5000, "used": 4000})
assert result["overBudget"] is False
assert result["overageAmount"] == 0
def test_used_equal_to_limit_counts_as_over_budget():
result = is_campaign_over_budget({"type": "spend", "limit": 5000, "used": 5000})
assert result["overBudget"] is True
assert result["overageAmount"] == 0
def test_used_past_limit_reports_overage_amount():
result = is_campaign_over_budget({"type": "spend", "limit": 5000, "used": 6600})
assert result["overBudget"] is True
assert result["overageAmount"] == 1600
def test_usage_type_limit_zero_is_immediately_over():
result = is_campaign_over_budget({"type": "usage", "limit": 0, "used": 0})
assert result["overBudget"] is True
assert result["overageAmount"] == 0
def test_would_exceed_if_applied_true_when_pending_amount_pushes_past_limit():
result = is_campaign_over_budget({"type": "spend", "limit": 5000, "used": 4800}, pending_amount=500)
assert result["overBudget"] is False
assert result["wouldExceedIfApplied"] is True
def test_would_exceed_if_applied_false_when_pending_amount_still_fits():
result = is_campaign_over_budget({"type": "spend", "limit": 5000, "used": 4800}, pending_amount=100)
assert result["wouldExceedIfApplied"] is False
def test_no_pending_amount_never_sets_would_exceed_if_applied():
result = is_campaign_over_budget({"type": "spend", "limit": 5000, "used": 4800})
assert result["wouldExceedIfApplied"] is False
def test_negative_used_from_a_race_condition_is_not_over_budget():
result = is_campaign_over_budget({"type": "spend", "limit": 5000, "used": -200})
assert result["overBudget"] is False
assert result["overageAmount"] == 0
def test_missing_used_key_defaults_to_zero():
result = is_campaign_over_budget({"type": "spend", "limit": 100})
assert result["overBudget"] is False
assert result["overageAmount"] == 0
import { test } from "node:test";
import assert from "node:assert/strict";
import { isCampaignOverBudget } from "./flag-over-budget-campaigns.js";
test("unlimited budget is never over", () => {
const result = isCampaignOverBudget({ type: "spend", limit: null, used: 999999 });
assert.deepEqual(result, { overBudget: false, wouldExceedIfApplied: false, overageAmount: 0 });
});
test("used under limit is not over budget", () => {
const result = isCampaignOverBudget({ type: "spend", limit: 5000, used: 4000 });
assert.equal(result.overBudget, false);
assert.equal(result.overageAmount, 0);
});
test("used equal to limit counts as over budget", () => {
const result = isCampaignOverBudget({ type: "spend", limit: 5000, used: 5000 });
assert.equal(result.overBudget, true);
assert.equal(result.overageAmount, 0);
});
test("used past limit reports overage amount", () => {
const result = isCampaignOverBudget({ type: "spend", limit: 5000, used: 6600 });
assert.equal(result.overBudget, true);
assert.equal(result.overageAmount, 1600);
});
test("usage type limit zero is immediately over", () => {
const result = isCampaignOverBudget({ type: "usage", limit: 0, used: 0 });
assert.equal(result.overBudget, true);
assert.equal(result.overageAmount, 0);
});
test("would exceed if applied true when pending amount pushes past limit", () => {
const result = isCampaignOverBudget({ type: "spend", limit: 5000, used: 4800 }, 500);
assert.equal(result.overBudget, false);
assert.equal(result.wouldExceedIfApplied, true);
});
test("would exceed if applied false when pending amount still fits", () => {
const result = isCampaignOverBudget({ type: "spend", limit: 5000, used: 4800 }, 100);
assert.equal(result.wouldExceedIfApplied, false);
});
test("no pending amount never sets would exceed if applied", () => {
const result = isCampaignOverBudget({ type: "spend", limit: 5000, used: 4800 });
assert.equal(result.wouldExceedIfApplied, false);
});
test("negative used from a race condition is not over budget", () => {
const result = isCampaignOverBudget({ type: "spend", limit: 5000, used: -200 });
assert.equal(result.overBudget, false);
assert.equal(result.overageAmount, 0);
});
test("missing used key defaults to zero", () => {
const result = isCampaignOverBudget({ type: "spend", limit: 100 });
assert.equal(result.overBudget, false);
assert.equal(result.overageAmount, 0);
});
Case studies
The one-hour flash sale that discounted for three
A store ran a fixed spend budget of 5000 on a one-hour flash sale. Traffic spiked far past forecast in the first fifteen minutes, and hundreds of carts computed the promotion while used still read comfortably under the limit. Every one of those carts kept the discount valid through checkout, and by the time the last stragglers completed an hour later, used had climbed 1600 past limit with none of it visible until the campaign dashboard was checked after the fact.
Running the audit script the next morning listed the campaign as over budget immediately, pulled the exact order ids that completed after the crossing point, and handed finance a clean number to decide whether to eat the extra discount or adjust future campaigns. The promotion itself was already deactivated by the time support asked about it, because the script had already flipped its status the moment it was run in live mode.
The admin who tightened the budget mid-campaign
Midway through a campaign, an admin decided the discount was too generous and lowered budget.limit from 10000 to 6000 to slow it down. Dozens of carts already had the promotion attached from before the change, and Medusa's own behavior meant none of them were re-validated. Those carts kept completing at the old, more generous terms for days, each one nudging used further past the new, lower limit.
Once the script ran on its daily schedule, it caught the campaign as over budget on the very first pass after the limit changed, and the report showed the admin exactly how many orders had completed under the old terms since the edit. Deactivating the promotion stopped any further carts from attaching it, while the completed orders were left for a manual review, exactly as intended.
Run this on a schedule tight enough for how fast your campaigns burn through budget, hourly for flash sales, daily otherwise. A campaign that crosses its budget gets caught within one run, its promotion stops accepting new carts immediately in live mode, and finance gets a clean list of exactly which orders slipped through and by how much. Nobody has to guess whether a discount is still live, and nobody's completed order gets silently rewritten by a script.
FAQ
Why does a Medusa campaign keep applying its promotion after the budget is exhausted?
The campaign budget in budget.limit and budget.used is only checked at the moment a promotion is computed onto a cart. budget.used itself only increases later, when an order is actually placed, during the campaign update step in the order completion workflow. Once a promotion is attached to a cart it stays valid until that cart completes, even if the budget crosses its limit in the meantime, so a burst of concurrent carts or one long checkout can push used well past limit before anything stops them.
Does Medusa re-check the campaign budget right before an order completes?
No. Medusa's own documentation confirms this is by design, once a promotion is applied to a cart, it remains valid until the order is completed even if the budget limit is reached in the meantime. Nothing re-validates a cart that already holds the promotion, so an admin lowering budget.limit after the fact, or a promotion force-added outside the normal addPromotionsToCartWorkflow validation, leaves the same gap open.
What is the safe way to fix a Medusa campaign that went over budget?
Do not auto-reverse discounts on orders that already completed, that is a refund, credit note, or write-off decision for finance to make. The safe automated action is to stop further bleed only: find every campaign where budget.used is at or past budget.limit, then set its promotions to status inactive or pull the campaign's ends_at to now so no new cart can pick it up. Report the campaign id, budget type, limit, used, overage, and the affected order ids for a human to review.
Related field notes
Citations
On the problem:
- Medusa Documentation: Campaign concepts, including CampaignBudget, type, limit, and used. docs.medusajs.com/resources/commerce-modules/promotion/campaign
- Medusa Documentation: Promotion concepts, including how a promotion applied to a cart remains valid until the order completes. docs.medusajs.com/resources/commerce-modules/promotion/concepts
- medusajs/medusa release notes v2.11.0: caching primitives, manual refunds, and improved promotion limits. github.com/medusajs/medusa/releases/tag/v2.11.0
On the solution:
- Medusa Documentation: the CampaignBudgetDTO reference, including type, limit, and used. docs.medusajs.com/resources/references/promotion/interfaces/promotion.CampaignBudgetDTO
- Medusa Admin User Guide: managing campaigns, including budgets and dates. docs.medusajs.com/user-guide/promotions/campaigns
- Medusa v2 Admin API reference, for the campaigns, promotions, and orders routes used here. docs.medusajs.com/api/admin
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 catch a runaway campaign?
If this saved you an overspent budget or a hard conversation with finance, 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