Diagnostic Regions, pricing, and currency
Medusa price list not applied at checkout
You set up a sale price list in the Medusa admin, gave it a start date, and moved on. Now a shopper opens the cart and the price is still the default one, no discount in sight. Nothing crashed, nothing logged an error, the list just was not used. Here is why Medusa's pricing module silently skips a price list and a script that audits every list against the storefront context it is supposed to serve.
A Medusa price list is only applied when it satisfies all three conditions at once: its status is "active", the current time falls inside its starts_at and ends_at window, and it has a price row for the cart's exact currency_code plus any region_id or customer_group_id rule attached to the list. Miss any one of those and the pricing module falls back to the default price without an error. Run a small Python or Node.js script that fetches every price list, computes its real effective state, cross-checks currency coverage against your regions, and reports the exact mismatch instead of guessing at commercial data.
The problem in plain words
In Medusa v2, prices live in the Pricing module, and a price list is just another set of prices with extra conditions attached. When a cart or a storefront product request asks for a price, the pricing module's price-selection strategy looks at every price list that could apply and picks one only if it clears every gate: is the list turned on, are we inside its active dates, and does it actually have a price for this currency and this shopper's region or group.
None of those checks raise an error when they fail. A list with status: "active" but a starts_at still in the future is not an error, it is just not live yet, so Medusa treats it as effectively Scheduled. A list whose ends_at already passed is effectively Expired. A list missing a price row for the store's currency is effectively invisible for that currency. In every case the calculator quietly falls through to the original price, and the merchant sees a cart that looks unchanged, with no signal that a sale price list even exists.
Why it happens
Medusa's pricing module is strict about matching, but it never surfaces a match failure as feedback to the merchant. A few concrete ways a price list ends up ignored:
- The list has
status: "active"in the database, but itsstarts_atis still in the future, so it is effectively Scheduled and the full price keeps showing. - The list's
ends_atalready passed, often left over from a promotion that was never cleaned up, so it is effectively Expired. - The list was created and never flipped out of
status: "draft", so it was never live at all even though someone believes it is running. - The list has prices only in one currency, for example USD, but the storefront region checks out in EUR, so there is no matching price row and Medusa falls back silently.
- The list has a
region_idorcustomer_group_idrule that does not match the shopper's actual region or group, so the price row is skipped even though the currency matches. - Someone edited
starts_atorends_atafter the list was created, and the storefront's cached or stalecalculated_pricekeeps showing the old result until the variant is refetched, which is a caching gap seen in Medusa's own issue tracker rather than a data problem.
This is a common source of confusion because the admin UI shows the list as "Active" without checking whether today's date is actually inside the window, and it does not cross-check currency coverage against your regions at all. See the citations at the end for the exact GitHub issues and docs.
A price list is a set of merchant decisions: dates, amounts, which regions and groups it targets. A script should never guess at those and silently rewrite them. The safe pattern is to compute the list's real effective state from its own data, compare that against what the storefront is actually asking for, and report the exact mismatch and the exact fix. A human who owns the pricing decides whether to change the dates, flip the status, or add a price row.
The fix, as a flow
We do not touch checkout and we do not change pricing on our own. We pull every price list with its status and dates, compute a real effective state with a pure function, pull the storefront regions to know what currencies actually matter, and check each effectively active list for a price row that covers that currency and any rule it carries. Anything that does not line up gets reported with the reason and the admin call a human can make to fix it.
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 price lists and regions. 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, this fix only ever reports
npm install @medusajs/js-sdk
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" // start safe, this fix only ever reports
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 every price list with its status, dates, and prices
Ask for status, starts_at, ends_at, and the nested prices with their price_rules, paging through with limit and offset so a store with many lists is fully covered.
def list_price_lists(token):
headers = {"Authorization": f"Bearer {token}"}
fields = "id,title,status,starts_at,ends_at,type,*prices,*prices.price_rules,*rules"
out, offset, limit = [], 0, 100
while True:
r = requests.get(
f"{BASE_URL}/admin/price-lists",
params={"fields": fields, "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(body["price_lists"])
offset += limit
if offset >= body["count"]:
return out
async function listPriceLists(sdk) {
const fields = "id,title,status,starts_at,ends_at,type,*prices,*prices.price_rules,*rules";
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.priceList.list({ fields, limit, offset });
out.push(...body.price_lists);
offset += limit;
if (offset >= body.count) return out;
}
}
Decide the real state, with one pure function
Keep the decision in a function with no network calls, so it is easy to read and easy to test. Draft always wins regardless of dates. Otherwise, a starts_at still ahead of now means Scheduled, and an ends_at already behind now means Expired. Only when none of that applies is the list truly active.
from datetime import datetime
def get_price_list_effective_state(price_list, now):
# Draft always wins regardless of dates, a draft list is never live.
if price_list.get("status") == "draft":
return "draft"
starts_at = price_list.get("starts_at")
ends_at = price_list.get("ends_at")
starts_dt = datetime.fromisoformat(starts_at) if starts_at else None
ends_dt = datetime.fromisoformat(ends_at) if ends_at else None
if starts_dt and now < starts_dt:
return "scheduled" # not started yet, full price still shown
if ends_dt and now > ends_dt:
return "expired" # window closed, full price still shown
return "active" # status is active AND now falls within the window
export function getPriceListEffectiveState(priceList, now) {
// Draft always wins regardless of dates, a draft list is never live.
if (priceList.status === "draft") return "draft";
const startsAt = priceList.starts_at ? new Date(priceList.starts_at) : null;
const endsAt = priceList.ends_at ? new Date(priceList.ends_at) : null;
if (startsAt && now < startsAt) return "scheduled"; // not started yet, full price still shown
if (endsAt && now > endsAt) return "expired"; // window closed, full price still shown
return "active"; // status is active AND now falls within [starts_at, ends_at]
}
Check currency and region coverage for the lists that are truly active
An effectively active list can still be invisible if it has no price row for the storefront's currency, or if a region_id or customer_group_id rule on the price does not match the shopper's context. Pull the region's currency with GET /admin/regions and run this pure check against each candidate list.
def has_matching_price(prices, context):
for p in prices:
if p.get("currency_code") != context.get("currency_code"):
continue
rules = p.get("rules") or {}
if rules.get("region_id") and rules["region_id"] != context.get("region_id"):
continue
if rules.get("customer_group_id") and rules["customer_group_id"] != context.get("customer_group_id"):
continue
return True
return False
export function hasMatchingPrice(prices, context) {
return prices.some((p) =>
p.currency_code === context.currency_code &&
(!p.rules?.region_id || p.rules.region_id === context.region_id) &&
(!p.rules?.customer_group_id || p.rules.customer_group_id === context.customer_group_id)
);
}
Confirm against the storefront and report, never auto-mutate
For the final proof, fetch the product from the Store API with the storefront's x-publishable-api-key and check calculated_price.calculated_price.price_list_id. If it is null despite an effectively active, currency-matched list, the mismatch is confirmed. Every run is gated behind DRY_RUN, and even when it is false the script only logs the intended admin payload, it never calls a write endpoint on its own, because dates, status, and amounts are commercial decisions only a human should confirm.
This script never writes to your store. It logs the exact POST /admin/price-lists/{id} payload that would fix each mismatch, whether that is flipping status to "active", correcting starts_at or ends_at, or adding a price row for a missing currency. A human confirms the commercial intent, then applies it in the admin or re-runs the call by hand.
The full code
Here is the complete script in one file for each language. It authenticates, lists every price list, computes its effective state, checks currency and rule coverage against your regions, and prints a report with the exact fix for anything that does not line up.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Audit Medusa price lists for why they are not applied at checkout.
Reports mismatches (draft, scheduled, expired, or missing currency/region price).
Never mutates merchant pricing data. Safe to run again and again.
"""
import os
import logging
from datetime import datetime, timezone
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("audit_price_lists")
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"
PRICE_LIST_FIELDS = "id,title,status,starts_at,ends_at,type,*prices,*prices.price_rules,*rules"
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_price_lists(token):
headers = {"Authorization": f"Bearer {token}"}
out, offset, limit = [], 0, 100
while True:
r = requests.get(
f"{BASE_URL}/admin/price-lists",
params={"fields": PRICE_LIST_FIELDS, "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(body["price_lists"])
offset += limit
if offset >= body["count"]:
return out
def list_regions(token):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/regions",
params={"fields": "id,name,currency_code", "limit": 100},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["regions"]
def get_price_list_effective_state(price_list, now):
# Draft always wins regardless of dates, a draft list is never live.
if price_list.get("status") == "draft":
return "draft"
starts_at = price_list.get("starts_at")
ends_at = price_list.get("ends_at")
starts_dt = datetime.fromisoformat(starts_at.replace("Z", "+00:00")) if starts_at else None
ends_dt = datetime.fromisoformat(ends_at.replace("Z", "+00:00")) if ends_at else None
if starts_dt and now < starts_dt:
return "scheduled" # not started yet, full price still shown
if ends_dt and now > ends_dt:
return "expired" # window closed, full price still shown
return "active" # status is active AND now falls within the window
def has_matching_price(prices, context):
for p in prices or []:
if p.get("currency_code") != context.get("currency_code"):
continue
rules = p.get("rules") or {}
if rules.get("region_id") and rules["region_id"] != context.get("region_id"):
continue
if rules.get("customer_group_id") and rules["customer_group_id"] != context.get("customer_group_id"):
continue
return True
return False
def build_fix_payload(price_list, state):
if state == "draft":
return {"status": "active"}
if state == "scheduled":
return {"starts_at": datetime.now(timezone.utc).isoformat()}
if state == "expired":
return {"ends_at": None}
return None
def audit(price_lists, regions, now):
"""Pure: returns a list of report dicts, one per mismatched price list."""
reports = []
for pl in price_lists:
state = get_price_list_effective_state(pl, now)
if state != "active":
reports.append({
"price_list_id": pl["id"],
"title": pl.get("title"),
"reason": state,
"fix": build_fix_payload(pl, state),
})
continue
for region in regions:
context = {"currency_code": region["currency_code"], "region_id": region["id"]}
if not has_matching_price(pl.get("prices"), context):
reports.append({
"price_list_id": pl["id"],
"title": pl.get("title"),
"reason": "active-but-no-matching-currency/region-price",
"region": region["name"],
"currency_code": region["currency_code"],
"fix": {"currency_code": region["currency_code"], "rules": {"region_id": region["id"]}},
})
return reports
def run():
token = get_token()
price_lists = list_price_lists(token)
regions = list_regions(token)
now = datetime.now(timezone.utc)
reports = audit(price_lists, regions, now)
if not reports:
log.info("All %d price list(s) are effectively active with full currency coverage.", len(price_lists))
return
for r in reports:
log.info(
"Price list %s (%s): %s. %s payload: %s",
r["price_list_id"], r["title"], r["reason"],
"Would send" if DRY_RUN else "Suggested",
r["fix"],
)
log.info("Done. %d price list(s) flagged out of %d.", len(reports), len(price_lists))
if __name__ == "__main__":
run()
/**
* Audit Medusa price lists for why they are not applied at checkout.
* Reports mismatches (draft, scheduled, expired, or missing currency/region price).
* Never mutates merchant pricing data. 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 PRICE_LIST_FIELDS = "id,title,status,starts_at,ends_at,type,*prices,*prices.price_rules,*rules";
export function getPriceListEffectiveState(priceList, now) {
// Draft always wins regardless of dates, a draft list is never live.
if (priceList.status === "draft") return "draft";
const startsAt = priceList.starts_at ? new Date(priceList.starts_at) : null;
const endsAt = priceList.ends_at ? new Date(priceList.ends_at) : null;
if (startsAt && now < startsAt) return "scheduled"; // not started yet, full price still shown
if (endsAt && now > endsAt) return "expired"; // window closed, full price still shown
return "active"; // status is active AND now falls within [starts_at, ends_at]
}
export function hasMatchingPrice(prices, context) {
return (prices || []).some((p) =>
p.currency_code === context.currency_code &&
(!p.rules?.region_id || p.rules.region_id === context.region_id) &&
(!p.rules?.customer_group_id || p.rules.customer_group_id === context.customer_group_id)
);
}
function buildFixPayload(priceList, state) {
if (state === "draft") return { status: "active" };
if (state === "scheduled") return { starts_at: new Date().toISOString() };
if (state === "expired") return { ends_at: null };
return null;
}
export function audit(priceLists, regions, now) {
// Pure: returns a list of report objects, one per mismatched price list.
const reports = [];
for (const pl of priceLists) {
const state = getPriceListEffectiveState(pl, now);
if (state !== "active") {
reports.push({ priceListId: pl.id, title: pl.title, reason: state, fix: buildFixPayload(pl, state) });
continue;
}
for (const region of regions) {
const context = { currency_code: region.currency_code, region_id: region.id };
if (!hasMatchingPrice(pl.prices, context)) {
reports.push({
priceListId: pl.id,
title: pl.title,
reason: "active-but-no-matching-currency/region-price",
region: region.name,
currencyCode: region.currency_code,
fix: { currency_code: region.currency_code, rules: { region_id: region.id } },
});
}
}
}
return reports;
}
async function login() {
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
async function listPriceLists(sdk) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.priceList.list({ fields: PRICE_LIST_FIELDS, limit, offset });
out.push(...body.price_lists);
offset += limit;
if (offset >= body.count) return out;
}
}
async function listRegions(sdk) {
const body = await sdk.admin.region.list({ fields: "id,name,currency_code", limit: 100 });
return body.regions;
}
export async function run() {
const sdk = await login();
const priceLists = await listPriceLists(sdk);
const regions = await listRegions(sdk);
const now = new Date();
const reports = audit(priceLists, regions, now);
if (reports.length === 0) {
console.log(`All ${priceLists.length} price list(s) are effectively active with full currency coverage.`);
return;
}
for (const r of reports) {
console.log(
`Price list ${r.priceListId} (${r.title}): ${r.reason}. ${DRY_RUN ? "Would send" : "Suggested"} payload: ${JSON.stringify(r.fix)}`
);
}
console.log(`Done. ${reports.length} price list(s) flagged out of ${priceLists.length}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The two functions worth testing are the ones that decide the outcome: the effective state calculation and the currency and rule match. Both are pure, so the tests feed in plain objects and a fixed date, no Medusa backend required.
from datetime import datetime, timezone
from audit_price_lists import get_price_list_effective_state, has_matching_price
NOW = datetime(2026, 7, 10, tzinfo=timezone.utc)
def price_list(**over):
base = {"status": "active", "starts_at": None, "ends_at": None}
base.update(over)
return base
def test_draft_wins_regardless_of_dates():
pl = price_list(status="draft", starts_at="2020-01-01T00:00:00+00:00")
assert get_price_list_effective_state(pl, NOW) == "draft"
def test_scheduled_when_starts_in_future():
pl = price_list(starts_at="2030-01-01T00:00:00+00:00")
assert get_price_list_effective_state(pl, NOW) == "scheduled"
def test_expired_when_ends_in_past():
pl = price_list(ends_at="2020-01-01T00:00:00+00:00")
assert get_price_list_effective_state(pl, NOW) == "expired"
def test_active_when_status_active_and_inside_window():
pl = price_list(starts_at="2020-01-01T00:00:00+00:00", ends_at="2030-01-01T00:00:00+00:00")
assert get_price_list_effective_state(pl, NOW) == "active"
def test_matching_price_requires_currency_and_region():
prices = [{"currency_code": "eur", "rules": {"region_id": "reg_1"}}]
assert has_matching_price(prices, {"currency_code": "eur", "region_id": "reg_1"}) is True
assert has_matching_price(prices, {"currency_code": "eur", "region_id": "reg_2"}) is False
assert has_matching_price(prices, {"currency_code": "usd", "region_id": "reg_1"}) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { getPriceListEffectiveState, hasMatchingPrice } from "./audit-price-lists.js";
const NOW = new Date("2026-07-10T00:00:00Z");
const priceList = (over = {}) => ({ status: "active", starts_at: null, ends_at: null, ...over });
test("draft wins regardless of dates", () => {
const pl = priceList({ status: "draft", starts_at: "2020-01-01T00:00:00Z" });
assert.equal(getPriceListEffectiveState(pl, NOW), "draft");
});
test("scheduled when starts in future", () => {
const pl = priceList({ starts_at: "2030-01-01T00:00:00Z" });
assert.equal(getPriceListEffectiveState(pl, NOW), "scheduled");
});
test("expired when ends in past", () => {
const pl = priceList({ ends_at: "2020-01-01T00:00:00Z" });
assert.equal(getPriceListEffectiveState(pl, NOW), "expired");
});
test("active when status active and inside window", () => {
const pl = priceList({ starts_at: "2020-01-01T00:00:00Z", ends_at: "2030-01-01T00:00:00Z" });
assert.equal(getPriceListEffectiveState(pl, NOW), "active");
});
test("matching price requires currency and region", () => {
const prices = [{ currency_code: "eur", rules: { region_id: "reg_1" } }];
assert.equal(hasMatchingPrice(prices, { currency_code: "eur", region_id: "reg_1" }), true);
assert.equal(hasMatchingPrice(prices, { currency_code: "eur", region_id: "reg_2" }), false);
assert.equal(hasMatchingPrice(prices, { currency_code: "usd", region_id: "reg_1" }), false);
});
Case studies
The flash sale that started a day late
A store set up a weekend flash sale price list two weeks ahead of time, with status: "active" already set so it would not be forgotten. Saturday morning arrived and the storefront still showed full price. The starts_at had been entered in the wrong time zone, so the list would not actually become active for another eighteen hours.
The audit script flagged it as scheduled within the first minute of the sale and printed the exact starts_at correction. A quick admin edit and the sale price appeared, instead of losing a third of the discount window to a silent date error.
The EU price list that never covered EUR checkouts
A merchant built a regional discount list for their European launch, added it to the right products, and set the dates correctly. But every price row on the list was entered in USD, copied from an earlier US promotion, while the EU region's storefront checks out in EUR.
Every EU cart quietly fell back to the default EUR price with no error anywhere in the logs. Running the audit against the region list surfaced it immediately as active-but-no-matching-currency/region-price, with the missing currency named, instead of the team discovering it from a confused customer email.
Run this audit whenever a price list should be live and is not showing up as expected, or on a schedule alongside other pricing checks. It never touches your commercial data, it only tells you which list is draft, scheduled, expired, or missing a currency row, and the exact admin call to fix it. The merchant keeps full control over dates, status, and amounts, and stops losing sale windows to a silent mismatch.
FAQ
Why does my Medusa price list not show up in the cart?
Medusa only applies a price list when its status is active, the current time falls inside its starts_at and ends_at window, and it has a price row matching the cart's exact currency code and any region or customer group rule on the list. If any one of those does not match, Medusa quietly falls back to the default price instead of raising an error, so the cart shows list price with no warning.
I changed the price list dates but the storefront still shows the old price. Why?
Medusa's own issue tracker has reports of the storefront and cart price calculation not always re-evaluating price list eligibility right away after starts_at or ends_at are edited. Refetch the variant's calculated_price from the Store API after the edit, and if it still does not reflect the new window, treat it as a caching gap rather than a data problem.
How do I know for sure a price list is being skipped at checkout?
Fetch the product from the Store API with fields=*variants.calculated_price using the storefront's region and publishable key, then look at calculated_price.calculated_price.price_list_id. If it is null despite a price list that should be active and covers that variant and currency, the list is confirmed not applied.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub issue #10490: Prices from price lists are not applicable when adding to cart. github.com/medusajs/medusa/issues/10490
- medusajs/medusa GitHub issue #9376: the price list start and end dates to mark as expired or active do not work on the storefront. github.com/medusajs/medusa/issues/9376
- medusajs/medusa GitHub issue #9625: price list not applied. github.com/medusajs/medusa/issues/9625
On the solution:
- Medusa Documentation: the Pricing module. docs.medusajs.com/resources/commerce-modules/pricing
- Medusa Documentation: Pricing concepts, including price lists, rules, and calculated price. docs.medusajs.com/resources/commerce-modules/pricing/concepts
- Medusa Admin User Guide: Manage price lists, status, and dates. docs.medusajs.com/user-guide/price-lists/manage
Stuck on a tricky one?
If you have a problem in Medusa pricing, inventory, orders, promotions, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this find your missing sale price?
If this saved you a support ticket or a lost discount window, 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