Reconciler Cart and checkout
Stale cart prices after a price change
You update a variant's price or push a new price list, check the storefront on a fresh page load, and everything looks right. But a shopper who added the item to their cart before the change is still looking at the old number, and it will ride all the way to checkout unless something touches that cart again. Here is why Medusa never goes back to fix an open cart on its own, and a script that finds the carts still holding stale prices and repairs them safely.
In Medusa v2, a cart line item's unit_price is calculated once by the Pricing module and saved as a snapshot on the line item, not a live pointer to the price set. Changing a variant's price or a price list's rows only updates the Pricing module's own tables. Nothing re-runs the cart's price calculation until the cart itself is touched again, so any cart left open across a price change keeps quoting the old amount. Run a small Python or Node.js script that lists open carts, compares each non custom priced line item's unit_price against the live price for that variant, currency, and region, and reports the stale ones. Behind a DRY_RUN guard it can also force a safe recompute, one cart at a time.
The problem in plain words
When a shopper adds an item to their cart, Medusa asks the Pricing module's calculatePrices for a number, using the price set plus the context of currency, region, and customer group. That number gets written onto the line item as unit_price and stays there. It is a snapshot taken at one moment, not a live formula that recomputes itself.
Later, someone edits the variant's price through /admin/products/{id}/variants, or updates a price list's rows through /admin/price-lists/{id}/prices. Both of those calls only write to the Pricing module's own tables. Medusa ships no subscriber that listens for that change and goes back to re-run refreshCartItemsWorkflow against every cart that happens to reference that variant. The cart's line item is only recalculated the next time the cart itself is explicitly touched, for example through updateLineItemInCartWorkflow or a quantity change. A cart nobody touches again just keeps its old snapshot, unnoticed, straight through to checkout.
Why it happens
Every one of these is a normal, working part of Medusa v2, not a bug on its own. The gap only shows up when they line up with an open cart:
unit_priceis written once, when the line item is added or last refreshed, from the Pricing module'scalculatePricesusing the price set and the cart's currency, region, and customer group context.- Updating a variant's price with
/admin/products/{id}/variantsor a price list's rows with/admin/price-lists/{id}/pricesonly writes to the Pricing module's own tables. It does not walk the carts that reference that variant. - Medusa has no built-in subscriber that listens for a price change and re-runs
refreshCartItemsWorkflowagainst already-open carts. That recompute only happens the next time the cart itself is explicitly touched, such as throughupdateLineItemInCartWorkflow, a quantity change, or a custom scheduled job you add yourself. - Even when a cart is touched again, any line item flagged
is_custom_price: trueis intentionally skipped by the recalculation, by design, so a manually discounted line does not get silently overwritten. - A cart left idle across the price change, browser tab open, no quantity edits, nothing, simply rides its old snapshot all the way to checkout with no warning to the shopper or the merchant.
This is a common source of confusion because store owners assume a price change is instant everywhere, when it is only instant for new price calculations, not for carts that already have a number written down. See the citations at the end for the exact GitHub issues and discussion.
A stale cart price is not something a script should silently overwrite in bulk. A shopper can be mid checkout right now, and forcing a blanket refresh can also strip a deliberately set custom price or fight a tiered price line item, both reported against Medusa's cart refresh path. The safe pattern is to compute which carts are actually stale with a pure, testable rule, report that list first, and only recompute a specific line item, one cart at a time, when it is confirmed not custom priced.
The fix, as a flow
We do not touch checkout and we do not rewrite prices in bulk. We list open carts with their line items, pull the current live price for each distinct variant, currency, and region combination those carts reference, and run a pure function that flags any non custom priced line item whose stored price no longer matches the live one and that has not been touched since the price changed. Behind DRY_RUN, the repair step forces a real recompute on just the flagged line items, one cart at a time, and confirms the result.
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 and update carts. 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, only logs old vs new price
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, only logs old vs new price
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 open carts with their line items
Ask for carts with their currency_code, region_id, and nested line items, including unit_price, is_custom_price, variant_id, and updated_at. Page through with limit and offset, and keep only carts where completed_at is still null, since a completed or abandoned cart cannot be repaired anyway.
CART_FIELDS = (
"id,updated_at,completed_at,currency_code,region_id,"
"*line_items,line_items.unit_price,line_items.is_custom_price,"
"line_items.variant_id,line_items.updated_at"
)
def list_open_carts(token):
headers = {"Authorization": f"Bearer {token}"}
out, offset, limit = [], 0, 100
while True:
r = requests.get(
f"{BASE_URL}/admin/carts",
params={"fields": CART_FIELDS, "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(c for c in body["carts"] if c.get("completed_at") is None)
offset += limit
if offset >= body["count"]:
return out
const CART_FIELDS =
"id,updated_at,completed_at,currency_code,region_id," +
"*line_items,line_items.unit_price,line_items.is_custom_price," +
"line_items.variant_id,line_items.updated_at";
async function listOpenCarts(sdk) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.cart.list({ fields: CART_FIELDS, limit, offset });
out.push(...body.carts.filter((c) => c.completed_at == null));
offset += limit;
if (offset >= body.count) return out;
}
}
Fetch the live price for every referenced variant
Collect the distinct variant_id values across the open carts, then look up the current price for each one, either from the variant directly with /admin/products/{product_id}/variants/{variant_id}?fields=id,*prices or from the owning price list. Build a lookup map keyed by variant_id:currency_code:region_id so the decision function can look up a match in one step.
def get_variant_prices(token, product_id, variant_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/products/{product_id}/variants/{variant_id}",
params={"fields": "id,*prices"},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["variant"]["prices"]
def build_live_price_map(token, variant_product_pairs, now_iso):
live_prices = {}
for variant_id, product_id in variant_product_pairs:
for p in get_variant_prices(token, product_id, variant_id):
key = f"{variant_id}:{p['currency_code']}:{p.get('rules', {}).get('region_id')}"
live_prices[key] = {
"amount": p["amount"],
"currency_code": p["currency_code"],
"region_id": p.get("rules", {}).get("region_id"),
"updated_at": now_iso,
}
return live_prices
async function getVariantPrices(sdk, productId, variantId) {
const body = await sdk.admin.product.retrieveVariant(productId, variantId, { fields: "id,*prices" });
return body.variant.prices;
}
async function buildLivePriceMap(sdk, variantProductPairs, nowIso) {
const livePrices = new Map();
for (const [variantId, productId] of variantProductPairs) {
const prices = await getVariantPrices(sdk, productId, variantId);
for (const p of prices) {
const regionId = p.rules?.region_id ?? null;
const key = `${variantId}:${p.currency_code}:${regionId}`;
livePrices.set(key, {
amount: p.amount,
currency_code: p.currency_code,
region_id: regionId,
updated_at: nowIso,
});
}
}
return livePrices;
}
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. For every open cart and every line item that is not a custom price, look up the live price by variant_id, currency_code, and region_id. If the amounts differ and the line item was last touched before the live price's own updated_at, it is stale.
def find_stale_cart_line_items(carts, live_prices):
"""Pure: no I/O. carts is a list of open cart dicts with line_items.
live_prices is a dict keyed by "variant_id:currency_code:region_id"."""
flagged = []
for cart in carts:
if cart.get("completed_at") is not None:
continue
for item in cart.get("line_items", []):
if item.get("is_custom_price"):
continue
key = f"{item['variant_id']}:{cart['currency_code']}:{cart['region_id']}"
live = live_prices.get(key)
if live is None:
continue
if live["amount"] == item["unit_price"]:
continue
if item["updated_at"] >= live["updated_at"]:
continue
flagged.append({
"cart_id": cart["id"],
"line_item_id": item["id"],
"old_price": item["unit_price"],
"new_price": live["amount"],
})
return flagged
export function findStaleCartLineItems(carts, livePrices) {
// Pure: no I/O. carts is an array of open cart objects with line_items.
// livePrices is a Map keyed by `${variant_id}:${currency_code}:${region_id}`.
const flagged = [];
for (const cart of carts) {
if (cart.completed_at !== null) continue;
for (const item of cart.line_items || []) {
if (item.is_custom_price) continue;
const key = `${item.variant_id}:${cart.currency_code}:${cart.region_id}`;
const live = livePrices.get(key);
if (!live) continue;
if (live.amount === item.unit_price) continue;
if (item.updated_at >= live.updated_at) continue;
flagged.push({
cart_id: cart.id,
line_item_id: item.id,
old_price: item.unit_price,
new_price: live.amount,
});
}
}
return flagged;
}
Repair one cart at a time, behind a dry run guard
When DRY_RUN is true, only log the cart_id, line_item_id, and old versus new price for every flagged line item. When it is false, call the line item update route with no unit_price override, which lets updateLineItemInCartWorkflow recompute the price from the live price set, then re-fetch the cart to confirm the new unit_price matches. Never batch this, and never touch a line item where is_custom_price is true.
Do not overwrite cart prices in bulk. A shopper can be mid checkout right now, and forcing a refresh across the board can strip a legitimately custom price or break a tiered price line item, both seen in Medusa's own issue tracker. Start with DRY_RUN=true, review the exact list of flagged carts, then flip it off and let the script repair one cart at a time, only for lines where is_custom_price is false.
The full code
Here is the complete script in one file for each language. It authenticates, lists open carts, builds a live price map, flags every stale non custom priced line item with a pure function, and either logs the repair or applies it one cart at a time depending on DRY_RUN.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Flag and safely repair Medusa v2 open carts still holding a stale unit_price
after a variant or price list change. Never touches is_custom_price line items,
never bulk overwrites. DRY_RUN=true only logs old vs new price. Safe to run again
and again, one cart at a time.
"""
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("reconcile_stale_cart_prices")
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"
CART_FIELDS = (
"id,updated_at,completed_at,currency_code,region_id,"
"*line_items,line_items.unit_price,line_items.is_custom_price,"
"line_items.variant_id,line_items.updated_at"
)
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_open_carts(token):
headers = {"Authorization": f"Bearer {token}"}
out, offset, limit = [], 0, 100
while True:
r = requests.get(
f"{BASE_URL}/admin/carts",
params={"fields": CART_FIELDS, "limit": limit, "offset": offset},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(c for c in body["carts"] if c.get("completed_at") is None)
offset += limit
if offset >= body["count"]:
return out
def get_variant_prices(token, product_id, variant_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/products/{product_id}/variants/{variant_id}",
params={"fields": "id,*prices"},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["variant"]["prices"]
def build_live_price_map(token, variant_product_pairs, now_iso):
live_prices = {}
for variant_id, product_id in variant_product_pairs:
for p in get_variant_prices(token, product_id, variant_id):
region_id = (p.get("rules") or {}).get("region_id")
key = f"{variant_id}:{p['currency_code']}:{region_id}"
live_prices[key] = {
"amount": p["amount"],
"currency_code": p["currency_code"],
"region_id": region_id,
"updated_at": now_iso,
}
return live_prices
def find_stale_cart_line_items(carts, live_prices):
"""Pure: no I/O. carts is a list of open cart dicts with line_items.
live_prices is a dict keyed by "variant_id:currency_code:region_id"."""
flagged = []
for cart in carts:
if cart.get("completed_at") is not None:
continue
for item in cart.get("line_items", []):
if item.get("is_custom_price"):
continue
key = f"{item['variant_id']}:{cart['currency_code']}:{cart['region_id']}"
live = live_prices.get(key)
if live is None:
continue
if live["amount"] == item["unit_price"]:
continue
if item["updated_at"] >= live["updated_at"]:
continue
flagged.append({
"cart_id": cart["id"],
"line_item_id": item["id"],
"old_price": item["unit_price"],
"new_price": live["amount"],
})
return flagged
def force_recompute(token, cart_id, line_item_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/carts/{cart_id}/line-items/{line_item_id}",
json={},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()
def get_cart(token, cart_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/carts/{cart_id}",
params={"fields": CART_FIELDS},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["cart"]
def run():
token = get_token()
carts = list_open_carts(token)
variant_product_pairs = set()
for cart in carts:
for item in cart.get("line_items", []):
product_id = item.get("product_id")
if product_id:
variant_product_pairs.add((item["variant_id"], product_id))
now_iso = datetime.now(timezone.utc).isoformat()
live_prices = build_live_price_map(token, variant_product_pairs, now_iso)
flagged = find_stale_cart_line_items(carts, live_prices)
if not flagged:
log.info("No stale cart line items found across %d open cart(s).", len(carts))
return
for f in flagged:
log.info(
"Cart %s line item %s: old_price=%s new_price=%s. %s",
f["cart_id"], f["line_item_id"], f["old_price"], f["new_price"],
"Would repair" if DRY_RUN else "Repairing",
)
if not DRY_RUN:
force_recompute(token, f["cart_id"], f["line_item_id"])
refreshed = get_cart(token, f["cart_id"])
confirmed = any(
li["id"] == f["line_item_id"] and li["unit_price"] == f["new_price"]
for li in refreshed.get("line_items", [])
)
log.info("Cart %s line item %s confirmed: %s", f["cart_id"], f["line_item_id"], confirmed)
log.info("Done. %d stale line item(s) %s.", len(flagged), "to repair" if DRY_RUN else "repaired")
if __name__ == "__main__":
run()
/**
* Flag and safely repair Medusa v2 open carts still holding a stale unit_price
* after a variant or price list change. Never touches is_custom_price line items,
* never bulk overwrites. DRY_RUN=true only logs old vs new price. Safe to run
* again and again, one cart at a time.
*/
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 CART_FIELDS =
"id,updated_at,completed_at,currency_code,region_id," +
"*line_items,line_items.unit_price,line_items.is_custom_price," +
"line_items.variant_id,line_items.updated_at";
export function findStaleCartLineItems(carts, livePrices) {
// Pure: no I/O. carts is an array of open cart objects with line_items.
// livePrices is a Map keyed by `${variant_id}:${currency_code}:${region_id}`.
const flagged = [];
for (const cart of carts) {
if (cart.completed_at !== null) continue;
for (const item of cart.line_items || []) {
if (item.is_custom_price) continue;
const key = `${item.variant_id}:${cart.currency_code}:${cart.region_id}`;
const live = livePrices.get(key);
if (!live) continue;
if (live.amount === item.unit_price) continue;
if (item.updated_at >= live.updated_at) continue;
flagged.push({
cart_id: cart.id,
line_item_id: item.id,
old_price: item.unit_price,
new_price: live.amount,
});
}
}
return flagged;
}
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 listOpenCarts(sdk) {
const out = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.cart.list({ fields: CART_FIELDS, limit, offset });
out.push(...body.carts.filter((c) => c.completed_at == null));
offset += limit;
if (offset >= body.count) return out;
}
}
async function getVariantPrices(sdk, productId, variantId) {
const body = await sdk.admin.product.retrieveVariant(productId, variantId, { fields: "id,*prices" });
return body.variant.prices;
}
async function buildLivePriceMap(sdk, variantProductPairs, nowIso) {
const livePrices = new Map();
for (const [variantId, productId] of variantProductPairs) {
const prices = await getVariantPrices(sdk, productId, variantId);
for (const p of prices) {
const regionId = p.rules?.region_id ?? null;
const key = `${variantId}:${p.currency_code}:${regionId}`;
livePrices.set(key, {
amount: p.amount,
currency_code: p.currency_code,
region_id: regionId,
updated_at: nowIso,
});
}
}
return livePrices;
}
async function forceRecompute(sdk, cartId, lineItemId) {
return sdk.admin.cart.updateLineItem(cartId, lineItemId, {});
}
async function getCart(sdk, cartId) {
const body = await sdk.admin.cart.retrieve(cartId, { fields: CART_FIELDS });
return body.cart;
}
export async function run() {
const sdk = await login();
const carts = await listOpenCarts(sdk);
const variantProductPairs = new Set();
const pairsList = [];
for (const cart of carts) {
for (const item of cart.line_items || []) {
if (item.product_id) {
const key = `${item.variant_id}:${item.product_id}`;
if (!variantProductPairs.has(key)) {
variantProductPairs.add(key);
pairsList.push([item.variant_id, item.product_id]);
}
}
}
}
const nowIso = new Date().toISOString();
const livePrices = await buildLivePriceMap(sdk, pairsList, nowIso);
const flagged = findStaleCartLineItems(carts, livePrices);
if (flagged.length === 0) {
console.log(`No stale cart line items found across ${carts.length} open cart(s).`);
return;
}
for (const f of flagged) {
console.log(
`Cart ${f.cart_id} line item ${f.line_item_id}: old_price=${f.old_price} new_price=${f.new_price}. ${DRY_RUN ? "Would repair" : "Repairing"}`
);
if (!DRY_RUN) {
await forceRecompute(sdk, f.cart_id, f.line_item_id);
const refreshed = await getCart(sdk, f.cart_id);
const confirmed = (refreshed.line_items || []).some(
(li) => li.id === f.line_item_id && li.unit_price === f.new_price
);
console.log(`Cart ${f.cart_id} line item ${f.line_item_id} confirmed: ${confirmed}`);
}
}
console.log(`Done. ${flagged.length} stale line item(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 one that decides the outcome, find_stale_cart_line_items. It is pure, current time is passed in through the live price map's own updated_at, so the tests feed in plain cart and price objects, no Medusa backend required.
from reconcile_stale_cart_prices import find_stale_cart_line_items
def cart(**over):
base = {
"id": "cart_1",
"currency_code": "usd",
"region_id": "reg_1",
"completed_at": None,
"line_items": [
{
"id": "item_1",
"variant_id": "variant_1",
"unit_price": 1000,
"is_custom_price": False,
"updated_at": "2026-07-01T00:00:00Z",
}
],
}
base.update(over)
return base
def live_map(**over):
base = {
"variant_1:usd:reg_1": {
"amount": 1200,
"currency_code": "usd",
"region_id": "reg_1",
"updated_at": "2026-07-05T00:00:00Z",
}
}
base.update(over)
return base
def test_flags_stale_line_item_touched_before_price_change():
result = find_stale_cart_line_items([cart()], live_map())
assert result == [{"cart_id": "cart_1", "line_item_id": "item_1", "old_price": 1000, "new_price": 1200}]
def test_skips_custom_price_line_item():
c = cart()
c["line_items"][0]["is_custom_price"] = True
assert find_stale_cart_line_items([c], live_map()) == []
def test_skips_when_price_already_matches():
c = cart()
c["line_items"][0]["unit_price"] = 1200
assert find_stale_cart_line_items([c], live_map()) == []
def test_skips_completed_cart():
c = cart(completed_at="2026-07-06T00:00:00Z")
assert find_stale_cart_line_items([c], live_map()) == []
def test_skips_line_item_touched_after_price_change():
c = cart()
c["line_items"][0]["updated_at"] = "2026-07-06T00:00:00Z"
assert find_stale_cart_line_items([c], live_map()) == []
def test_skips_when_no_live_price_match():
assert find_stale_cart_line_items([cart()], live_map(**{})) != None
assert find_stale_cart_line_items([cart()], {}) == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { findStaleCartLineItems } from "./reconcile-stale-cart-prices.js";
const cart = (over = {}) => ({
id: "cart_1",
currency_code: "usd",
region_id: "reg_1",
completed_at: null,
line_items: [
{
id: "item_1",
variant_id: "variant_1",
unit_price: 1000,
is_custom_price: false,
updated_at: "2026-07-01T00:00:00Z",
},
],
...over,
});
const liveMap = () =>
new Map([
[
"variant_1:usd:reg_1",
{ amount: 1200, currency_code: "usd", region_id: "reg_1", updated_at: "2026-07-05T00:00:00Z" },
],
]);
test("flags stale line item touched before price change", () => {
const result = findStaleCartLineItems([cart()], liveMap());
assert.deepEqual(result, [{ cart_id: "cart_1", line_item_id: "item_1", old_price: 1000, new_price: 1200 }]);
});
test("skips custom price line item", () => {
const c = cart();
c.line_items[0].is_custom_price = true;
assert.deepEqual(findStaleCartLineItems([c], liveMap()), []);
});
test("skips when price already matches", () => {
const c = cart();
c.line_items[0].unit_price = 1200;
assert.deepEqual(findStaleCartLineItems([c], liveMap()), []);
});
test("skips completed cart", () => {
const c = cart({ completed_at: "2026-07-06T00:00:00Z" });
assert.deepEqual(findStaleCartLineItems([c], liveMap()), []);
});
test("skips line item touched after price change", () => {
const c = cart();
c.line_items[0].updated_at = "2026-07-06T00:00:00Z";
assert.deepEqual(findStaleCartLineItems([c], liveMap()), []);
});
test("skips when no live price match", () => {
assert.deepEqual(findStaleCartLineItems([cart()], new Map()), []);
});
Case studies
The cart that missed the price going back up
A store ran a weekend flash sale by lowering a bestseller's variant price directly, then raised it back to normal on Monday morning through the admin. A shopper had added the item to their cart Sunday night and did not check out until Tuesday. Their cart still quoted the Sunday sale price, two days after it ended, because nothing about raising the price back up touched their open cart.
Running the reconciler in dry run surfaced that single cart immediately, with the old sale price and the new regular price side by side. The team repaired it by hand before the order shipped at a loss, instead of finding out from a support ticket after the fact.
The B2B price list edit that left old carts behind
A wholesale merchant edited a customer group's price list to reflect a new supplier cost, updating dozens of rows in one sitting. Several buyers in that group had carts sitting open from earlier in the week while they finalized purchase orders internally.
Because the update only touched the Pricing module's tables, those open carts kept the old rows. The script flagged every affected line item across the open carts, skipping the handful that were separately marked is_custom_price for a negotiated discount, and the merchant repaired only the ones that should track the new list price.
Run this after any variant price update or price list edit that matters, or on a schedule alongside your other pricing checks. It never rewrites a cart on its own. It tells you exactly which open carts still hold a stale, non custom price, and only recomputes a specific line item when you confirm it with DRY_RUN=false, one cart at a time, with the result checked right after. Custom prices and mid checkout carts stay untouched, and a price change finally reaches the shoppers who are still deciding.
FAQ
Why does a Medusa cart keep showing the old price after I change it?
A cart line item's unit_price is calculated once from the Pricing module and stored as a snapshot on the item when it is added or last touched. Updating a variant's price or a price list only writes to the Pricing module's own tables. Medusa has no built-in subscriber that goes back and re-runs the cart's price calculation, so any cart left open across the change keeps the old snapshot.
Will refreshing the cart overwrite a legitimate custom price?
It should not if you check is_custom_price first. Medusa's updateLineItemInCartWorkflow intentionally skips recalculating any line item flagged is_custom_price: true, and forcing a refresh across the board can also fight tiered pricing in some reported cases, so the safe rule is to only recompute lines where is_custom_price is false.
Is it safe to bulk update open carts to the new price?
Not by default. A customer can be mid checkout, and Medusa's issue tracker has reports of price list refreshes interacting badly with tiered prices and custom line items. Treat this as flag and report first with DRY_RUN=true, then repair one cart at a time behind an explicit flag once you have reviewed the list.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub issue #11103: Price list prices not updating properly. github.com/medusajs/medusa/issues/11103
- medusajs/medusa GitHub issue #13262: Price tiers and refreshCartItemsWorkflow are not compatible. github.com/medusajs/medusa/issues/13262
- medusajs/medusa GitHub discussion #9807: Ability to set a custom price on a line item and have it not get refreshed when updating the cart. github.com/medusajs/medusa/discussions/9807
On the solution:
- Medusa Documentation: Prices Calculation in the Pricing module. docs.medusajs.com/resources/commerce-modules/pricing/price-calculation
- Medusa Documentation: Implement Custom Line Item Pricing in Medusa. docs.medusajs.com/resources/examples/guides/custom-item-price
- Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
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 catch a stale cart before it shipped?
If this saved you a lost margin or a confused customer, 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