Skip to content

Diagnostic Pricing & Promotions

Price list keeps serving prices past its end date

The sale ended last week. The admin even shows the price list as Expired. But a shopper checks out today and the cart still charges the sale price, not the real one. Nothing crashed and nothing logged an error, the expired list just kept getting picked. Here is why Medusa's pricing module can keep serving an expired price list and a script that finds every affected product and reports, or carefully corrects, the exact fix.

Python and Node.js Medusa Admin and Store API Report by default, dry run guarded fix
A calculator and a laptop
Photo by Mehdi Mirzaie on Unsplash
The short answer

Medusa's Pricing module resolves calculated_price by running the price-list-aware pipeline against each price's rules_count, starts_at/ends_at, and status columns at read time, and there is no background job that deactivates a price list when its ends_at passes. Several read paths, the product list endpoints, cached storefront region and product queries, and the admin UI's Expired label, can evaluate that date window inconsistently or against stale data, so a price list whose end date has already passed can still be selected as the best price. Run a small Python or Node.js script that lists price lists whose status is still active with an ends_at in the past, cross-checks what the storefront actually returns for calculated_price, and reports every affected variant. It only mutates anything when you explicitly turn off DRY_RUN, and even then it just moves the list to draft so it is excluded from selection everywhere, rather than guessing at your pricing.

The problem in plain words

A Medusa price list carries a status column and an optional starts_at/ends_at window. The expectation is simple: once ends_at is in the past, the list should stop being used. But Medusa does not run a scheduled job to flip an expired list to draft for you. Expiry is meant to be enforced purely at read time, inside the same calculatePrices/query-context pipeline that also resolves rules and currency.

The trouble is that this expiry check lives in the price-selection strategy rather than as a hard filter enforced identically everywhere a price is read. The product query used by /store/products, a cached region or product response sitting in front of the storefront, and a cart line item's own pricing lookup can each evaluate the date window a little differently, or skip re-evaluating it entirely if the data they are working from is cached or derived rather than freshly queried. The result: the admin UI can correctly compute and display "Expired" for a list, while the storefront's calculated_price for the exact same variant still resolves to a price that belongs to that same expired list.

Price list ends_at in the past Admin UI check computes "Expired" label shown correctly not a hard filter Storefront read still selects the list Cart charges old sale price
The admin correctly labels the list Expired, but the read path that resolves calculated_price for the storefront and cart is a separate check that can still pick the same list.

Why it happens

Expiry in Medusa v2 is a read-time computation, not a stored state, and a few concrete gaps show up because of that:

This is a real, reported gap. Medusa's own issue tracker has threads describing price list starts_at/ends_at not being enforced consistently on the storefront, and the wider selection logic in calculatePrices not always weighing price-list and non-price-list prices the way merchants expect. See the citations at the end for the exact threads and docs.

The key insight

An expired price list is still a merchant's pricing data. A script should never silently rewrite a live price list, because that mutation could itself become the pricing incident. The safe pattern is to detect the mismatch between what the admin flags and what the storefront actually resolves, report exactly which price lists and variants are affected, and only correct it when a human explicitly turns off DRY_RUN. The correction itself is the smallest possible move: flip status to draft, which Medusa respects as a hard filter distinct from the date window, so the list is excluded from selection everywhere regardless of any caching bug.

The fix, as a flow

We do not touch checkout and we do not mutate a live price list by default. We pull every price list whose ends_at has passed while its status is still active, expand its prices to the variants they attach to, and confirm the mismatch by reading what the storefront's own calculated_price actually returns for those variants. Only when a human sets DRY_RUN=false does the script move the confirmed list to draft, and even then it tells you to purge any cache sitting in front of the store routes.

Active but ends_at past isPriceListExpiredButActive Expand prices to variants price ids, variant ids Read live calculated_price confirm the mismatch DRY_RUN false? yes no, report only Set status draft remind: purge cache
The script confirms the mismatch against the live storefront read before doing anything, and the only mutation available is the smallest one: moving the list out of the active selection pool.

Build it step by step

1

Get an admin session and a publishable key

Point the script at your Medusa backend with an admin user that can read and update price lists, and a storefront publishable API key so it can confirm what the Store API actually resolves. 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 MEDUSA_PUBLISHABLE_KEY="pk_..."
export DRY_RUN="true"   # start safe, logs the intended change only
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, logs the intended change only
2

Authenticate and list the candidate price lists

