Skip to content

Reconciler Pricing & Promotions

Campaign budget usage never increments for Buy X Get Y

The campaign has a spend cap or a usage cap, it is tied to a Buy X Get Y promotion, and the admin dashboard keeps showing budget.used at 0 no matter how many orders redeem it. Nothing is throwing an error. The counter is just never being told anything happened. Here is why buyget promotions slip past Medusa's own usage accounting, and a script that recomputes real usage from your orders and tells you exactly which campaigns have quietly gone over budget.

Python and Node.js Medusa Admin API Report by default, sync guarded by DRY_RUN
A sale sign on a building
Photo by Aleksi Partanen on Unsplash
The short answer

In Medusa v2, a campaign's budget.used is meant to be incremented by the promotion workflows whenever an order redeems a promotion tied to that campaign. For standard percentage and fixed promotions this happens reliably. For buyget (Buy X Get Y) promotions, core maintainer investigation on GitHub issue #8829 found that the usage-accounting step in the computeActions and adjustment pipeline does not reliably emit or persist the corresponding usage update, so budget.used can stay at 0 or stale while real redemptions keep happening. Run a small Python or Node.js script that lists campaigns with a budget tied to a buyget promotion, recomputes the real usage from actual orders, and reports any campaign where the recomputed number disagrees with the stored one or has already crossed the limit. Full code, tests, and a dry run guard are below.

The problem in plain words

A Medusa campaign can carry a budget with a type of spend or usage and a limit. The idea is simple: every time an order redeems a promotion tied to that campaign, Medusa should add to budget.used, and once used reaches limit the promotion should stop being offered. That part works for ordinary percentage and fixed amount promotions.

Buy X Get Y promotions are handled differently under the hood. They add a free or discounted line item instead of a flat percentage or amount off, and that path through computeActions and the cart adjustment pipeline does not reliably produce the same usage-update action that percentage and fixed promotions emit on the way to the order. The result is a campaign whose budget.used is only ever touched by that apply path, never derived from a count of real redemptions, so it can sit at 0 or an old number forever while the promotion keeps firing on every eligible checkout.

Order placed buyget promotion applied free item added to cart computeActions usage-update action not emitted for buyget no error raised budget.used stays 0 or stale Limit silently passed
The order redeems fine and the free item shows up in the cart. The campaign just never hears about it, so its budget counter never moves.

Why it happens

The Promotion module treats budget.used as a value it recomputes on the promotion's own apply path, not as a number derived independently from real redemptions. A few concrete ways this shows up:

This is a common source of confusion because store owners assume a budget cap on a campaign is a hard limit enforced the same way regardless of promotion type. See the citations at the end for the exact GitHub issues and docs.

The key insight

Retroactively rewriting budget.used can misstate history, and it does nothing to stop a promotion that already fired past its cap. So the safe pattern is not "quietly patch the number." It is "recompute the true usage from real orders, report every campaign where the stored and recomputed numbers disagree, and only sync the counter, or flag the promotion for deactivation, when a human says so."

The fix, as a flow

We list campaigns that have a budget and are tied to a buyget promotion, pull the real orders that redeemed those promotions, recompute what usage should actually be, and compare it against what is stored. Anything that disagrees gets reported. Anything that has actually crossed the limit gets flagged as over budget. Only when a human sets DRY_RUN=false does the script sync the counter, and it never deactivates a live promotion on its own.

List campaigns budget + buyget promotion Pull real orders by promotion id Recompute usage count or sum adjustments Matches stored used? no yes, nothing to report Report row, sync only if DRY_RUN=false
The script only reports by default. It never rewrites budget.used or deactivates a promotion unless DRY_RUN is explicitly turned off by a human.

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 campaigns, promotions, and orders. 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, report only until you flip this
setup (shell)
// 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, report only until you flip this
2

Authenticate against the Admin API

Exchange the admin email and password for a JWT once and reuse it on every call. A small helper wraps fetch or requests and sends the token as a Bearer header.

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
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;
}
3

