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.

Python and Node.js Medusa Admin API Report only, no auto-mutate
The short answer

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.

Price list status: active Gates checked dates, currency, rules one gate fails no error raised Falls back to default price Cart shows list price
The list looks active in the admin, but a date, currency, or rule mismatch silently blocks it. Nothing errors, so the cart just shows the default price.

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:

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.

The key insight

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.

List price lists status, dates, prices Compute effective state active, scheduled, expired, draft Check currency and rules against region context Everything matches? no yes, list is fine Report mismatch and exact admin fix
The script only reports. It never rewrites dates, status, or amounts on its own, because those are commercial decisions.

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 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.

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, this fix only ever reports
setup (shell)
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
2

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.

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

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.

step3.py
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
step3.js
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;
  }
}
4

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.

decide.py
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
decide.js
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]
}
5

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.

coverage.py
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
coverage.js
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)
  );
}
6

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.

Run it safe

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_price_lists.py
"""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-price-lists.js
/**
 * 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.

test_price_list_state.py
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
price-list-state.test.js
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

Scheduled sale

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.

Currency gap

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.

What good looks like

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:

  1. medusajs/medusa GitHub issue #10490: Prices from price lists are not applicable when adding to cart. github.com/medusajs/medusa/issues/10490
  2. 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
  3. medusajs/medusa GitHub issue #9625: price list not applied. github.com/medusajs/medusa/issues/9625

On the solution:

  1. Medusa Documentation: the Pricing module. docs.medusajs.com/resources/commerce-modules/pricing
  2. Medusa Documentation: Pricing concepts, including price lists, rules, and calculated price. docs.medusajs.com/resources/commerce-modules/pricing/concepts
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes