Skip to content

Reconciler Pricing & Promotions

Shipping discount uses a stale shipping amount after cart changes

A customer has a percentage-off shipping promotion applied, then adds another item or changes a quantity. The shipping price recalculates the way it should. But the discount sitting next to it does not move with it, it is still computed off the shipping price from before the change. The cart total ends up wrong by exactly the difference, and nothing in the UI tells anyone why. Here is why Medusa's own recompute steps can disagree with each other, and a script that finds the carts where this happened and safely re-triggers the fix.

Python and Node.js Medusa Admin and Store API Flag first, repair behind DRY_RUN
A 50 percent discount sign
Photo by Artem Beliaikin on Unsplash
The short answer

In Medusa v2, adding or updating a cart item runs refreshCartItemsWorkflow, which triggers refreshCartShippingMethodsWorkflow to recalculate a calculated shipping option's price, and separately runs updateCartPromotionsWorkflow to recompute promotion adjustments. Because those are two independent workflow steps that each fetch and pass cart state on their own, the promotion step's computeActions can compute the shipping discount against the shipping amount as it stood before the shipping refresh finished, so the stored ShippingMethodAdjustment reflects the old price, not the new one (Medusa GitHub issue #14484). Run a small Python or Node.js script that recomputes what each shipping adjustment should be from the promotion's own rule and the cart's current shipping amount, flags the carts where the stored value disagrees, and, behind a DRY_RUN guard, re-applies the same promotion code through the store API so Medusa recomputes it for real.

The problem in plain words

A shipping promotion in Medusa v2 does not store a fixed discount amount. It stores a rule, for example one hundred percent off shipping, and Medusa computes the actual number from that rule against the shipping method's current price whenever the cart's promotions are recomputed.

The trouble is that a single cart change can trigger two separate recomputations that are supposed to agree but do not always run in the order the numbers need. Adding an item or changing a quantity kicks off refreshCartItemsWorkflow. That workflow calls refreshCartShippingMethodsWorkflow, which updates the price of any calculated shipping option, and it also calls updateCartPromotionsWorkflow, which asks the promotion module to recompute every adjustment, including the shipping one. Both of those are separate workflow steps, and each one fetches and passes cart state on its own rather than sharing one consistent snapshot. When the promotion step runs its computeActions logic before the shipping refresh's new price is visible to it, the discount gets calculated against the old shipping amount. The result sits right there on the cart, a ShippingMethodAdjustment whose amount is stale, while shipping_methods[i].amount itself is already correct.

Cart item changes refreshCartItemsWorkflow Shipping refresh step recalculates shipping amount, its own snapshot Promotion recompute step updateCartPromotionsWorkflow reads shipping amount separately reads before refresh visible Adjustment stale computed off old shipping amount Cart total off by the delta shipping_methods[i].amount is already correct
Both workflow steps run correctly on their own. The shipping amount refreshes, but the promotion step can compute the discount before that refresh is visible to it, so only the adjustment stays stale.

Why it happens

Every one of these is a normal part of Medusa v2's cart workflows, and the mismatch only shows up when they land in a certain order:

This is a common source of confusion because both numbers look plausible on their own, the shipping price is right and the discount percentage is right, they just were not computed against each other at the same moment. See the citations at the end for the exact GitHub issue and the docs on how promotion adjustments work.

The key insight

This is not bad data sitting in a database column, it is a cart-level pricing bug caused by workflow ordering. That means a script should never reach in and overwrite the ShippingMethodAdjustment.amount directly, since the next cart update can just recompute it wrong again, and a live cart can be mid checkout right now. The safe pattern is to recompute what the adjustment should be with a pure, testable rule, flag the carts where it disagrees with what is stored, and repair by re-triggering Medusa's own recompute, not by hand-patching the number.

The fix, as a flow

We do not touch checkout and we do not write straight to a cart's adjustment amount. We fetch each cart's current shipping method amount and its promotions, pull each promotion's live rule, and run a pure function that computes what the shipping adjustment should be right now. Where that disagrees with what is stored, past a small tolerance, we flag it. Behind DRY_RUN, the repair step re-applies the same promotion codes through the store API's cart promotions route, which forces Medusa to run updateCartPromotionsWorkflow again against the now-current shipping amount.

Read cart shipping amount, adjustments Fetch live promotion rule application_method.value Compute expected vs stored adjustment Delta over tolerance? yes no, leave alone Log or re-apply promo codes
The script flags first. Repair means re-applying the promotion code so Medusa's own workflow recomputes the adjustment, never a direct write to the stored amount.

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 carts, orders, and promotions. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Store carts also need the publishable API key when read from the store side. 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 MEDUSA_PUBLISHABLE_KEY="pk_..."
export DRY_RUN="true"   # start safe, only logs stored vs expected amount
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 MEDUSA_PUBLISHABLE_KEY="pk_..."
export DRY_RUN="true"   // start safe, only logs stored vs expected amount
2

Authenticate and list carts with their shipping methods

Exchange credentials for a token, then ask for carts along with their shipping totals, shipping methods, each method's adjustments, and applied promotions. Recently updated carts are the ones most likely to have hit the ordering bug, since it only shows up right after an item change.

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"]

CART_FIELDS = (
    "id,shipping_total,item_total,"
    "*shipping_methods,*shipping_methods.adjustments,*promotions"
)

def get_token():
    r = requests.post(
        f"{BASE_URL}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def get_cart(token, cart_id):
    r = requests.get(
        f"{BASE_URL}/store/carts/{cart_id}",
        params={"fields": CART_FIELDS},
        headers={
            "Authorization": f"Bearer {token}",
            "x-publishable-api-key": os.environ.get("MEDUSA_PUBLISHABLE_KEY", ""),
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["cart"]
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;
const PUBLISHABLE_KEY = process.env.MEDUSA_PUBLISHABLE_KEY || "";

const CART_FIELDS =
  "id,shipping_total,item_total," +
  "*shipping_methods,*shipping_methods.adjustments,*promotions";

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 getCart(token, cartId) {
  const url = new URL(`${BASE_URL}/store/carts/${cartId}`);
  url.searchParams.set("fields", CART_FIELDS);
  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      "x-publishable-api-key": PUBLISHABLE_KEY,
    },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.cart;
}
3

Fetch each promotion's live rule

For every promotion code on a flagged cart, read the promotion's application_method to get its type and value. Only target_type === "shipping_methods" promotions are relevant here, since a product or order discount does not touch shipping at all.

step3.py
def get_promotion(token, promotion_id):
    r = requests.get(
        f"{BASE_URL}/admin/promotions/{promotion_id}",
        params={"fields": "id,code,application_method.value,application_method.target_type,application_method.type"},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["promotion"]
step3.js
async function getPromotion(token, promotionId) {
  const url = new URL(`${BASE_URL}/admin/promotions/${promotionId}`);
  url.searchParams.set(
    "fields",
    "id,code,application_method.value,application_method.target_type,application_method.type"
  );
  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.promotion;
}
4

Decide, with one pure function

Keep the decision in a function with no network calls, so it is easy to read and easy to test. Given the shipping method's current amount and the promotion's application method, it returns what the adjustment amount should be right now. The caller compares that to the persisted adjustment and derives the delta, so the function itself never touches a cart or a database.

decide.py
TOLERANCE = 0.01

def compute_expected_shipping_adjustment(shipping_method, promotion):
    """Pure: no I/O. shipping_method is {"id","amount"}.
    promotion is {"id","code","application_method": {"type","value","target_type"}}.
    Returns None when the promotion does not target shipping methods."""
    app = promotion["application_method"]
    if app["target_type"] != "shipping_methods":
        return None
    amount = shipping_method["amount"]
    if app["type"] == "percentage":
        adjustment_amount = amount * (app["value"] / 100)
    else:
        adjustment_amount = min(app["value"], amount)
    return {"adjustment_amount": adjustment_amount, "is_stale": False, "delta": 0}


def evaluate_stale_adjustment(shipping_method, promotion, stored_amount):
    """Pure: no I/O. Combines the expected amount with the persisted stored_amount
    (read by the caller) to produce delta and is_stale."""
    expected = compute_expected_shipping_adjustment(shipping_method, promotion)
    if expected is None:
        return None
    delta = stored_amount - expected["adjustment_amount"]
    expected["delta"] = delta
    expected["is_stale"] = abs(delta) > TOLERANCE
    return expected
decide.js
const TOLERANCE = 0.01;

export function computeExpectedShippingAdjustment(shippingMethod, promotion) {
  // Pure: no I/O. shippingMethod is { id, amount }.
  // promotion is { id, code, application_method: { type, value, target_type } }.
  // Returns null when the promotion does not target shipping methods.
  const app = promotion.application_method;
  if (app.target_type !== "shipping_methods") return null;
  const amount = shippingMethod.amount;
  const adjustmentAmount =
    app.type === "percentage" ? amount * (app.value / 100) : Math.min(app.value, amount);
  return { adjustment_amount: adjustmentAmount, is_stale: false, delta: 0 };
}

export function evaluateStaleAdjustment(shippingMethod, promotion, storedAmount) {
  // Pure: no I/O. Combines the expected amount with the persisted storedAmount
  // (read by the caller) to produce delta and is_stale.
  const expected = computeExpectedShippingAdjustment(shippingMethod, promotion);
  if (expected === null) return null;
  const delta = storedAmount - expected.adjustment_amount;
  expected.delta = delta;
  expected.is_stale = Math.abs(delta) > TOLERANCE;
  return expected;
}
5

Repair by re-applying the promotion code, behind a dry run guard

When DRY_RUN is true, only log the cart id, shipping method id, promotion code, stored amount, expected amount, and delta for every flagged cart. When it is false, call the store API's cart promotions route with the same codes the cart already has. Re-applying the codes forces Medusa to run updateCartPromotionsWorkflow again, now against the current, already-refreshed shipping_methods[i].amount, which is exactly what should have happened the first time.

apply.py
def reapply_promotions(token, cart_id, promo_codes):
    r = requests.post(
        f"{BASE_URL}/store/carts/{cart_id}/promotions",
        json={"promo_codes": promo_codes},
        headers={
            "Authorization": f"Bearer {token}",
            "x-publishable-api-key": os.environ.get("MEDUSA_PUBLISHABLE_KEY", ""),
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["cart"]
apply.js
async function reapplyPromotions(token, cartId, promoCodes) {
  const res = await fetch(`${BASE_URL}/store/carts/${cartId}/promotions`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "x-publishable-api-key": PUBLISHABLE_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ promo_codes: promoCodes }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.cart;
}
Run it safe

Never write the recomputed number straight into a cart's ShippingMethodAdjustment.amount. Start with DRY_RUN=true, review the exact list of flagged carts, then flip it off to let the script re-apply the promotion codes and let Medusa recompute for real. For an order that already completed with the stale amount, do not auto-correct it, since the total is already captured. Flag it for support to review manually.

The full code

Here is the complete script in one file for each language. It authenticates, reads a cart's shipping methods and promotions, fetches each promotion's live rule, flags every stale shipping adjustment with a pure function, and either logs the repair or re-applies the promotion codes depending on DRY_RUN.

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_shipping_discount.py
"""Flag Medusa v2 carts whose shipping promotion adjustment was computed against
a stale shipping amount, and safely repair by re-applying the same promotion
codes so Medusa's own updateCartPromotionsWorkflow recomputes it for real.
Never writes ShippingMethodAdjustment.amount directly. DRY_RUN=true only logs
stored vs expected. Safe to run again and again, one cart at a time.
"""
import os
import logging

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_shipping_discount")

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")
PUBLISHABLE_KEY = os.environ.get("MEDUSA_PUBLISHABLE_KEY", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

TOLERANCE = 0.01

CART_FIELDS = (
    "id,shipping_total,item_total,"
    "*shipping_methods,*shipping_methods.adjustments,*promotions"
)


def get_token():
    r = requests.post(
        f"{BASE_URL}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


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


def get_promotion(token, promotion_id):
    r = requests.get(
        f"{BASE_URL}/admin/promotions/{promotion_id}",
        params={"fields": "id,code,application_method.value,application_method.target_type,application_method.type"},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["promotion"]


def compute_expected_shipping_adjustment(shipping_method, promotion):
    """Pure: no I/O. shipping_method is {"id","amount"}.
    promotion is {"id","code","application_method": {"type","value","target_type"}}.
    Returns None when the promotion does not target shipping methods."""
    app = promotion["application_method"]
    if app["target_type"] != "shipping_methods":
        return None
    amount = shipping_method["amount"]
    if app["type"] == "percentage":
        adjustment_amount = amount * (app["value"] / 100)
    else:
        adjustment_amount = min(app["value"], amount)
    return {"adjustment_amount": adjustment_amount, "is_stale": False, "delta": 0}


def evaluate_stale_adjustment(shipping_method, promotion, stored_amount):
    """Pure: no I/O. Combines the expected amount with the persisted stored_amount
    (read by the caller) to produce delta and is_stale."""
    expected = compute_expected_shipping_adjustment(shipping_method, promotion)
    if expected is None:
        return None
    delta = stored_amount - expected["adjustment_amount"]
    expected["delta"] = delta
    expected["is_stale"] = abs(delta) > TOLERANCE
    return expected


def reapply_promotions(token, cart_id, promo_codes):
    r = requests.post(
        f"{BASE_URL}/store/carts/{cart_id}/promotions",
        json={"promo_codes": promo_codes},
        headers={
            "Authorization": f"Bearer {token}",
            "x-publishable-api-key": PUBLISHABLE_KEY,
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["cart"]


def find_stale_shipping_adjustments(token, cart_id):
    cart = get_cart(token, cart_id)
    promotions_by_code = {p["code"]: p for p in cart.get("promotions", [])}
    flagged = []
    for method in cart.get("shipping_methods", []):
        shipping_method = {"id": method["id"], "amount": method["amount"]}
        for adj in method.get("adjustments", []) or []:
            code = adj.get("code")
            promo = promotions_by_code.get(code)
            if promo is None:
                continue
            promotion = get_promotion(token, promo["id"])
            result = evaluate_stale_adjustment(shipping_method, promotion, adj["amount"])
            if result is None or not result["is_stale"]:
                continue
            flagged.append({
                "cart_id": cart_id,
                "shipping_method_id": method["id"],
                "promotion_code": code,
                "stored_amount": adj["amount"],
                "expected_amount": result["adjustment_amount"],
                "delta": result["delta"],
            })
    return flagged, list(promotions_by_code.keys())


def run(cart_ids=None):
    token = get_token()
    cart_ids = cart_ids or os.environ.get("CART_IDS", "").split(",")
    cart_ids = [c.strip() for c in cart_ids if c.strip()]

    total_flagged = 0
    for cart_id in cart_ids:
        flagged, codes = find_stale_shipping_adjustments(token, cart_id)
        for f in flagged:
            log.info(
                "Cart %s shipping method %s promo %s: stored=%s expected=%s delta=%s. %s",
                f["cart_id"], f["shipping_method_id"], f["promotion_code"],
                f["stored_amount"], f["expected_amount"], f["delta"],
                "Would re-apply" if DRY_RUN else "Re-applying",
            )
            if not DRY_RUN:
                reapply_promotions(token, cart_id, codes)
            total_flagged += 1

    log.info("Done. %d stale shipping adjustment(s) %s.", total_flagged, "to repair" if DRY_RUN else "repaired")


if __name__ == "__main__":
    run()
reconcile-shipping-discount.js
/**
 * Flag Medusa v2 carts whose shipping promotion adjustment was computed against
 * a stale shipping amount, and safely repair by re-applying the same promotion
 * codes so Medusa's own updateCartPromotionsWorkflow recomputes it for real.
 * Never writes ShippingMethodAdjustment.amount directly. DRY_RUN=true only logs
 * stored vs expected. Safe to run again and again, one cart at a time.
 */
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 PUBLISHABLE_KEY = process.env.MEDUSA_PUBLISHABLE_KEY || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const TOLERANCE = 0.01;

const CART_FIELDS =
  "id,shipping_total,item_total," +
  "*shipping_methods,*shipping_methods.adjustments,*promotions";

export function computeExpectedShippingAdjustment(shippingMethod, promotion) {
  // Pure: no I/O. shippingMethod is { id, amount }.
  // promotion is { id, code, application_method: { type, value, target_type } }.
  // Returns null when the promotion does not target shipping methods.
  const app = promotion.application_method;
  if (app.target_type !== "shipping_methods") return null;
  const amount = shippingMethod.amount;
  const adjustmentAmount =
    app.type === "percentage" ? amount * (app.value / 100) : Math.min(app.value, amount);
  return { adjustment_amount: adjustmentAmount, is_stale: false, delta: 0 };
}

export function evaluateStaleAdjustment(shippingMethod, promotion, storedAmount) {
  // Pure: no I/O. Combines the expected amount with the persisted storedAmount
  // (read by the caller) to produce delta and is_stale.
  const expected = computeExpectedShippingAdjustment(shippingMethod, promotion);
  if (expected === null) return null;
  const delta = storedAmount - expected.adjustment_amount;
  expected.delta = delta;
  expected.is_stale = Math.abs(delta) > TOLERANCE;
  return expected;
}

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 getCart(token, cartId) {
  const url = new URL(`${BASE_URL}/store/carts/${cartId}`);
  url.searchParams.set("fields", CART_FIELDS);
  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      "x-publishable-api-key": PUBLISHABLE_KEY,
    },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.cart;
}

async function getPromotion(token, promotionId) {
  const url = new URL(`${BASE_URL}/admin/promotions/${promotionId}`);
  url.searchParams.set(
    "fields",
    "id,code,application_method.value,application_method.target_type,application_method.type"
  );
  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.promotion;
}

async function reapplyPromotions(token, cartId, promoCodes) {
  const res = await fetch(`${BASE_URL}/store/carts/${cartId}/promotions`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "x-publishable-api-key": PUBLISHABLE_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ promo_codes: promoCodes }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.cart;
}

async function findStaleShippingAdjustments(token, cartId) {
  const cart = await getCart(token, cartId);
  const promotionsByCode = new Map((cart.promotions || []).map((p) => [p.code, p]));
  const flagged = [];
  for (const method of cart.shipping_methods || []) {
    const shippingMethod = { id: method.id, amount: method.amount };
    for (const adj of method.adjustments || []) {
      const promo = promotionsByCode.get(adj.code);
      if (!promo) continue;
      const promotion = await getPromotion(token, promo.id);
      const result = evaluateStaleAdjustment(shippingMethod, promotion, adj.amount);
      if (!result || !result.is_stale) continue;
      flagged.push({
        cart_id: cartId,
        shipping_method_id: method.id,
        promotion_code: adj.code,
        stored_amount: adj.amount,
        expected_amount: result.adjustment_amount,
        delta: result.delta,
      });
    }
  }
  return { flagged, codes: [...promotionsByCode.keys()] };
}

export async function run(cartIds) {
  const token = await getToken();
  const ids = (cartIds || (process.env.CART_IDS || "").split(","))
    .map((c) => c.trim())
    .filter(Boolean);

  let totalFlagged = 0;
  for (const cartId of ids) {
    const { flagged, codes } = await findStaleShippingAdjustments(token, cartId);
    for (const f of flagged) {
      console.log(
        `Cart ${f.cart_id} shipping method ${f.shipping_method_id} promo ${f.promotion_code}: stored=${f.stored_amount} expected=${f.expected_amount} delta=${f.delta}. ${DRY_RUN ? "Would re-apply" : "Re-applying"}`
      );
      if (!DRY_RUN) await reapplyPromotions(token, cartId, codes);
      totalFlagged++;
    }
  }

  console.log(`Done. ${totalFlagged} stale shipping adjustment(s) ${DRY_RUN ? "to repair" : "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 is the pure decision, evaluate_stale_adjustment and the compute_expected_shipping_adjustment it wraps. Both take plain objects and do no fetching, so the tests need no Medusa backend and no network at all.

test_shipping_discount.py
from reconcile_shipping_discount import (
    compute_expected_shipping_adjustment,
    evaluate_stale_adjustment,
)


def shipping_method(**over):
    base = {"id": "sm_1", "amount": 1079}
    base.update(over)
    return base


def promotion(**over):
    base = {
        "id": "promo_1",
        "code": "FREESHIP",
        "application_method": {"type": "percentage", "value": 100, "target_type": "shipping_methods"},
    }
    base.update(over)
    return base


def test_percentage_full_off_matches_current_amount():
    result = compute_expected_shipping_adjustment(shipping_method(), promotion())
    assert result["adjustment_amount"] == 1079


def test_percentage_partial_off():
    promo = promotion(application_method={"type": "percentage", "value": 50, "target_type": "shipping_methods"})
    result = compute_expected_shipping_adjustment(shipping_method(), promo)
    assert result["adjustment_amount"] == 539.5


def test_fixed_amount_capped_at_shipping_amount():
    promo = promotion(application_method={"type": "fixed", "value": 5000, "target_type": "shipping_methods"})
    result = compute_expected_shipping_adjustment(shipping_method(amount=1079), promo)
    assert result["adjustment_amount"] == 1079


def test_non_shipping_target_returns_none():
    promo = promotion(application_method={"type": "percentage", "value": 100, "target_type": "items"})
    assert compute_expected_shipping_adjustment(shipping_method(), promo) is None


def test_stale_when_stored_amount_is_from_before_refresh():
    result = evaluate_stale_adjustment(shipping_method(), promotion(), 929)
    assert result["is_stale"] is True
    assert result["delta"] == 929 - 1079


def test_not_stale_when_stored_matches_expected():
    result = evaluate_stale_adjustment(shipping_method(), promotion(), 1079)
    assert result["is_stale"] is False
    assert result["delta"] == 0


def test_not_stale_within_tolerance():
    result = evaluate_stale_adjustment(shipping_method(), promotion(), 1079.005)
    assert result["is_stale"] is False


def test_none_when_promotion_not_shipping_targeted():
    promo = promotion(application_method={"type": "percentage", "value": 100, "target_type": "items"})
    assert evaluate_stale_adjustment(shipping_method(), promo, 929) is None
shipping-discount.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import {
  computeExpectedShippingAdjustment,
  evaluateStaleAdjustment,
} from "./reconcile-shipping-discount.js";

const shippingMethod = (over = {}) => ({ id: "sm_1", amount: 1079, ...over });

const promotion = (over = {}) => ({
  id: "promo_1",
  code: "FREESHIP",
  application_method: { type: "percentage", value: 100, target_type: "shipping_methods" },
  ...over,
});

test("percentage full off matches current amount", () => {
  const result = computeExpectedShippingAdjustment(shippingMethod(), promotion());
  assert.equal(result.adjustment_amount, 1079);
});

test("percentage partial off", () => {
  const promo = promotion({ application_method: { type: "percentage", value: 50, target_type: "shipping_methods" } });
  const result = computeExpectedShippingAdjustment(shippingMethod(), promo);
  assert.equal(result.adjustment_amount, 539.5);
});

test("fixed amount capped at shipping amount", () => {
  const promo = promotion({ application_method: { type: "fixed", value: 5000, target_type: "shipping_methods" } });
  const result = computeExpectedShippingAdjustment(shippingMethod({ amount: 1079 }), promo);
  assert.equal(result.adjustment_amount, 1079);
});

test("non shipping target returns null", () => {
  const promo = promotion({ application_method: { type: "percentage", value: 100, target_type: "items" } });
  assert.equal(computeExpectedShippingAdjustment(shippingMethod(), promo), null);
});

test("stale when stored amount is from before refresh", () => {
  const result = evaluateStaleAdjustment(shippingMethod(), promotion(), 929);
  assert.equal(result.is_stale, true);
  assert.equal(result.delta, 929 - 1079);
});

test("not stale when stored matches expected", () => {
  const result = evaluateStaleAdjustment(shippingMethod(), promotion(), 1079);
  assert.equal(result.is_stale, false);
  assert.equal(result.delta, 0);
});

test("not stale within tolerance", () => {
  const result = evaluateStaleAdjustment(shippingMethod(), promotion(), 1079.005);
  assert.equal(result.is_stale, false);
});

test("null when promotion not shipping targeted", () => {
  const promo = promotion({ application_method: { type: "percentage", value: 100, target_type: "items" } });
  assert.equal(evaluateStaleAdjustment(shippingMethod(), promo, 929), null);
});

Case studies

Free shipping promo

The cart that quietly kept a shipping charge

A store ran a free shipping over threshold promotion, one hundred percent off the shipping method. A shopper qualified, saw shipping drop to zero, then added one more item that bumped them into a different calculated shipping rate. The shipping price recalculated correctly to the new, higher amount, but the stored discount stayed at the old, lower amount, so the cart total showed a small leftover shipping charge on an order that was supposed to ship free.

Running the reconciler against recent carts surfaced the exact delta between the stored and expected adjustment. Re-applying the promotion code through the store API fixed the live cart before checkout, and the team used the same flagged list to spot the pattern across other free shipping carts that day.

Draft order support

The completed order with the wrong shipping discount

A support ticket came in about an order total that did not match the fifty percent off shipping promotion the customer expected. Pulling the order's shipping methods and adjustments showed the discount had in fact been computed against the shipping amount from before a last-minute quantity change, the same stale-amount pattern, just already captured on a completed order.

Because the order was already paid, the team did not try to auto-correct it. The reconciler's output was enough to confirm the exact expected versus stored amounts, and support issued a manual credit for the difference instead of rewriting a captured total.

What good looks like

Run this against active and recently updated carts, or sweep pending draft orders the same way. It never rewrites a ShippingMethodAdjustment.amount directly. It tells you exactly which shipping discounts were computed against a stale amount, and the only write it makes, behind DRY_RUN=false, is re-applying the promotion codes so Medusa's own workflow recomputes the number the way it should have the first time. Completed orders are flagged for a human, never auto-corrected.

FAQ

Why does my Medusa shipping discount not match the current shipping price?

When a cart item changes, Medusa runs refreshCartItemsWorkflow, which recalculates any calculated shipping option's price in one step and separately recomputes promotion adjustments in another step. Because those two steps each fetch cart state independently, the promotion step can compute the shipping discount against the shipping amount as it stood before the shipping refresh, leaving a ShippingMethodAdjustment that reflects the old price instead of the new one.

Is it safe to auto-correct a stale shipping discount on a live cart?

Not by silently rewriting it. A shopper can be mid checkout, and the underlying issue is workflow ordering, not bad data, so a manual patch can be undone by the next cart update anyway. The safe pattern is to flag the mismatch, then re-apply the same promotion codes through the store API's own promotions route, which forces Medusa to recompute the adjustment against the current shipping amount using its own workflow.

What about orders that already completed with the stale shipping discount?

Do not auto-correct a completed order. Once an order is placed the total is captured and rewriting it is unsafe to automate. Flag it for support to review and issue a manual refund or credit through the order edit or a manual payment adjustment, the same way you would handle any other money-affecting discrepancy.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #14484: Shipping promotion discount uses stale shipping amount when cart items change (V2). github.com/medusajs/medusa/issues/14484
  2. medusajs/medusa GitHub issue #14819: Shipping method adjustment uses gross (tax-inclusive) amount instead of pre-tax amount, causing negative cart total when promotion covers full shipping cost. github.com/medusajs/medusa/issues/14819
  3. Medusa Documentation: Promotions Adjustments in Carts. docs.medusajs.com/resources/commerce-modules/cart/promotions

On the solution:

  1. Medusa Documentation: Promotions Adjustments in Carts. docs.medusajs.com/resources/commerce-modules/cart/promotions
  2. Medusa Documentation: Promotion Actions. docs.medusajs.com/resources/commerce-modules/promotion/actions
  3. Medusa V2 Store API Reference. docs.medusajs.com/api/store

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 catch a stale shipping discount before it shipped?

If this saved you a wrong cart total or a confused refund request, 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