List campaigns with a budget tied to a buyget promotion

Ask for every campaign with its budget and its linked promotions. Keep only campaigns whose budget.limit is set and whose linked promotions include one of type buyget, since that is the exact combination the maintainer investigation on GitHub issue #8829 points at.

step3.py
CAMPAIGN_FIELDS = "id,name,campaign_identifier,starts_at,ends_at,*budget,*promotions"

def list_campaigns(token):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/campaigns",
        params={"fields": CAMPAIGN_FIELDS, "limit": 200},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["campaigns"]

def is_buyget_budget_campaign(campaign):
    budget = campaign.get("budget") or {}
    if not budget.get("limit"):
        return False
    promotions = campaign.get("promotions") or []
    return any(p.get("type") == "buyget" for p in promotions)
step3.js
const CAMPAIGN_FIELDS = "id,name,campaign_identifier,starts_at,ends_at,*budget,*promotions";

async function listCampaigns(token) {
  const url = new URL(`${BASE_URL}/admin/campaigns`);
  url.searchParams.set("fields", CAMPAIGN_FIELDS);
  url.searchParams.set("limit", "200");
  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.campaigns;
}

export function isBuygetBudgetCampaign(campaign) {
  const budget = campaign.budget || {};
  if (!budget.limit) return false;
  const promotions = campaign.promotions || [];
  return promotions.some((p) => p.type === "buyget");
}
4

Pull the real redemptions from orders

For each flagged campaign, fetch orders whose promotions include one of the campaign's buyget promotion ids, along with each order's line item adjustment totals for that promotion. This is the ground truth, since it counts what actually happened at checkout instead of trusting a counter that may never have moved.

step4.py
ORDER_FIELDS = "id,display_id,total,created_at,*promotions,*items,*items.adjustments"

def orders_redeeming(token, promotion_ids):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/orders",
        params={"fields": ORDER_FIELDS, "promotion_id[]": promotion_ids, "limit": 200},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["orders"]

def redemptions_for_campaign(orders, promotion_ids):
    """Turn raw orders into flat redemption rows the pure reconciler can use."""
    ids = set(promotion_ids)
    rows = []
    for order in orders:
        matched = [p["id"] for p in (order.get("promotions") or []) if p.get("id") in ids]
        if not matched:
            continue
        discount_total = 0.0
        for item in order.get("items") or []:
            for adj in item.get("adjustments") or []:
                if adj.get("promotion_id") in ids:
                    discount_total += float(adj.get("amount") or 0)
        rows.append({"orderId": order["id"], "promotionId": matched[0], "discountTotal": discount_total})
    return rows
step4.js
const ORDER_FIELDS = "id,display_id,total,created_at,*promotions,*items,*items.adjustments";

async function ordersRedeeming(token, promotionIds) {
  const url = new URL(`${BASE_URL}/admin/orders`);
  url.searchParams.set("fields", ORDER_FIELDS);
  url.searchParams.set("limit", "200");
  for (const id of promotionIds) url.searchParams.append("promotion_id[]", id);
  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.orders;
}

export function redemptionsForCampaign(orders, promotionIds) {
  // Turn raw orders into flat redemption rows the pure reconciler can use.
  const ids = new Set(promotionIds);
  const rows = [];
  for (const order of orders) {
    const matched = (order.promotions || []).filter((p) => ids.has(p.id)).map((p) => p.id);
    if (matched.length === 0) continue;
    let discountTotal = 0;
    for (const item of order.items || []) {
      for (const adj of item.adjustments || []) {
        if (ids.has(adj.promotion_id)) discountTotal += Number(adj.amount || 0);
      }
    }
    rows.push({ orderId: order.id, promotionId: matched[0], discountTotal });
  }
  return rows;
}
5

Recompute usage and decide, with one pure function

Take the campaign's stored budget and the flat list of redemptions and decide, with plain arithmetic, what usage should actually be. For a usage budget that is the count of redemptions. For a spend budget that is the sum of their discount totals. Compare that to the stored used and to the limit. This function touches no network at all, which is exactly what makes it easy to trust and easy to test.

