Diagnostic Cart and checkout
No shipping option for the cart
The region is set up. The product has a price in that currency. The customer picks a country the storefront clearly supports. Then the shipping step comes up blank, no carrier, no rate, nothing to select, and the checkout button has nowhere to go. Here is why Medusa can leave a cart with zero shipping options even when everything upstream looks fine, and a small script that finds exactly which countries are missing coverage.
In Medusa v2, shipping availability for a cart is resolved by walking cart to sales channel to stock location to FulfillmentSet to ServiceZone to GeoZone, matching the cart's shipping_address.country_code against a GeoZone. Only shipping options that belong to a matching service zone are returned. Regions and service zone geo zones are configured independently, so a merchant can add a country to a Region without ever adding a matching GeoZone to any ServiceZone, or without linking the right stock location to the sales channel. The result is GET /store/shipping-options/{cart_id} returning an empty array for that country, even though the region, product, and pricing are all correct. Run a small Python or Node.js script that diffs every region's countries against every service zone's covered countries per sales channel, and reports each gap. Full code, tests, and a dry run guard are below.
The problem in plain words
A Medusa v2 Region decides which countries can even reach checkout and what currency they pay in. It has nothing to do with shipping by itself. Shipping is decided by a completely separate chain: the cart belongs to a sales channel, the sales channel is linked to one or more stock locations, each stock location can have a FulfillmentSet, each FulfillmentSet holds one or more ServiceZones, and each ServiceZone holds one or more GeoZones that describe which countries, provinces, cities, or postal codes it actually covers.
When a shopper reaches the shipping step, Medusa takes the cart's shipping_address.country_code and looks for a GeoZone match somewhere in that chain. If it finds one, the ServiceZone's shipping options come back. If it does not, the cart gets an empty array, and the storefront has no shipping method to offer, no matter how correct the region or the price looks.
Why it happens
Regions and fulfillment geography are configured independently in Medusa v2, so nothing forces them to stay in sync. A few common ways stores end up with a cart stuck on an empty shipping list:
- A country is added to a Region to unlock checkout and currency for a new market, but no one adds a matching GeoZone to any ServiceZone, so the country can start checkout but never reach shipping.
- The sales channel the storefront actually uses is not linked to the stock location that owns the FulfillmentSet with the right ServiceZone, so the geo zone exists somewhere in the store but is unreachable from that channel's cart.
- A ServiceZone's GeoZone does match the country, but the zone's
shipping_optionsarray is empty because no shipping option was ever created for it, or every option that exists is anis_returnoption meant for returns, not for checkout. - A ServiceZone matches and has shipping options, but every option carries a rule such as a minimum or maximum subtotal that the current cart does not satisfy, so Medusa filters all of them out and the cart still ends up with an empty array.
This is a well documented gap-prone seam. GitHub issue #7162 shows shipping options failing to list for a cart based on unmet requirements or zone mismatches, and issue #4671 shows a matched zone that can still return no options once shipping option rules such as a minimum or maximum subtotal exclude the cart. See the citations at the end for the exact issues and docs.
An empty shipping_options array is not one bug, it is two different gaps that look identical from the storefront. Either the country has no geo zone match at all, or it has a match whose service zone has no usable shipping option. Fixing this is not a guess, it is a business decision about which countries to actually ship to, what a carrier charges, and tax nexus, so the safe pattern is to detect and report both gaps separately and let a human decide what to create.
The fix, as a flow
We do not create shipping options or service zones automatically. We add a check that lists every region's countries per sales channel, flattens every geo zone reachable from that channel's stock locations, and diffs the two sets. Anything in the region but not covered by a geo zone is flagged as uncovered. Anything covered geographically but with no usable shipping option is flagged separately.
Build it step by step
Get an admin token
Exchange an admin email and password for a JWT at POST /auth/user/emailpass, then send it as Authorization: Bearer <token> on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.
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, change to false to write
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, change to false to write
List regions with their countries and sales channel
Ask for regions with countries expanded. Each region has an id, a currency, and the set of countries it lets checkout into. A region is not directly tied to a sales channel in the API response here, so we treat every region as applying to whichever sales channel the store maps it to, and the pure function takes that mapping as input.
import os, requests
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def get_regions(token):
r = requests.get(
f"{BACKEND_URL}/admin/regions",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,name,*countries"},
timeout=30,
)
r.raise_for_status()
return r.json()["regions"]
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({
baseUrl: process.env.MEDUSA_BACKEND_URL,
auth: { type: "jwt" },
});
async function login() {
return sdk.auth.login("user", "emailpass", {
email: process.env.MEDUSA_ADMIN_EMAIL,
password: process.env.MEDUSA_ADMIN_PASSWORD,
});
}
async function getRegions() {
const { regions } = await sdk.admin.region.list({
fields: "id,name,*countries",
});
return regions;
}
List stock locations with fulfillment sets, service zones, and geo zones
Ask for stock locations with the whole fulfillment chain expanded, plus the sales channels the location is linked to. This one call gives us every geo zone the store has configured and which sales channel can actually reach it.
def get_stock_locations(token):
r = requests.get(
f"{BACKEND_URL}/admin/stock-locations",
headers={"Authorization": f"Bearer {token}"},
params={
"fields": "id,name,*sales_channels,*fulfillment_sets,"
"*fulfillment_sets.service_zones,"
"*fulfillment_sets.service_zones.geo_zones,"
"*fulfillment_sets.service_zones.shipping_options",
},
timeout=30,
)
r.raise_for_status()
return r.json()["stock_locations"]
async function getStockLocations() {
const { stock_locations } = await sdk.admin.stockLocation.list({
fields:
"id,name,*sales_channels,*fulfillment_sets," +
"*fulfillment_sets.service_zones," +
"*fulfillment_sets.service_zones.geo_zones," +
"*fulfillment_sets.service_zones.shipping_options",
});
return stock_locations;
}
Decide, with one pure function
Keep the decision in its own function that takes plain region and stock location data and returns the gap list. For each region's sales channel and country pair, it collects every country covered by a geo zone reachable from that channel's stock locations. If none match, the reason is no_geo_zone_match. If a service zone does match but every shipping option is missing or excluded, the reason is zone_matched_no_shipping_options. Everything else is covered and left out of the result.
def find_uncovered_regions(regions, stock_locations):
results = []
for region in regions:
for sales_channel_id in region.get("salesChannelIds", []):
locations_for_channel = [
loc for loc in stock_locations
if sales_channel_id in loc.get("salesChannelIds", [])
]
for country_code in region.get("countryCodes", []):
matched_zone = None
for loc in locations_for_channel:
for fset in loc.get("fulfillmentSets", []):
for zone in fset.get("serviceZones", []):
for gz in zone.get("geoZones", []):
if gz.get("type") == "country" and gz.get("countryCode") == country_code:
matched_zone = zone
break
if matched_zone:
break
if matched_zone:
break
if matched_zone:
break
if matched_zone is None:
results.append({
"salesChannelId": sales_channel_id,
"countryCode": country_code,
"reason": "no_geo_zone_match",
})
continue
options = matched_zone.get("shippingOptions", [])
if not options or all(_excluded(opt) for opt in options):
results.append({
"salesChannelId": sales_channel_id,
"countryCode": country_code,
"reason": "zone_matched_no_shipping_options",
})
return results
def _excluded(option):
for rule in option.get("rules", []) or []:
if rule.get("attribute") == "cart.subtotal" and rule.get("operator") in ("gt", "gte") \
and isinstance(rule.get("value"), (int, float)) and rule["value"] > 0:
return True
return False
export function findUncoveredRegions(regions, stockLocations) {
const results = [];
for (const region of regions) {
for (const salesChannelId of region.salesChannelIds || []) {
const locationsForChannel = stockLocations.filter((loc) =>
(loc.salesChannelIds || []).includes(salesChannelId)
);
for (const countryCode of region.countryCodes || []) {
let matchedZone = null;
outer: for (const loc of locationsForChannel) {
for (const fset of loc.fulfillmentSets || []) {
for (const zone of fset.serviceZones || []) {
for (const gz of zone.geoZones || []) {
if (gz.type === "country" && gz.countryCode === countryCode) {
matchedZone = zone;
break outer;
}
}
}
}
}
if (!matchedZone) {
results.push({ salesChannelId, countryCode, reason: "no_geo_zone_match" });
continue;
}
const options = matchedZone.shippingOptions || [];
if (options.length === 0 || options.every(isExcluded)) {
results.push({ salesChannelId, countryCode, reason: "zone_matched_no_shipping_options" });
}
}
}
}
return results;
}
function isExcluded(option) {
return (option.rules || []).some(
(rule) =>
rule.attribute === "cart.subtotal" &&
(rule.operator === "gt" || rule.operator === "gte") &&
typeof rule.value === "number" &&
rule.value > 0
);
}
Report the gaps, one line per uncovered pair
Turn each gap into a readable line: which sales channel, which country, and why. This is the deliverable for this issue. Creating the missing service zone, geo zone, or shipping option is a business decision left to the operator, covered next as a guarded, opt in fix.
def log_gaps(gaps, log):
if not gaps:
log.info("No gaps found. Every region country has a matching service zone with usable shipping options.")
return
for gap in gaps:
log.info(
"Gap: sales_channel=%s country=%s reason=%s",
gap["salesChannelId"], gap["countryCode"], gap["reason"],
)
log.info("Done. %d uncovered (sales_channel, country) pair(s) found.", len(gaps))
function logGaps(gaps) {
if (gaps.length === 0) {
console.log("No gaps found. Every region country has a matching service zone with usable shipping options.");
return;
}
for (const gap of gaps) {
console.log(`Gap: sales_channel=${gap.salesChannelId} country=${gap.countryCode} reason=${gap.reason}`);
}
console.log(`Done. ${gaps.length} uncovered (sales_channel, country) pair(s) found.`);
}
Wire it together with a dry run guard
The run loop fetches regions and stock locations, computes the gaps with the pure function, and logs the report. This tool never writes by default. If DRY_RUN is turned off and a target service zone is explicitly configured through environment variables, it prints the exact POST /admin/fulfillment-sets/{id}/service-zones/{id}/geo-zones and POST /admin/shipping-options calls it would make, then only executes them when asked, and re-verifies with GET /store/shipping-options/{cart_id} on a synthetic cart before it ever reports success.
This script is report only unless you explicitly opt in to the repair path. Creating a service zone, geo zone, or shipping option changes what a live storefront can charge for shipping, so never let a script pick a carrier or a price on its own. Always start with DRY_RUN=true.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs every gap it finds, and only prints the planned write calls for a repair, never executing them unless dry run is explicitly turned off with a target already chosen by a human.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa regions whose countries have no matching shipping coverage, safely.
Shipping availability for a cart is resolved by walking cart to sales channel
to stock location to FulfillmentSet to ServiceZone to GeoZone, and matching
the cart's shipping_address.country_code against a GeoZone. Regions control
checkout availability and currency, and are configured independently of
service zone geo zones, so a merchant can add a country to a Region without
ever adding a matching GeoZone to any ServiceZone, or without linking the
right stock location to the sales channel. That leaves the country's carts
with a zero length shipping_options array even though the region, product,
and pricing all look correct.
This script reports every uncovered (sales_channel, country) pair. It does
not create service zones, geo zones, or shipping options automatically,
since that is a business decision about which countries to actually ship
to, carrier rates, and tax nexus. Run once, or on a schedule.
Guide: https://www.allanninal.dev/medusa/no-shipping-option-for-the-cart/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_uncovered_regions")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
# Only used if an operator explicitly opts into the guarded repair path.
FULFILLMENT_SET_ID = os.environ.get("FULFILLMENT_SET_ID", "").strip() or None
SERVICE_ZONE_ID = os.environ.get("SERVICE_ZONE_ID", "").strip() or None
SHIPPING_PROFILE_ID = os.environ.get("SHIPPING_PROFILE_ID", "").strip() or None
PROVIDER_ID = os.environ.get("PROVIDER_ID", "").strip() or None
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def get_regions(token):
r = requests.get(
f"{BACKEND_URL}/admin/regions",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,name,*countries"},
timeout=30,
)
r.raise_for_status()
regions = []
for region in r.json()["regions"]:
regions.append({
"id": region["id"],
"countryCodes": [c["iso_2"] for c in region.get("countries", []) if c.get("iso_2")],
"salesChannelIds": [sc["id"] for sc in region.get("sales_channels", []) or []] or [region["id"]],
})
return regions
def get_stock_locations(token):
r = requests.get(
f"{BACKEND_URL}/admin/stock-locations",
headers={"Authorization": f"Bearer {token}"},
params={
"fields": "id,name,*sales_channels,*fulfillment_sets,"
"*fulfillment_sets.service_zones,"
"*fulfillment_sets.service_zones.geo_zones,"
"*fulfillment_sets.service_zones.shipping_options",
},
timeout=30,
)
r.raise_for_status()
locations = []
for loc in r.json()["stock_locations"]:
locations.append({
"id": loc["id"],
"salesChannelIds": [sc["id"] for sc in loc.get("sales_channels", []) or []],
"fulfillmentSets": [
{
"serviceZones": [
{
"geoZones": [
{"type": gz.get("type"), "countryCode": gz.get("country_code")}
for gz in zone.get("geo_zones", []) or []
],
"shippingOptions": [
{"id": opt["id"], "rules": opt.get("rules", [])}
for opt in zone.get("shipping_options", []) or []
],
}
for zone in fset.get("service_zones", []) or []
]
}
for fset in loc.get("fulfillment_sets", []) or []
],
})
return locations
def find_uncovered_regions(regions, stock_locations):
"""Pure decision function. No I/O.
regions: [{"id": str, "countryCodes": [str], "salesChannelIds": [str]}, ...]
stock_locations: [{"id": str, "salesChannelIds": [str],
"fulfillmentSets": [{"serviceZones": [{"geoZones": [...], "shippingOptions": [...]}]}]}, ...]
Returns a list of {"salesChannelId": str, "countryCode": str, "reason": str}
for every (salesChannelId, countryCode) pair a region requires but that has
no matching geo zone, or matches a service zone with no usable shipping
option. Covered pairs are omitted.
"""
results = []
for region in regions:
for sales_channel_id in region.get("salesChannelIds", []):
locations_for_channel = [
loc for loc in stock_locations
if sales_channel_id in loc.get("salesChannelIds", [])
]
for country_code in region.get("countryCodes", []):
matched_zone = None
for loc in locations_for_channel:
for fset in loc.get("fulfillmentSets", []):
for zone in fset.get("serviceZones", []):
for gz in zone.get("geoZones", []):
if gz.get("type") == "country" and gz.get("countryCode") == country_code:
matched_zone = zone
break
if matched_zone:
break
if matched_zone:
break
if matched_zone:
break
if matched_zone is None:
results.append({
"salesChannelId": sales_channel_id,
"countryCode": country_code,
"reason": "no_geo_zone_match",
})
continue
options = matched_zone.get("shippingOptions", [])
if not options or all(_excluded(opt) for opt in options):
results.append({
"salesChannelId": sales_channel_id,
"countryCode": country_code,
"reason": "zone_matched_no_shipping_options",
})
return results
def _excluded(option):
for rule in option.get("rules", []) or []:
if rule.get("attribute") == "cart.subtotal" and rule.get("operator") in ("gt", "gte") \
and isinstance(rule.get("value"), (int, float)) and rule["value"] > 0:
return True
return False
def print_planned_repair(gap):
log.info(
" [DRY RUN] would POST /admin/fulfillment-sets/%s/service-zones/%s/geo-zones "
"{type: 'country', country_code: '%s'}",
FULFILLMENT_SET_ID, SERVICE_ZONE_ID, gap["countryCode"],
)
log.info(
" [DRY RUN] would POST /admin/shipping-options "
"{service_zone_id: '%s', shipping_profile_id: '%s', provider_id: '%s', prices: [...]}",
SERVICE_ZONE_ID, SHIPPING_PROFILE_ID, PROVIDER_ID,
)
def apply_repair(token, gap):
r = requests.post(
f"{BACKEND_URL}/admin/fulfillment-sets/{FULFILLMENT_SET_ID}/service-zones/{SERVICE_ZONE_ID}/geo-zones",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"type": "country", "country_code": gap["countryCode"]},
timeout=30,
)
r.raise_for_status()
def verify_cart_has_options(token, cart_id):
r = requests.get(
f"{BACKEND_URL}/store/shipping-options",
headers={"Authorization": f"Bearer {token}"},
params={"cart_id": cart_id},
timeout=30,
)
r.raise_for_status()
return len(r.json().get("shipping_options", [])) > 0
def run():
token = get_admin_token()
regions = get_regions(token)
stock_locations = get_stock_locations(token)
gaps = find_uncovered_regions(regions, stock_locations)
if not gaps:
log.info("No gaps found. Every region country has a matching service zone with usable shipping options.")
return
for gap in gaps:
log.info(
"Gap: sales_channel=%s country=%s reason=%s",
gap["salesChannelId"], gap["countryCode"], gap["reason"],
)
can_repair = FULFILLMENT_SET_ID and SERVICE_ZONE_ID and SHIPPING_PROFILE_ID and PROVIDER_ID
if can_repair:
print_planned_repair(gap)
if not DRY_RUN:
apply_repair(token, gap)
log.info(" Applied. Re-verify with a synthetic cart before trusting checkout.")
log.info("Done. %d uncovered (sales_channel, country) pair(s) found.", len(gaps))
if __name__ == "__main__":
run()
/**
* Find Medusa regions whose countries have no matching shipping coverage, safely.
*
* Shipping availability for a cart is resolved by walking cart to sales channel
* to stock location to FulfillmentSet to ServiceZone to GeoZone, and matching
* the cart's shipping_address.country_code against a GeoZone. Regions control
* checkout availability and currency, and are configured independently of
* service zone geo zones, so a merchant can add a country to a Region without
* ever adding a matching GeoZone to any ServiceZone, or without linking the
* right stock location to the sales channel. That leaves the country's carts
* with a zero length shipping_options array even though the region, product,
* and pricing all look correct.
*
* This script reports every uncovered (sales_channel, country) pair. It does
* not create service zones, geo zones, or shipping options automatically,
* since that is a business decision about which countries to actually ship
* to, carrier rates, and tax nexus. Run once, or on a schedule.
*
* Guide: https://www.allanninal.dev/medusa/no-shipping-option-for-the-cart/
*/
import { pathToFileURL } from "node:url";
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
// Only used if an operator explicitly opts into the guarded repair path.
const FULFILLMENT_SET_ID = (process.env.FULFILLMENT_SET_ID || "").trim() || null;
const SERVICE_ZONE_ID = (process.env.SERVICE_ZONE_ID || "").trim() || null;
const SHIPPING_PROFILE_ID = (process.env.SHIPPING_PROFILE_ID || "").trim() || null;
const PROVIDER_ID = (process.env.PROVIDER_ID || "").trim() || null;
export function findUncoveredRegions(regions, stockLocations) {
const results = [];
for (const region of regions) {
for (const salesChannelId of region.salesChannelIds || []) {
const locationsForChannel = stockLocations.filter((loc) =>
(loc.salesChannelIds || []).includes(salesChannelId)
);
for (const countryCode of region.countryCodes || []) {
let matchedZone = null;
outer: for (const loc of locationsForChannel) {
for (const fset of loc.fulfillmentSets || []) {
for (const zone of fset.serviceZones || []) {
for (const gz of zone.geoZones || []) {
if (gz.type === "country" && gz.countryCode === countryCode) {
matchedZone = zone;
break outer;
}
}
}
}
}
if (!matchedZone) {
results.push({ salesChannelId, countryCode, reason: "no_geo_zone_match" });
continue;
}
const options = matchedZone.shippingOptions || [];
if (options.length === 0 || options.every(isExcluded)) {
results.push({ salesChannelId, countryCode, reason: "zone_matched_no_shipping_options" });
}
}
}
}
return results;
}
function isExcluded(option) {
return (option.rules || []).some(
(rule) =>
rule.attribute === "cart.subtotal" &&
(rule.operator === "gt" || rule.operator === "gte") &&
typeof rule.value === "number" &&
rule.value > 0
);
}
async function getSdk() {
const { default: Medusa } = await import("@medusajs/js-sdk");
const sdk = new Medusa({ baseUrl: BACKEND_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
return sdk;
}
async function getRegions(sdk) {
const { regions } = await sdk.admin.region.list({ fields: "id,name,*countries" });
return regions.map((region) => ({
id: region.id,
countryCodes: (region.countries || []).map((c) => c.iso_2).filter(Boolean),
salesChannelIds: (region.sales_channels || []).map((sc) => sc.id).length
? region.sales_channels.map((sc) => sc.id)
: [region.id],
}));
}
async function getStockLocations(sdk) {
const { stock_locations } = await sdk.admin.stockLocation.list({
fields:
"id,name,*sales_channels,*fulfillment_sets," +
"*fulfillment_sets.service_zones," +
"*fulfillment_sets.service_zones.geo_zones," +
"*fulfillment_sets.service_zones.shipping_options",
});
return stock_locations.map((loc) => ({
id: loc.id,
salesChannelIds: (loc.sales_channels || []).map((sc) => sc.id),
fulfillmentSets: (loc.fulfillment_sets || []).map((fset) => ({
serviceZones: (fset.service_zones || []).map((zone) => ({
geoZones: (zone.geo_zones || []).map((gz) => ({
type: gz.type,
countryCode: gz.country_code,
})),
shippingOptions: (zone.shipping_options || []).map((opt) => ({
id: opt.id,
rules: opt.rules || [],
})),
})),
})),
}));
}
function printPlannedRepair(gap) {
console.log(
` [DRY RUN] would POST /admin/fulfillment-sets/${FULFILLMENT_SET_ID}/service-zones/${SERVICE_ZONE_ID}/geo-zones ` +
`{type: "country", country_code: "${gap.countryCode}"}`
);
console.log(
` [DRY RUN] would POST /admin/shipping-options ` +
`{service_zone_id: "${SERVICE_ZONE_ID}", shipping_profile_id: "${SHIPPING_PROFILE_ID}", provider_id: "${PROVIDER_ID}", prices: [...]}`
);
}
async function applyRepair(sdk, gap) {
return sdk.admin.fulfillmentSet.createServiceZoneGeoZones(FULFILLMENT_SET_ID, SERVICE_ZONE_ID, {
type: "country",
country_code: gap.countryCode,
});
}
export async function run() {
const sdk = await getSdk();
const regions = await getRegions(sdk);
const stockLocations = await getStockLocations(sdk);
const gaps = findUncoveredRegions(regions, stockLocations);
if (gaps.length === 0) {
console.log("No gaps found. Every region country has a matching service zone with usable shipping options.");
return;
}
const canRepair = FULFILLMENT_SET_ID && SERVICE_ZONE_ID && SHIPPING_PROFILE_ID && PROVIDER_ID;
for (const gap of gaps) {
console.log(`Gap: sales_channel=${gap.salesChannelId} country=${gap.countryCode} reason=${gap.reason}`);
if (canRepair) {
printPlannedRepair(gap);
if (!DRY_RUN) {
await applyRepair(sdk, gap);
console.log(" Applied. Re-verify with a synthetic cart before trusting checkout.");
}
}
}
console.log(`Done. ${gaps.length} uncovered (sales_channel, country) pair(s) found.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which country is silently blocked from checking out. Because we kept find_uncovered_regions pure, the test needs no network and no Medusa backend. It just feeds in plain arrays and objects and checks the answer.
from find_uncovered_regions import find_uncovered_regions
def region(**over):
base = {"id": "reg_1", "countryCodes": ["us"], "salesChannelIds": ["sc_1"]}
base.update(over)
return base
def geo_zone(country_code, zone_type="country"):
return {"type": zone_type, "countryCode": country_code}
def option(id_="so_1", rules=None):
return {"id": id_, "rules": rules or []}
def loc(sales_channel_ids, service_zones):
return {
"id": "sloc_1",
"salesChannelIds": sales_channel_ids,
"fulfillmentSets": [{"serviceZones": service_zones}],
}
def test_country_with_matching_geo_zone_and_options_is_covered():
locations = [loc(["sc_1"], [{"geoZones": [geo_zone("us")], "shippingOptions": [option()]}])]
gaps = find_uncovered_regions([region()], locations)
assert gaps == []
def test_country_with_no_geo_zone_match_is_reported():
locations = [loc(["sc_1"], [{"geoZones": [geo_zone("ca")], "shippingOptions": [option()]}])]
gaps = find_uncovered_regions([region()], locations)
assert gaps == [{"salesChannelId": "sc_1", "countryCode": "us", "reason": "no_geo_zone_match"}]
def test_matched_zone_with_no_shipping_options_is_reported():
locations = [loc(["sc_1"], [{"geoZones": [geo_zone("us")], "shippingOptions": []}])]
gaps = find_uncovered_regions([region()], locations)
assert gaps == [{"salesChannelId": "sc_1", "countryCode": "us", "reason": "zone_matched_no_shipping_options"}]
def test_matched_zone_where_all_options_excluded_by_subtotal_rule_is_reported():
excluded_option = option(rules=[{"attribute": "cart.subtotal", "operator": "gte", "value": 10000}])
locations = [loc(["sc_1"], [{"geoZones": [geo_zone("us")], "shippingOptions": [excluded_option]}])]
gaps = find_uncovered_regions([region()], locations)
assert gaps == [{"salesChannelId": "sc_1", "countryCode": "us", "reason": "zone_matched_no_shipping_options"}]
def test_stock_location_not_linked_to_sales_channel_is_ignored():
locations = [loc(["sc_other"], [{"geoZones": [geo_zone("us")], "shippingOptions": [option()]}])]
gaps = find_uncovered_regions([region()], locations)
assert gaps == [{"salesChannelId": "sc_1", "countryCode": "us", "reason": "no_geo_zone_match"}]
def test_multiple_countries_each_get_their_own_verdict():
locations = [loc(["sc_1"], [{"geoZones": [geo_zone("us")], "shippingOptions": [option()]}])]
gaps = find_uncovered_regions([region(countryCodes=["us", "ca"])], locations)
assert gaps == [{"salesChannelId": "sc_1", "countryCode": "ca", "reason": "no_geo_zone_match"}]
import { test } from "node:test";
import assert from "node:assert/strict";
import { findUncoveredRegions } from "./find-uncovered-regions.js";
const region = (over = {}) => ({ id: "reg_1", countryCodes: ["us"], salesChannelIds: ["sc_1"], ...over });
const geoZone = (countryCode, type = "country") => ({ type, countryCode });
const option = (id = "so_1", rules = []) => ({ id, rules });
const loc = (salesChannelIds, serviceZones) => ({
id: "sloc_1",
salesChannelIds,
fulfillmentSets: [{ serviceZones }],
});
test("country with matching geo zone and options is covered", () => {
const locations = [loc(["sc_1"], [{ geoZones: [geoZone("us")], shippingOptions: [option()] }])];
const gaps = findUncoveredRegions([region()], locations);
assert.deepEqual(gaps, []);
});
test("country with no geo zone match is reported", () => {
const locations = [loc(["sc_1"], [{ geoZones: [geoZone("ca")], shippingOptions: [option()] }])];
const gaps = findUncoveredRegions([region()], locations);
assert.deepEqual(gaps, [{ salesChannelId: "sc_1", countryCode: "us", reason: "no_geo_zone_match" }]);
});
test("matched zone with no shipping options is reported", () => {
const locations = [loc(["sc_1"], [{ geoZones: [geoZone("us")], shippingOptions: [] }])];
const gaps = findUncoveredRegions([region()], locations);
assert.deepEqual(gaps, [{ salesChannelId: "sc_1", countryCode: "us", reason: "zone_matched_no_shipping_options" }]);
});
test("matched zone where all options are excluded by a subtotal rule is reported", () => {
const excludedOption = option("so_1", [{ attribute: "cart.subtotal", operator: "gte", value: 10000 }]);
const locations = [loc(["sc_1"], [{ geoZones: [geoZone("us")], shippingOptions: [excludedOption] }])];
const gaps = findUncoveredRegions([region()], locations);
assert.deepEqual(gaps, [{ salesChannelId: "sc_1", countryCode: "us", reason: "zone_matched_no_shipping_options" }]);
});
test("stock location not linked to the sales channel is ignored", () => {
const locations = [loc(["sc_other"], [{ geoZones: [geoZone("us")], shippingOptions: [option()] }])];
const gaps = findUncoveredRegions([region()], locations);
assert.deepEqual(gaps, [{ salesChannelId: "sc_1", countryCode: "us", reason: "no_geo_zone_match" }]);
});
test("multiple countries each get their own verdict", () => {
const locations = [loc(["sc_1"], [{ geoZones: [geoZone("us")], shippingOptions: [option()] }])];
const gaps = findUncoveredRegions([region({ countryCodes: ["us", "ca"] })], locations);
assert.deepEqual(gaps, [{ salesChannelId: "sc_1", countryCode: "ca", reason: "no_geo_zone_match" }]);
});
Case studies
The country that could check out but never ship
A team added a new country to their EU region to open a market, updated the currency, and pushed a marketing campaign. Orders came in from the new country, all the way to the shipping step, and then the checkout had nothing to offer. Support tickets started calling it a broken checkout.
Running the script found the exact gap in seconds, that one country's ISO code had no geo zone in any service zone reachable from the storefront's sales channel. The region team had updated checkout availability without ever telling the fulfillment team the country was live, and the two lists had simply never been compared before.
The zone that matched but still returned nothing
A merchant added a minimum subtotal rule to their only shipping option in a service zone, meant to discourage tiny orders, without adding a fallback option below that threshold. Every cart under the minimum matched the country's geo zone perfectly, then hit checkout with zero shipping options and no obvious reason why.
The detection step flagged it with the second reason, zone_matched_no_shipping_options, distinct from a missing geo zone, which pointed the team straight at the shipping option's rule instead of a wasted afternoon re-checking their region settings.
After this runs, every gap between what a region allows into checkout and what fulfillment can actually ship is a short, readable list, sales channel, country, and the precise reason. No script silently invents a shipping rate or picks a carrier. A human decides which countries are worth the carrier contract and the tax nexus, and once that geo zone and shipping option exist, the same script reports the pair as covered and moves on.
FAQ
Why does my Medusa cart show no shipping options for a country that is in the region?
Regions control checkout availability and currency, while shipping availability is resolved separately by walking cart to sales channel to stock location to FulfillmentSet to ServiceZone to GeoZone and matching the cart's shipping_address.country_code. A merchant can add a country to a Region without adding a matching GeoZone to any ServiceZone, so the region and product and pricing all look correct while GET /store/shipping-options/{cart_id} still returns an empty array for that country.
Is an empty shipping_options array always the same root cause?
No. It can mean the cart's country has no matching geo_zone in any service zone reachable from the sales channel's stock location, or it can mean a service zone does match geographically but every one of its shipping options is a return option or is excluded by a rule such as a minimum or maximum subtotal. Both end in the same empty array, so the detection step has to check both and report which one applies.
Should a script automatically create the missing service zone or shipping option?
Not automatically. Which countries to ship to, what a carrier actually charges, and tax nexus are business decisions, so the safe default is to report every uncovered sales channel and country pair. If an operator confirms intent, a guarded script can run in dry run first, print the planned POST calls to create the service zone, geo zone, and shipping option, and only execute them when dry run is turned off, then re-verify with a real cart before reporting success.
Related field notes
Citations
On the problem:
- Medusa Documentation: Fulfillment Concepts, Fulfillment Set, Service Zone, Geo Zone. docs.medusajs.com/resources/commerce-modules/fulfillment/concepts
- GitHub Issue: Shipping Methods don't get listed for cart based on requirements. github.com/medusajs/medusa/issues/7162
- GitHub Issue: Not getting shipping option after I add min max subtotal. github.com/medusajs/medusa/issues/4671
On the solution:
- Medusa Documentation: Shipping Option, Fulfillment Module. docs.medusajs.com/resources/commerce-modules/fulfillment/shipping-option
- Medusa Documentation: createServiceZones, Fulfillment Module Reference. docs.medusajs.com/resources/references/fulfillment/createServiceZones
- Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
Stuck on a tricky one?
If you have a problem in Medusa storefront access, pricing, inventory, orders, 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 get checkout working again?
If this saved you a confusing afternoon chasing a phantom shipping bug, 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