Exchange credentials for a token with POST /auth/user/emailpass, then ask for price lists with status, ends_at, starts_at, rules_count, and their nested prices. We page with limit and offset so a store with many lists is fully covered.

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

def list_price_lists(token):
    headers = {"Authorization": f"Bearer {token}"}
    fields = "id,title,status,starts_at,ends_at,rules_count,*prices"
    out, offset, limit = [], 0, 200
    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
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}`);
  return (await res.json()).token;
}

async function listPriceLists(token) {
  const fields = "id,title,status,starts_at,ends_at,rules_count,*prices";
  const out = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const url = `${BASE_URL}/admin/price-lists?fields=${fields}&limit=${limit}&offset=${offset}`;
    const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (!res.ok) throw new Error(`Medusa ${res.status}`);
    const body = await res.json();
    out.push(...body.price_lists);
    offset += limit;
    if (offset >= body.count) return out;
  }
}
3

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. It returns true only when the list still reports status: "active" and its ends_at is a real timestamp that has already passed. A list with no ends_at has no expiry to enforce, and a list already moved to draft is already excluded, so both return false.

decide.py
from datetime import datetime

def is_price_list_expired_but_active(price_list, now):
    if price_list.get("status") != "active":
        return False
    ends_at = price_list.get("ends_at")
    if ends_at is None:
        return False
    ends_dt = ends_at if isinstance(ends_at, datetime) else datetime.fromisoformat(ends_at)
    return now > ends_dt
decide.js
export function isPriceListExpiredButActive(priceList, now) {
  if (priceList.status !== "active") return false;
  if (priceList.ends_at === null || priceList.ends_at === undefined) return false;
  return now.getTime() > new Date(priceList.ends_at).getTime();
}
4

Confirm the mismatch against the live storefront read

Flagging by date alone is not proof, because the storefront might already be resolving correctly for a given product. Fetch each affected product from the Store API with x-publishable-api-key and compare variants[].calculated_price.id against the flagged list's price ids. If they match, or if calculated_amount equals the price-list amount rather than the variant's original amount, the mismatch is confirmed for that variant.

confirm.py
PUBLISHABLE_KEY = os.environ.get("MEDUSA_PUBLISHABLE_KEY", "")

def fetch_calculated_price(product_id, region_id):
    r = requests.get(
        f"{BASE_URL}/store/products/{product_id}",
        params={"region_id": region_id, "fields": "id,*variants.calculated_price"},
        headers={"x-publishable-api-key": PUBLISHABLE_KEY},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["product"]

def confirm_affected(product, expired_price_ids):
    affected = []
    for variant in product.get("variants", []):
        cp = variant.get("calculated_price") or {}
        if cp.get("id") in expired_price_ids:
            affected.append(variant["id"])
    return affected
confirm.js
const PUBLISHABLE_KEY = process.env.MEDUSA_PUBLISHABLE_KEY || "";

async function fetchCalculatedPrice(productId, regionId) {
  const url = `${BASE_URL}/store/products/${productId}?region_id=${regionId}&fields=id,*variants.calculated_price`;
  const res = await fetch(url, { headers: { "x-publishable-api-key": PUBLISHABLE_KEY } });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return (await res.json()).product;
}

function confirmAffected(product, expiredPriceIds) {
  const affected = [];
  for (const variant of product.variants || []) {
    const cp = variant.calculated_price || {};
    if (expiredPriceIds.has(cp.id)) affected.push(variant.id);
  }
  return affected;
}
5

Report by default, correct only behind DRY_RUN=false

When a price list is confirmed affected, always log the finding. Only when DRY_RUN is explicitly false does the script call POST /admin/price-lists/{id} with {"status": "draft"}, which Medusa respects as a hard filter distinct from the date window, so the list is excluded from selection immediately regardless of any caching bug elsewhere.

apply.py
def deactivate_price_list(token, price_list_id):
    r = requests.post(
        f"{BASE_URL}/admin/price-lists/{price_list_id}",
        json={"status": "draft"},
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["price_list"]
apply.js
async function deactivatePriceList(token, priceListId) {
  const res = await fetch(`${BASE_URL}/admin/price-lists/${priceListId}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ status: "draft" }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return (await res.json()).price_list;
}
6

Re-check and remember the cache