decide.py
def reconcile_campaign_budget_usage(campaign, redemptions):
    """Pure: recomputes usage from redemptions and compares it to the stored budget.
    campaign = {"id": str, "budget": {"type": "spend" | "usage", "limit": float, "used": float}}
    redemptions = [{"orderId": str, "promotionId": str, "discountTotal": float}, ...]
    """
    budget = campaign["budget"]
    if budget["type"] == "usage":
        recomputed_used = len(redemptions)
    else:
        recomputed_used = sum(r["discountTotal"] for r in redemptions)

    limit = budget["limit"]
    stored_used = budget["used"]
    needs_sync = recomputed_used != stored_used
    over_budget = limit > 0 and recomputed_used > limit

    return {
        "campaignId": campaign["id"],
        "storedUsed": stored_used,
        "recomputedUsed": recomputed_used,
        "limit": limit,
        "needsSync": needs_sync,
        "overBudget": over_budget,
    }
decide.js
/**
 * Pure: recomputes usage from redemptions and compares it to the stored budget.
 * campaign = { id, budget: { type: "spend" | "usage", limit, used } }
 * redemptions = [{ orderId, promotionId, discountTotal }, ...]
 */
export function reconcileCampaignBudgetUsage(campaign, redemptions) {
  const { budget } = campaign;
  const recomputedUsed =
    budget.type === "usage"
      ? redemptions.length
      : redemptions.reduce((sum, r) => sum + r.discountTotal, 0);

  const { limit, used: storedUsed } = budget;
  const needsSync = recomputedUsed !== storedUsed;
  const overBudget = limit > 0 && recomputedUsed > limit;

  return {
    campaignId: campaign.id,
    storedUsed,
    recomputedUsed,
    limit,
    needsSync,
    overBudget,
  };
}
6

Report by default, sync only behind a reviewed DRY_RUN=false

Print one report row per campaign: the id, the campaign identifier, the budget type, the stored and recomputed usage, the limit, and whether it is over budget. Only when a human sets DRY_RUN=false does the script POST /admin/campaigns/:id with { budget: { used: recomputed_used } } to sync the counter. If a campaign is over budget, the script additionally prints the suggested (never automatic) PATCH /admin/promotions/:id { status: "inactive" } call, because deactivating a live promotion has customer-facing impact and deserves a human decision.

Run it safe

Leave DRY_RUN=true on every first run. Read the report, confirm which campaigns actually need their counter synced, and only then rerun with DRY_RUN=false to write. The script never calls the promotion deactivation endpoint on its own even when DRY_RUN=false. It only prints the suggested call so a person reviews the business impact before a live promotion goes inactive.

The full code