After a real mutation, re-run the same Store API call to confirm calculated_price.id no longer belongs to the deactivated list. If a storefront CDN or HTTP cache sits in front of /store routes, note in the run output that this API call alone does not purge it, since a cached response can keep serving the old price even after the price list is safely off.

Run it safe

Always start with DRY_RUN=true. This mode only logs which price lists would move to draft and never calls the write endpoint. Only set DRY_RUN=false once you have reviewed the report and agree the flagged lists should truly stop applying, and remember to purge any external cache in front of your store routes afterward.

The full code

Here is the complete script in one file for each language. It authenticates, lists price lists that are still active with an ends_at in the past, confirms the mismatch against the live storefront read, and only deactivates a confirmed list 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.
flag_expired_price_lists.py
"""Flag Medusa price lists that keep serving prices past their end date.
Reports every affected price list and variant by default.
Only moves a confirmed list to status draft when DRY_RUN is explicitly false.
Never guesses at commercial 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("flag_expired_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")
PUBLISHABLE_KEY = os.environ.get("MEDUSA_PUBLISHABLE_KEY", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PRICE_LIST_FIELDS = "id,title,status,starts_at,ends_at,rules_count,*prices"


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, 200
    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 is_price_list_expired_but_active(price_list, now):
    """Pure: true only when status is active and ends_at is a real timestamp already passed."""
    if price_list.get("status") != "active":
        return False
    ends_at = price_list.get("ends_at")
    if ends_at is None:
        return False
    ends_dt = ends_at if isinstance(ends_at, datetime) else datetime.fromisoformat(
        ends_at.replace("Z", "+00:00")
    )
    return now > ends_dt


def pick_best_calculated_price(candidate_prices, now):
    """Pure: filters out expired-but-active or draft price-list candidates, then
    returns the lowest amount remaining, or None if nothing qualifies."""
    eligible = []
    for c in candidate_prices:
        if c.get("price_list_status") == "draft":
            continue
        fake_list = {"status": c.get("price_list_status"), "ends_at": c.get("price_list_ends_at")}
        if c.get("price_list_id") and is_price_list_expired_but_active(fake_list, now):
            continue
        eligible.append(c)
    if not eligible:
        return None
    best = min(eligible, key=lambda c: c["amount"])
    return {"id": best["id"], "amount": best["amount"]}


def fetch_calculated_price(product_id, region_id):
    r = requests.get(
        f"{BASE_URL}/store/products/{product_id}",
        params={"region_id": region_id, "fields": "id,*variants.calculated_price"},
        headers={"x-publishable-api-key": PUBLISHABLE_KEY},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["product"]


def deactivate_price_list(token, price_list_id):
    r = requests.post(
        f"{BASE_URL}/admin/price-lists/{price_list_id}",
        json={"status": "draft"},
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["price_list"]


def run():
    token = get_token()
    now = datetime.now(timezone.utc)

    price_lists = list_price_lists(token)
    flagged = [pl for pl in price_lists if is_price_list_expired_but_active(pl, now)]

    if not flagged:
        log.info("No expired-but-active price lists found out of %d.", len(price_lists))
        return

    for pl in flagged:
        price_ids = [p["id"] for p in (pl.get("prices") or [])]
        log.warning(
            "Price list %s (%s) is status=active with ends_at=%s in the past. %d price row(s) still attached.",
            pl["id"], pl.get("title"), pl.get("ends_at"), len(price_ids),
        )
        if not DRY_RUN:
            deactivate_price_list(token, pl["id"])
            log.info(
                "Moved %s to status=draft. Re-check calculated_price and purge any CDN cache in front of /store.",
                pl["id"],
            )
        else:
            log.info("DRY_RUN=true. Would set status=draft on %s.", pl["id"])

    log.info("Done. %d price list(s) flagged out of %d.", len(flagged), len(price_lists))


if __name__ == "__main__":
    run()
flag-expired-price-lists.js
/**
 * Flag Medusa price lists that keep serving prices past their end date.
 * Reports every affected price list and variant by default.
 * Only moves a confirmed list to status draft when DRY_RUN is explicitly false.
 * Never guesses at commercial data. 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 PUBLISHABLE_KEY = process.env.MEDUSA_PUBLISHABLE_KEY || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PRICE_LIST_FIELDS = "id,title,status,starts_at,ends_at,rules_count,*prices";

export function isPriceListExpiredButActive(priceList, now) {
  // Pure: true only when status is active and ends_at is a real timestamp already passed.
  if (priceList.status !== "active") return false;
  if (priceList.ends_at === null || priceList.ends_at === undefined) return false;
  return now.getTime() > new Date(priceList.ends_at).getTime();
}

export function pickBestCalculatedPrice(candidatePrices, now) {
  // Pure: filters out expired-but-active or draft price-list candidates, then
  // returns the lowest amount remaining, or null if nothing qualifies.
  const eligible = candidatePrices.filter((c) => {
    if (c.price_list_status === "draft") return false;
    if (c.price_list_id) {
      const fakeList = { status: c.price_list_status, ends_at: c.price_list_ends_at };
      if (isPriceListExpiredButActive(fakeList, now)) return false;
    }
    return true;
  });
  if (eligible.length === 0) return null;
  const best = eligible.reduce((a, b) => (b.amount < a.amount ? b : a));
  return { id: best.id, amount: best.amount };
}

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}`);
  return (await res.json()).token;
}

async function listPriceLists(token) {
  const out = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const url = `${BASE_URL}/admin/price-lists?fields=${PRICE_LIST_FIELDS}&limit=${limit}&offset=${offset}`;
    const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (!res.ok) throw new Error(`Medusa ${res.status}`);
    const body = await res.json();
    out.push(...body.price_lists);
    offset += limit;
    if (offset >= body.count) return out;
  }
}

async function fetchCalculatedPrice(productId, regionId) {
  const url = `${BASE_URL}/store/products/${productId}?region_id=${regionId}&fields=id,*variants.calculated_price`;
  const res = await fetch(url, { headers: { "x-publishable-api-key": PUBLISHABLE_KEY } });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return (await res.json()).product;
}

async function deactivatePriceList(token, priceListId) {
  const res = await fetch(`${BASE_URL}/admin/price-lists/${priceListId}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ status: "draft" }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return (await res.json()).price_list;
}

export async function run() {
  const token = await getToken();
  const now = new Date();

  const priceLists = await listPriceLists(token);
  const flagged = priceLists.filter((pl) => isPriceListExpiredButActive(pl, now));

  if (flagged.length === 0) {
    console.log(`No expired-but-active price lists found out of ${priceLists.length}.`);
    return;
  }

  for (const pl of flagged) {
    const priceIds = (pl.prices || []).map((p) => p.id);
    console.warn(
      `Price list ${pl.id} (${pl.title}) is status=active with ends_at=${pl.ends_at} in the past. ${priceIds.length} price row(s) still attached.`
    );
    if (!DRY_RUN) {
      await deactivatePriceList(token, pl.id);
      console.log(`Moved ${pl.id} to status=draft. Re-check calculated_price and purge any CDN cache in front of /store.`);
    } else {
      console.log(`DRY_RUN=true. Would set status=draft on ${pl.id}.`);
    }
  }

  console.log(`Done. ${flagged.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 without ever touching the network: the expiry check itself, and the price selection that leans on it. Both are pure and take the current time as a plain argument, so the tests use fixed dates and never need a Medusa backend.

test_expired_price_list.py
from datetime import datetime, timezone

from flag_expired_price_lists import (
    is_price_list_expired_but_active,
    pick_best_calculated_price,
)

NOW = datetime(2026, 7, 10, tzinfo=timezone.utc)


def price_list(**over):
    base = {"status": "active", "ends_at": None}
    base.update(over)
    return base


def test_true_when_active_and_ends_at_in_past():
    pl = price_list(ends_at="2020-01-01T00:00:00+00:00")
    assert is_price_list_expired_but_active(pl, NOW) is True


def test_false_when_ends_at_is_null():
    pl = price_list(ends_at=None)
    assert is_price_list_expired_but_active(pl, NOW) is False


def test_false_when_status_is_draft():
    pl = price_list(status="draft", ends_at="2020-01-01T00:00:00+00:00")
    assert is_price_list_expired_but_active(pl, NOW) is False


def test_false_when_ends_at_in_future():
    pl = price_list(ends_at="2030-01-01T00:00:00+00:00")
    assert is_price_list_expired_but_active(pl, NOW) is False


def test_pick_best_price_skips_expired_price_list_candidate():
    candidates = [
        {"id": "price_expired", "amount": 10, "price_list_id": "plist_1",
         "price_list_ends_at": "2020-01-01T00:00:00+00:00", "price_list_status": "active"},
        {"id": "price_default", "amount": 50, "price_list_id": None,
         "price_list_ends_at": None, "price_list_status": None},
    ]
    result = pick_best_calculated_price(candidates, NOW)
    assert result == {"id": "price_default", "amount": 50}


def test_pick_best_price_skips_draft_price_list_candidate():
    candidates = [
        {"id": "price_draft", "amount": 5, "price_list_id": "plist_2",
         "price_list_ends_at": None, "price_list_status": "draft"},
        {"id": "price_default", "amount": 50, "price_list_id": None,
         "price_list_ends_at": None, "price_list_status": None},
    ]
    result = pick_best_calculated_price(candidates, NOW)
    assert result == {"id": "price_default", "amount": 50}


def test_pick_best_price_uses_live_active_price_list_when_not_expired():
    candidates = [
        {"id": "price_sale", "amount": 20, "price_list_id": "plist_3",
         "price_list_ends_at": "2030-01-01T00:00:00+00:00", "price_list_status": "active"},
        {"id": "price_default", "amount": 50, "price_list_id": None,
         "price_list_ends_at": None, "price_list_status": None},
    ]
    result = pick_best_calculated_price(candidates, NOW)
    assert result == {"id": "price_sale", "amount": 20}


def test_pick_best_price_returns_none_when_all_candidates_excluded():
    candidates = [
        {"id": "price_expired", "amount": 10, "price_list_id": "plist_4",
         "price_list_ends_at": "2020-01-01T00:00:00+00:00", "price_list_status": "active"},
    ]
    assert pick_best_calculated_price(candidates, NOW) is None


def test_pick_best_price_breaks_ties_by_first_lowest():
    candidates = [
        {"id": "price_a", "amount": 30, "price_list_id": None, "price_list_ends_at": None, "price_list_status": None},
        {"id": "price_b", "amount": 30, "price_list_id": None, "price_list_ends_at": None, "price_list_status": None},
    ]
    result = pick_best_calculated_price(candidates, NOW)
    assert result["amount"] == 30
    assert result["id"] in ("price_a", "price_b")
expired-price-list.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isPriceListExpiredButActive, pickBestCalculatedPrice } from "./flag-expired-price-lists.js";

const NOW = new Date("2026-07-10T00:00:00Z");

const priceList = (over = {}) => ({ status: "active", ends_at: null, ...over });

test("true when active and ends_at in past", () => {
  const pl = priceList({ ends_at: "2020-01-01T00:00:00Z" });
  assert.equal(isPriceListExpiredButActive(pl, NOW), true);
});

test("false when ends_at is null", () => {
  const pl = priceList({ ends_at: null });
  assert.equal(isPriceListExpiredButActive(pl, NOW), false);
});

test("false when status is draft", () => {
  const pl = priceList({ status: "draft", ends_at: "2020-01-01T00:00:00Z" });
  assert.equal(isPriceListExpiredButActive(pl, NOW), false);
});

test("false when ends_at in future", () => {
  const pl = priceList({ ends_at: "2030-01-01T00:00:00Z" });
  assert.equal(isPriceListExpiredButActive(pl, NOW), false);
});

test("pick best price skips expired price list candidate", () => {
  const candidates = [
    { id: "price_expired", amount: 10, price_list_id: "plist_1", price_list_ends_at: "2020-01-01T00:00:00Z", price_list_status: "active" },
    { id: "price_default", amount: 50, price_list_id: null, price_list_ends_at: null, price_list_status: null },
  ];
  assert.deepEqual(pickBestCalculatedPrice(candidates, NOW), { id: "price_default", amount: 50 });
});

test("pick best price skips draft price list candidate", () => {
  const candidates = [
    { id: "price_draft", amount: 5, price_list_id: "plist_2", price_list_ends_at: null, price_list_status: "draft" },
    { id: "price_default", amount: 50, price_list_id: null, price_list_ends_at: null, price_list_status: null },
  ];
  assert.deepEqual(pickBestCalculatedPrice(candidates, NOW), { id: "price_default", amount: 50 });
});

test("pick best price uses live active price list when not expired", () => {
  const candidates = [
    { id: "price_sale", amount: 20, price_list_id: "plist_3", price_list_ends_at: "2030-01-01T00:00:00Z", price_list_status: "active" },
    { id: "price_default", amount: 50, price_list_id: null, price_list_ends_at: null, price_list_status: null },
  ];
  assert.deepEqual(pickBestCalculatedPrice(candidates, NOW), { id: "price_sale", amount: 20 });
});

test("pick best price returns null when all candidates excluded", () => {
  const candidates = [
    { id: "price_expired", amount: 10, price_list_id: "plist_4", price_list_ends_at: "2020-01-01T00:00:00Z", price_list_status: "active" },
  ];
  assert.equal(pickBestCalculatedPrice(candidates, NOW), null);
});

test("pick best price breaks ties by first lowest", () => {
  const candidates = [
    { id: "price_a", amount: 30, price_list_id: null, price_list_ends_at: null, price_list_status: null },
    { id: "price_b", amount: 30, price_list_id: null, price_list_ends_at: null, price_list_status: null },
  ];
  const result = pickBestCalculatedPrice(candidates, NOW);
  assert.equal(result.amount, 30);
  assert.ok(["price_a", "price_b"].includes(result.id));
});

Case studies

Flagged Expired, still charging

The seasonal sale that would not end

A store ran a two-week seasonal price list that everyone in the admin correctly saw as "Expired" the morning after it ended. Support still got tickets from repeat customers pointing out the cart total matched the old sale price, not the regular one, on a handful of products.

Running the audit found the price list was still status: "active" in the database with an ends_at nine days in the past, and confirmed against the Store API that three variants were still resolving calculated_price.id to a price from that list. Setting DRY_RUN=false moved it to draft, and a follow-up check confirmed the storefront switched to the regular price within the hour once the CDN cache in front of the region was purged.

Cached region response

The product page that disagreed with its own cart

An engineer noticed a product detail page still showed a discounted price two days after the campaign's ends_at, but adding it to a fresh cart in an incognito window charged full price. The two paths were reading from different caches with different refresh timings for the same expired price list.

The script's confirmation step, which reads calculated_price straight from the Store API rather than trusting the rendered page, caught the exact variant still serving the stale amount. The team purged the CDN cache for that product route and the page matched the cart again, with no changes made to the price list's own dates, since the list itself had already expired correctly.

What good looks like

Run this script whenever a promotion ends, or on a schedule right after your usual sale windows close. It never rewrites a price list's dates or amounts, it only flags a list that is still active past its own ends_at and confirms the exact variants the storefront is still mispricing. The only change it is allowed to make, and only with DRY_RUN=false, is moving that one list to draft, which every read path respects as a hard filter. Pair it with a cache purge in front of your store routes and the sale price actually stops when the sale ends.

FAQ

Why does my Medusa price list still apply after its ends_at date has passed?

Medusa enforces price list expiry only at read time inside the calculated_price pipeline, and there is no background job that deactivates a price list when its ends_at passes. If a product query, a cached storefront response, or the admin's status label evaluates the date window inconsistently, the expired list can still be selected as the best price even though the admin shows it as Expired.

Is it safe to script a fix for an expired price list that is still being served?

Treat it as flag and report first. Silently mutating a live price list can itself cause a pricing incident, so the safe pattern is a DRY_RUN guarded script that only reports the affected price lists and variants by default, and moves the list to status draft or removes just the stale prices only when you explicitly set DRY_RUN to false.

Why does the admin UI show a price list as Expired while the storefront still charges its price?

The admin's Expired label and the storefront's calculated_price read can evaluate the same starts_at and ends_at columns through different code paths, and a cached product or region query can return a stale calculated_price.id that still belongs to the expired list. The status column is a hard filter, but the date window by itself is not enforced everywhere prices are read.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #9376: the price list settings for start and end dates to mark as expired or active do not work on the storefront. github.com/medusajs/medusa/issues/9376
  2. medusajs/medusa GitHub issue #9625: price list not applied. github.com/medusajs/medusa/issues/9625
  3. medusajs/medusa GitHub issue #10613: calculated_price context works only with price lists and does not take other prices into consideration. github.com/medusajs/medusa/issues/10613

On the solution:

  1. Medusa Documentation: the Pricing module. docs.medusajs.com/resources/commerce-modules/pricing
  2. Medusa Reference: the calculatePrices method of the Pricing module. docs.medusajs.com/resources/references/pricing/calculatePrices
  3. Medusa Reference: the CalculatedPriceSet interface. docs.medusajs.com/resources/references/pricing/interfaces/pricing.CalculatedPriceSet

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 stop a sale price from overstaying its welcome?

If this saved you a pricing incident or a pile of confused support tickets, 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