Here is the complete script in one file for each language. It authenticates, lists campaigns whose budget is tied to a buyget promotion, pulls the real redemptions from orders, recomputes usage with the pure reconciler, and prints a report row for every campaign, syncing the counter only when DRY_RUN is explicitly turned off.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
reconcile_campaign_budget.py
"""Reconcile a Medusa campaign's budget.used against real order redemptions.

Buy X Get Y (buyget) promotions do not reliably emit or persist the usage-update
action that keeps a campaign's budget.used current, so a campaign tied only to a
buyget promotion can be redeemed past its limit while its dashboard still shows an
untouched budget. This recomputes real usage from orders and reports every campaign
where the recomputed number disagrees with what is stored, or has crossed the limit.
By default it only reports. It syncs budget.used only when DRY_RUN=false, and it
never deactivates a promotion on its own even then. 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("reconcile_campaign_budget")

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"

CAMPAIGN_FIELDS = "id,name,campaign_identifier,starts_at,ends_at,*budget,*promotions"
ORDER_FIELDS = "id,display_id,total,created_at,*promotions,*items,*items.adjustments"


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_campaigns(token):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/campaigns",
        params={"fields": CAMPAIGN_FIELDS, "limit": 200},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["campaigns"]


def is_buyget_budget_campaign(campaign):
    budget = campaign.get("budget") or {}
    if not budget.get("limit"):
        return False
    promotions = campaign.get("promotions") or []
    return any(p.get("type") == "buyget" for p in promotions)


def orders_redeeming(token, promotion_ids):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/orders",
        params={"fields": ORDER_FIELDS, "promotion_id[]": promotion_ids, "limit": 200},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["orders"]


def redemptions_for_campaign(orders, promotion_ids):
    """Turn raw orders into flat redemption rows the pure reconciler can use."""
    ids = set(promotion_ids)
    rows = []
    for order in orders:
        matched = [p["id"] for p in (order.get("promotions") or []) if p.get("id") in ids]
        if not matched:
            continue
        discount_total = 0.0
        for item in order.get("items") or []:
            for adj in item.get("adjustments") or []:
                if adj.get("promotion_id") in ids:
                    discount_total += float(adj.get("amount") or 0)
        rows.append({"orderId": order["id"], "promotionId": matched[0], "discountTotal": discount_total})
    return rows


def reconcile_campaign_budget_usage(campaign, redemptions):
    """Pure: recomputes usage from redemptions and compares it to the stored budget.
    campaign = {"id": str, "budget": {"type": "spend" | "usage", "limit": float, "used": float}}
    redemptions = [{"orderId": str, "promotionId": str, "discountTotal": float}, ...]
    """
    budget = campaign["budget"]
    if budget["type"] == "usage":
        recomputed_used = len(redemptions)
    else:
        recomputed_used = sum(r["discountTotal"] for r in redemptions)

    limit = budget["limit"]
    stored_used = budget["used"]
    needs_sync = recomputed_used != stored_used
    over_budget = limit > 0 and recomputed_used > limit

    return {
        "campaignId": campaign["id"],
        "storedUsed": stored_used,
        "recomputedUsed": recomputed_used,
        "limit": limit,
        "needsSync": needs_sync,
        "overBudget": over_budget,
    }


def sync_budget_used(token, campaign_id, recomputed_used):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/campaigns/{campaign_id}",
        json={"budget": {"used": recomputed_used}},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["campaign"]


def run():
    token = get_token()
    campaigns = [c for c in list_campaigns(token) if is_buyget_budget_campaign(c)]
    if not campaigns:
        log.info("No campaigns found with a budget tied to a buyget promotion.")
        return

    for campaign in campaigns:
        promotion_ids = [p["id"] for p in campaign["promotions"] if p.get("type") == "buyget"]
        orders = orders_redeeming(token, promotion_ids)
        redemptions = redemptions_for_campaign(orders, promotion_ids)
        result = reconcile_campaign_budget_usage(campaign, redemptions)

        log.info(
            "campaign_id=%s identifier=%s budget_type=%s stored_used=%s recomputed_used=%s limit=%s over_budget=%s",
            result["campaignId"], campaign.get("campaign_identifier"), campaign["budget"]["type"],
            result["storedUsed"], result["recomputedUsed"], result["limit"], result["overBudget"],
        )

        if result["needsSync"]:
            if DRY_RUN:
                log.info("Would sync budget.used to %s for campaign %s.", result["recomputedUsed"], result["campaignId"])
            else:
                sync_budget_used(token, result["campaignId"], result["recomputedUsed"])
                log.info("Synced budget.used to %s for campaign %s.", result["recomputedUsed"], result["campaignId"])

        if result["overBudget"]:
            log.warning(
                "Campaign %s is over budget. Suggested review action (not automatic): "
                "PATCH /admin/promotions/%s {\"status\": \"inactive\"}",
                result["campaignId"], promotion_ids[0],
            )

    log.info("Done. %d buyget-budget campaign(s) checked.", len(campaigns))


if __name__ == "__main__":
    run()
reconcile-campaign-budget.js
/**
 * Reconcile a Medusa campaign's budget.used against real order redemptions.
 *
 * Buy X Get Y (buyget) promotions do not reliably emit or persist the usage-update
 * action that keeps a campaign's budget.used current, so a campaign tied only to a
 * buyget promotion can be redeemed past its limit while its dashboard still shows an
 * untouched budget. This recomputes real usage from orders and reports every campaign
 * where the recomputed number disagrees with what is stored, or has crossed the limit.
 * By default it only reports. It syncs budget.used only when DRY_RUN=false, and it
 * never deactivates a promotion on its own even then. 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 CAMPAIGN_FIELDS = "id,name,campaign_identifier,starts_at,ends_at,*budget,*promotions";
const ORDER_FIELDS = "id,display_id,total,created_at,*promotions,*items,*items.adjustments";

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 listCampaigns(token) {
  const url = new URL(`${BASE_URL}/admin/campaigns`);
  url.searchParams.set("fields", CAMPAIGN_FIELDS);
  url.searchParams.set("limit", "200");
  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.campaigns;
}

export function isBuygetBudgetCampaign(campaign) {
  const budget = campaign.budget || {};
  if (!budget.limit) return false;
  const promotions = campaign.promotions || [];
  return promotions.some((p) => p.type === "buyget");
}

async function ordersRedeeming(token, promotionIds) {
  const url = new URL(`${BASE_URL}/admin/orders`);
  url.searchParams.set("fields", ORDER_FIELDS);
  url.searchParams.set("limit", "200");
  for (const id of promotionIds) url.searchParams.append("promotion_id[]", id);
  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.orders;
}

export function redemptionsForCampaign(orders, promotionIds) {
  // Turn raw orders into flat redemption rows the pure reconciler can use.
  const ids = new Set(promotionIds);
  const rows = [];
  for (const order of orders) {
    const matched = (order.promotions || []).filter((p) => ids.has(p.id)).map((p) => p.id);
    if (matched.length === 0) continue;
    let discountTotal = 0;
    for (const item of order.items || []) {
      for (const adj of item.adjustments || []) {
        if (ids.has(adj.promotion_id)) discountTotal += Number(adj.amount || 0);
      }
    }
    rows.push({ orderId: order.id, promotionId: matched[0], discountTotal });
  }
  return rows;
}

/**
 * Pure: recomputes usage from redemptions and compares it to the stored budget.
 * campaign = { id, budget: { type: "spend" | "usage", limit, used } }
 * redemptions = [{ orderId, promotionId, discountTotal }, ...]
 */
export function reconcileCampaignBudgetUsage(campaign, redemptions) {
  const { budget } = campaign;
  const recomputedUsed =
    budget.type === "usage"
      ? redemptions.length
      : redemptions.reduce((sum, r) => sum + r.discountTotal, 0);

  const { limit, used: storedUsed } = budget;
  const needsSync = recomputedUsed !== storedUsed;
  const overBudget = limit > 0 && recomputedUsed > limit;

  return {
    campaignId: campaign.id,
    storedUsed,
    recomputedUsed,
    limit,
    needsSync,
    overBudget,
  };
}

async function syncBudgetUsed(token, campaignId, recomputedUsed) {
  const res = await fetch(`${BASE_URL}/admin/campaigns/${campaignId}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ budget: { used: recomputedUsed } }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.campaign;
}

export async function run() {
  const token = await getToken();
  const campaigns = (await listCampaigns(token)).filter(isBuygetBudgetCampaign);

  if (campaigns.length === 0) {
    console.log("No campaigns found with a budget tied to a buyget promotion.");
    return;
  }

  for (const campaign of campaigns) {
    const promotionIds = campaign.promotions.filter((p) => p.type === "buyget").map((p) => p.id);
    const orders = await ordersRedeeming(token, promotionIds);
    const redemptions = redemptionsForCampaign(orders, promotionIds);
    const result = reconcileCampaignBudgetUsage(campaign, redemptions);

    console.log(
      `campaign_id=${result.campaignId} identifier=${campaign.campaign_identifier} ` +
      `budget_type=${campaign.budget.type} stored_used=${result.storedUsed} ` +
      `recomputed_used=${result.recomputedUsed} limit=${result.limit} over_budget=${result.overBudget}`
    );

    if (result.needsSync) {
      if (DRY_RUN) {
        console.log(`Would sync budget.used to ${result.recomputedUsed} for campaign ${result.campaignId}.`);
      } else {
        await syncBudgetUsed(token, result.campaignId, result.recomputedUsed);
        console.log(`Synced budget.used to ${result.recomputedUsed} for campaign ${result.campaignId}.`);
      }
    }

    if (result.overBudget) {
      console.warn(
        `Campaign ${result.campaignId} is over budget. Suggested review action (not automatic): ` +
        `PATCH /admin/promotions/${promotionIds[0]} {"status": "inactive"}`
      );
    }
  }

  console.log(`Done. ${campaigns.length} buyget-budget campaign(s) checked.`);
}

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 reconciler, because it decides whether a campaign gets reported and whether it gets flagged as over budget. Because reconcile_campaign_budget_usage is pure, the tests feed in plain campaign and redemption objects, no Medusa backend required.

test_campaign_budget_reconcile.py
from reconcile_campaign_budget import reconcile_campaign_budget_usage, is_buyget_budget_campaign


def campaign(**over):
    base = {"id": "camp_1", "budget": {"type": "usage", "limit": 100, "used": 0}}
    base.update(over)
    return base


def redemption(**over):
    base = {"orderId": "order_1", "promotionId": "promo_1", "discountTotal": 10.0}
    base.update(over)
    return base


def test_usage_budget_counts_redemptions():
    redemptions = [redemption(orderId="order_1"), redemption(orderId="order_2")]
    result = reconcile_campaign_budget_usage(campaign(), redemptions)
    assert result["recomputedUsed"] == 2
    assert result["needsSync"] is True
    assert result["overBudget"] is False


def test_spend_budget_sums_discount_totals():
    c = campaign(budget={"type": "spend", "limit": 100, "used": 0})
    redemptions = [redemption(discountTotal=30.0), redemption(discountTotal=45.0)]
    result = reconcile_campaign_budget_usage(c, redemptions)
    assert result["recomputedUsed"] == 75.0
    assert result["needsSync"] is True
    assert result["overBudget"] is False


def test_no_sync_needed_when_stored_matches_recomputed():
    c = campaign(budget={"type": "usage", "limit": 100, "used": 2})
    redemptions = [redemption(orderId="order_1"), redemption(orderId="order_2")]
    result = reconcile_campaign_budget_usage(c, redemptions)
    assert result["needsSync"] is False


def test_over_budget_when_recomputed_exceeds_limit():
    c = campaign(budget={"type": "usage", "limit": 2, "used": 0})
    redemptions = [redemption(orderId=f"order_{i}") for i in range(5)]
    result = reconcile_campaign_budget_usage(c, redemptions)
    assert result["recomputedUsed"] == 5
    assert result["overBudget"] is True


def test_exactly_at_limit_is_not_over_budget():
    c = campaign(budget={"type": "usage", "limit": 3, "used": 0})
    redemptions = [redemption(orderId=f"order_{i}") for i in range(3)]
    result = reconcile_campaign_budget_usage(c, redemptions)
    assert result["overBudget"] is False


def test_zero_limit_means_unlimited_never_over_budget():
    c = campaign(budget={"type": "usage", "limit": 0, "used": 0})
    redemptions = [redemption(orderId=f"order_{i}") for i in range(50)]
    result = reconcile_campaign_budget_usage(c, redemptions)
    assert result["overBudget"] is False


def test_no_redemptions_recomputes_to_zero():
    result = reconcile_campaign_budget_usage(campaign(), [])
    assert result["recomputedUsed"] == 0
    assert result["needsSync"] is False


def test_is_buyget_budget_campaign_requires_limit_and_buyget_type():
    c = {"budget": {"limit": 50, "used": 0}, "promotions": [{"id": "promo_1", "type": "buyget"}]}
    assert is_buyget_budget_campaign(c) is True


def test_is_buyget_budget_campaign_false_without_limit():
    c = {"budget": {"limit": 0, "used": 0}, "promotions": [{"id": "promo_1", "type": "buyget"}]}
    assert is_buyget_budget_campaign(c) is False


def test_is_buyget_budget_campaign_false_without_buyget_promotion():
    c = {"budget": {"limit": 50, "used": 0}, "promotions": [{"id": "promo_1", "type": "standard"}]}
    assert is_buyget_budget_campaign(c) is False
campaign-budget-reconcile.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { reconcileCampaignBudgetUsage, isBuygetBudgetCampaign } from "./reconcile-campaign-budget.js";

const campaign = (over = {}) => ({ id: "camp_1", budget: { type: "usage", limit: 100, used: 0 }, ...over });
const redemption = (over = {}) => ({ orderId: "order_1", promotionId: "promo_1", discountTotal: 10.0, ...over });

test("usage budget counts redemptions", () => {
  const redemptions = [redemption({ orderId: "order_1" }), redemption({ orderId: "order_2" })];
  const result = reconcileCampaignBudgetUsage(campaign(), redemptions);
  assert.equal(result.recomputedUsed, 2);
  assert.equal(result.needsSync, true);
  assert.equal(result.overBudget, false);
});

test("spend budget sums discount totals", () => {
  const c = campaign({ budget: { type: "spend", limit: 100, used: 0 } });
  const redemptions = [redemption({ discountTotal: 30.0 }), redemption({ discountTotal: 45.0 })];
  const result = reconcileCampaignBudgetUsage(c, redemptions);
  assert.equal(result.recomputedUsed, 75.0);
  assert.equal(result.needsSync, true);
  assert.equal(result.overBudget, false);
});

test("no sync needed when stored matches recomputed", () => {
  const c = campaign({ budget: { type: "usage", limit: 100, used: 2 } });
  const redemptions = [redemption({ orderId: "order_1" }), redemption({ orderId: "order_2" })];
  const result = reconcileCampaignBudgetUsage(c, redemptions);
  assert.equal(result.needsSync, false);
});

test("over budget when recomputed exceeds limit", () => {
  const c = campaign({ budget: { type: "usage", limit: 2, used: 0 } });
  const redemptions = [0, 1, 2, 3, 4].map((i) => redemption({ orderId: `order_${i}` }));
  const result = reconcileCampaignBudgetUsage(c, redemptions);
  assert.equal(result.recomputedUsed, 5);
  assert.equal(result.overBudget, true);
});

test("exactly at limit is not over budget", () => {
  const c = campaign({ budget: { type: "usage", limit: 3, used: 0 } });
  const redemptions = [0, 1, 2].map((i) => redemption({ orderId: `order_${i}` }));
  const result = reconcileCampaignBudgetUsage(c, redemptions);
  assert.equal(result.overBudget, false);
});

test("zero limit means unlimited, never over budget", () => {
  const c = campaign({ budget: { type: "usage", limit: 0, used: 0 } });
  const redemptions = Array.from({ length: 50 }, (_, i) => redemption({ orderId: `order_${i}` }));
  const result = reconcileCampaignBudgetUsage(c, redemptions);
  assert.equal(result.overBudget, false);
});

test("no redemptions recomputes to zero", () => {
  const result = reconcileCampaignBudgetUsage(campaign(), []);
  assert.equal(result.recomputedUsed, 0);
  assert.equal(result.needsSync, false);
});

test("isBuygetBudgetCampaign requires limit and buyget type", () => {
  const c = { budget: { limit: 50, used: 0 }, promotions: [{ id: "promo_1", type: "buyget" }] };
  assert.equal(isBuygetBudgetCampaign(c), true);
});

test("isBuygetBudgetCampaign false without limit", () => {
  const c = { budget: { limit: 0, used: 0 }, promotions: [{ id: "promo_1", type: "buyget" }] };
  assert.equal(isBuygetBudgetCampaign(c), false);
});

test("isBuygetBudgetCampaign false without buyget promotion", () => {
  const c = { budget: { limit: 50, used: 0 }, promotions: [{ id: "promo_1", type: "standard" }] };
  assert.equal(isBuygetBudgetCampaign(c), false);
});

Case studies

Usage cap

The launch freebie that never stopped giving

A skincare brand ran a Buy X Get Y campaign capped at 200 redemptions for a launch week free gift. The admin dashboard showed budget.used sitting at 0 through the entire campaign. Nobody worried, since the number never moved and the promotion looked perfectly healthy from the outside.

The reconciler pulled every order that redeemed the promotion and counted 341 real redemptions against a limit of 200. The campaign had been over budget for four days before anyone noticed, purely because the counter that was supposed to sound the alarm had never once incremented.

Mixed promotions

The campaign that looked half-right

A campaign carried both a percentage discount promotion and a buyget promotion sharing one spend budget. The percentage promotion's redemptions updated budget.used correctly, so the number was not stuck at zero, it just quietly undercounted every buyget redemption layered on top, which made the gap much harder to spot by eye.

Recomputing spend from actual order adjustments across both promotion types showed the real total was nearly double what the dashboard reported. The team synced the counter with DRY_RUN=false after confirming the numbers, and kept the buyget promotion active while they watched it more closely going forward.

What good looks like

Run this reconciler on a schedule against any campaign that pairs a budget with a buyget promotion. It never rewrites history on its own and it never deactivates a live promotion by itself. It tells you, campaign by campaign, whether the stored counter can be trusted and whether the real spend or usage has already crossed the line, so a human can decide to sync the number, tighten the promotion, or leave it as is with full information instead of a blind dashboard.

FAQ

Why does my Medusa campaign budget.used stay at 0 for a Buy X Get Y promotion?

Medusa's Promotion module is supposed to increment a campaign's budget.used every time an order redeems a promotion tied to that campaign. For buyget (Buy X Get Y) promotions specifically, the usage-accounting step in the computeActions and adjustment pipeline does not reliably emit or persist that update, unlike standard percentage or fixed promotions. Because budget.used is only ever recomputed on that apply path rather than derived from actual orders, it can sit at 0 or a stale number no matter how many orders redeem the promotion.

Can a Buy X Get Y campaign be redeemed past its budget limit without anyone noticing?

Yes. Because budget.used never catches up to the real redemption count, Medusa has no accurate number to compare against budget.limit, so it keeps approving the promotion on every checkout. A campaign meant to cap spend or usage at a fixed number can be redeemed well past that cap with the admin dashboard still showing a budget that looks untouched.

Should I just overwrite budget.used with the recomputed number?

Only after review. Rewriting budget.used retroactively can misstate history and does nothing to stop orders that already redeemed the promotion past its cap. The safer default is to recompute real usage from orders, report any campaign where the stored and recomputed numbers disagree or where the recomputed usage exceeds the limit, and let a human decide whether to sync the counter or deactivate the promotion.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #8829: bug report and maintainer investigation into Buy X Get Y promotions not tracking campaign budget usage. github.com/medusajs/medusa/issues/8829
  2. medusajs/medusa GitHub issue #11259: buyxgety promotion is always applied with a 100% discount. github.com/medusajs/medusa/issues/11259
  3. Medusa Documentation: the Campaign concept in the Promotion module, including campaign budgets. docs.medusajs.com/resources/commerce-modules/promotion/campaign

On the solution:

  1. Medusa Documentation: Promotion module concepts, including computeActions and application methods. docs.medusajs.com/resources/commerce-modules/promotion/concepts
  2. Medusa Documentation: Promotion actions reference. docs.medusajs.com/resources/commerce-modules/promotion/actions
  3. Medusa Documentation: the CampaignBudgetDTO reference, including the used and limit fields. docs.medusajs.com/resources/references/promotion/interfaces/promotion.CampaignBudgetDTO

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 runaway campaign?

If this saved you from a promotion quietly bleeding past its budget, 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