Commerce

Medusa v2 Multi-Location Inventory: Why Checkout Fails on Stock You Actually Have

Medusa v2 confirms a cart against summed stock across every linked location, then reserves the whole line from location_ids[0]. The order is created first, so checkout fails after the order exists. Here is the source-level proof, the two fixes that provably cannot work, and the one that does.

MManojAugust 5, 202613 min read
#medusa#commerce#inventory#warehouse
Share

You have two warehouses. Warehouse A holds one unit, Warehouse B holds one unit, both are linked to your storefront's sales channel, and a customer orders two. Add-to-cart succeeds. Checkout creates the order row. Then the whole thing blows up with not_allowed: Not enough stock available for item iitem_… at location sloc_A — for stock you demonstrably have. This is not a configuration mistake. In Medusa v2.18.0, confirmInventory sums availability across the entire location_ids array, while reserveInventoryStep truncates that array to location_ids[0] and reserves the full line quantity from one location. The two steps disagree, and createOrdersStep runs before the disagreement surfaces. What follows is the source-level proof, an interactive simulator that reproduces it in five seconds, and — the part nobody else has written down — the negative proof that the two obvious fixes cannot work, and the one approach that does.

Also running Medusa in production?

This is the second failure mode in this series that only appears once a store gets real. The Medusa v2 checkout performance guide covers the quadratic variant pricing lookup that hits at 500+ variants, and the COD + Shiprocket guide covers the India fulfillment stack.

I have spent enough time in workflow engines to know that the expensive bugs are never the ones in the code you wrote — they are the ones where two correct-looking steps hold different opinions about the same number. I am Manoj, commerce and ERP implementation lead at Mith Tech in Bengaluru, and multi-warehouse Medusa builds are a large part of what our team ships.

The two steps that disagree

Two separate pieces of code answer the question "is there enough stock?" and they answer it differently.

The confirm side sums. prepareConfirmInventoryInput attaches a location_ids array to every item using a three-tier fallback. First it tries locationsWithAvailability — the locations where one location alone covers the whole line. When that list is empty (A holds 1, B holds 1, the line needs 2), it falls through to locationsWithLevel: every location that merely stocks the item. That multi-element array is handed to confirmInventory, which calls retrieveAvailableQuantity with the whole array, and the inventory level repository sums across all of them. 1 + 1 = 2 ≥ 2 → true. Add-to-cart succeeds.

The reserve side takes index zero. In reserveInventoryStep, the items map builds one reservation per line item at the full line quantity, with location_id: item.location_ids[0]. The array is silently truncated. The inventory module's createReservationItems then runs ensureInventoryLevels with validateQuantityAtLocation: true, compares the level's available_quantity against the requested quantity, and throws:

Error Type: not_allowed
Not enough stock available for item <inventory_item_id> at location <location_id>

A sibling error from the same guard fires when the item is not stocked at the chosen location at all: Item <inventory_item_id> is not stocked at location <location_id>, as not_found.

I read reserve-inventory.ts at ?ref=v2.18.0. The line location_id: item.location_ids[0], is present and unmodified. This ships in the current release.

The simulator below is the whole bug in one panel. Set the per-location numbers, set the cart line, and watch the confirm step pass while the reserve step fails on the same inputs.

InteractiveSplit-Stock Simulatorlink

Set availability across two or three stock locations and a cart line quantity. The panel runs the same three-tier location_ids fallback Medusa uses, then shows the confirm step summing and the reserve step truncating to location_ids[0], including the exact error string.

Set the per-location availability and the cart line. The confirm step sums; the reserve step takes location_ids[0].

Needed

2

quantity × required_quantity

Aggregate available

2

across 2 locations

At sloc_A

1

what reserve actually sees

1 · confirmVariantInventoryWorkflow → confirmInventory

retrieveAvailableQuantity sums 1 + 1 = 2 ≥ 2. Add-to-cart succeeds.

2 · createOrdersStep

The order row is written to the database before anything is reserved.

3 · reserveInventoryStep

location_ids = [sloc_A, sloc_B] → truncated to sloc_A, reserving the full 2 units there.

Order created, then checkout throws

Error Type: not_allowed
Not enough stock available for item iitem_01JX9 at location sloc_A

You have 2 units. The customer ordered 2. The store still fails the checkout, because no single location covers the line on its own.

The control case matters as much as the failure. Give Warehouse A two units instead of one and the bug vanishes — locationsWithAvailability is now non-empty, location_ids has exactly one element, and index [0] is correct. The bug only fires when no single channel-linked location can cover the line on its own. That is why it never shows up in development and always shows up after you add a second warehouse.

Why the order already exists

Inside completeCartWorkflow, the ordering in the create-order branch is:

createOrdersStep

The order row is written to the database. Nothing has been reserved yet.

prepareConfirmInventoryInput

A transform builds the location_ids arrays described above. Note that completeCartWorkflow never calls confirmInventoryStep — the aggregate confirmation already happened earlier, in confirmVariantInventoryWorkflow, which runs from addToCartWorkflow, createCartWorkflow and updateLineItemInCartWorkflow.

parallelize(createRemoteLinkStep, updateCartsStep, reserveInventoryStep, registerUsageStep, emitEventStep)

The reservation is attempted here. This is where it throws.

authorizePaymentSessionStep

Payment is authorized last, deliberately, to minimise the risk of cancelling a payment in the compensation flow.

So the sequence is: add-to-cart passes on aggregate stock → order row created → reservation throws → workflow compensates. Depending on where compensation lands, you get failed checkouts and order rows that never completed. Reconcile those before you trust your order count.

location_ids[0] is not your primary warehouse

It is worth being precise about what index zero actually is, because a lot of teams assume it is configurable.

location_ids is the set of stock locations that both (a) hold an InventoryLevel for one of the cart's inventory items and (b) are linked to the cart's sales_channel_id through the sales_channel_stock_location link table. That link is many-to-many on both sides: one channel can have N locations, one location can serve N channels. Order within the array is Set insertion order — that is, whatever order the query graph returned rows in.

There is no priority field. No "default location" setting. No proximity calculation. location_ids[0] is effectively arbitrary, and can change between deploys.

What you would try first, and why it cannot work

This is the part that cost real time, so here it is in full.

Attempt one: a workflow hook. Medusa's entire customisation story is hooks, so the natural move is to find the hook around the reservation and override the location. completeCartWorkflow calls createHook exactly three times: validate, beforePaymentAuthorization, and orderCreated. The latter two are @ignored in the type signature but are registered at runtime — createHook unconditionally calls context.hookBinder, and create-workflow.ts builds mainFlow.hooks[hook] for every declared hook. So you can call them. It just does not help: both sit after the parallelize block containing the reserve step. By the time either fires, the reservation has already thrown. Only validate runs early enough, and it has no mechanism to influence which location the reservation uses.

Attempt two: re-register the workflow. If there is no hook, define your own createWorkflow("complete-cart", …) and let yours load last. This throws at boot. WorkflowManager.register JSON.stringify-compares the incoming flow against the registered one and raises Workflow with id "complete-cart" and step definition already exists. when they differ — and a different step graph is exactly what you are trying to register.

Both roads are closed. That leaves a clone under a new name plus a route override as the only approach that both fixes the behaviour and boots. The decider below walks the full set, including the two mitigations that work but cost you something.

InteractiveFix Path Deciderlink

Six approaches to the multi-location reservation failure — hooks, workflow re-registration, allow_backorder, one-location-per-channel, a validate pre-flight guard, and the cloned workflow — each with the source-level reason it is impossible, harmful, partial, or viable.

Six things you would reasonably try. Two of them cannot work at all — here is the source-level reason why, so you do not spend a day finding out.

Paths tried

6

the obvious ones first

Provably impossible

2

hook, re-register

Actually viable

2

one-per-channel, cloned workflow

What you expect

Medusa's whole customisation story is hooks. Surely completeCartWorkflow exposes one where I can pick the location.

What the source says

completeCartWorkflow calls createHook exactly three times: validate (before createOrdersStep), beforePaymentAuthorization, and orderCreated. The last two are registered at runtime but both sit after the parallelize block that contains reserveInventoryStep. By the time either hook fires, the reservation has already thrown.

So

No hook can intercept the reservation. Only validate is early enough, and it has no way to influence which location is used.

The fix that works

Three files. A split-reservation step that greedily fills across location_ids in line-item units, a clone of complete-cart.ts under the id complete-cart-split, and a route override that calls the workflow engine with the new id.

The split algorithm is adapted from the live diff of PR #15327. Two APIs it uses could not be verified against v2.18.0's source and are marked UNVERIFIED inline: MathBN.mod / MathBN.div, and the array-filter typing of listInventoryLevels. Check both signatures against your installed version before you ship. If MathBN.mod and MathBN.div are not exported, plain integer arithmetic is safe — required_quantity is typed number in both step interfaces.

Note also that the greedy fill works in line-item units, not raw inventory units. A required_quantity: 3 variant must not receive a 4-unit allocation, because 4 cannot be mapped back to an integer number of line items at fulfillment.

Code recipeSplit-Reservation Recipelink

Three tabs with copy buttons: the split-reservation step preserving core's compensation contract, the cloned complete-cart-split workflow, and the store route override. Comments mark the two APIs taken from an unmerged PR that you must verify locally.

Three files. Comments marked UNVERIFIED flag APIs taken from an unmerged community PR — check those signatures against your installed version before shipping.

src/workflows/steps/reserve-inventory-split.ts
// src/workflows/steps/reserve-inventory-split.ts
// Userland adaptation of the algorithm in unmerged PR #15327.
// Preserves core's compensation contract: returns { reservations, inventoryItemIds }
// and deletes the reservations on rollback.

import { MathBN, Modules } from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import type {
  BigNumberInput,
  IInventoryService,
  InventoryTypes,
} from "@medusajs/framework/types"

// Same shape as core's ReserveVariantInventoryStepInput
export interface ReserveInventorySplitStepInput {
  items: {
    id?: string
    inventory_item_id: string
    required_quantity: number
    allow_backorder: boolean
    quantity: BigNumberInput
    location_ids: string[]
  }[]
}

const availabilityKey = (inventoryItemId: string, locationId: string) =>
  `${inventoryItemId}::${locationId}`

async function buildReservationInputs(
  items: ReserveInventorySplitStepInput["items"],
  inventoryService: IInventoryService
): Promise<InventoryTypes.CreateReservationItemInput[]> {
  // Only managed, non-backorder items with >1 candidate location can be split
  const itemsToSplit = items.filter(
    (i) => !i.allow_backorder && i.location_ids.length > 1
  )

  let availabilityByKey: Map<string, BigNumberInput> | null = null

  if (itemsToSplit.length) {
    const inventoryItemIds = Array.from(
      new Set(itemsToSplit.map((i) => i.inventory_item_id))
    )
    const locationIds = Array.from(
      new Set(itemsToSplit.flatMap((i) => i.location_ids))
    )

    // UNVERIFIED: the array-filter typing of listInventoryLevels was NOT
    // confirmed against v2.18.0's IInventoryService. Check the signature in
    // your installed version. If arrays are rejected, use:
    //   { inventory_item_id: { $in: inventoryItemIds },
    //     location_id: { $in: locationIds } }
    const levels = await inventoryService.listInventoryLevels({
      inventory_item_id: inventoryItemIds,
      location_id: locationIds,
    })

    availabilityByKey = new Map(
      levels.map((l) => [
        availabilityKey(l.inventory_item_id, l.location_id),
        MathBN.sub(l.stocked_quantity, l.reserved_quantity),
      ])
    )
  }

  const result: InventoryTypes.CreateReservationItemInput[] = []

  for (const item of items) {
    const totalNeeded = MathBN.mult(item.required_quantity, item.quantity)

    // Backorder / single location / no data => core behaviour, untouched
    if (
      item.allow_backorder ||
      item.location_ids.length <= 1 ||
      !availabilityByKey
    ) {
      result.push({
        line_item_id: item.id,
        inventory_item_id: item.inventory_item_id,
        quantity: totalNeeded,
        allow_backorder: item.allow_backorder,
        location_id: item.location_ids[0],
      })
      continue
    }

    // Greedy-fill in LINE-ITEM units, not raw inventory units, so every
    // allocation is a whole multiple of required_quantity. Otherwise a
    // required_quantity=3 variant can get a 4-unit allocation that cannot be
    // mapped back to an integer number of line-item units at fulfillment.
    let remainingUnits: BigNumberInput = item.quantity
    const splitEntries: InventoryTypes.CreateReservationItemInput[] = []

    for (const locationId of item.location_ids) {
      if (MathBN.lte(remainingUnits, 0)) break

      const available = availabilityByKey.get(
        availabilityKey(item.inventory_item_id, locationId)
      )
      if (available === undefined || MathBN.lte(available, 0)) continue

      // UNVERIFIED: MathBN.mod and MathBN.div are used by PR #15327 but their
      // existence on the exported MathBN in v2.18.0 was NOT confirmed. Check
      // before shipping. required_quantity is typed number in both step
      // interfaces, so plain integer math on Number(available.toString()) is a
      // safe substitute if these members are missing.
      const remainder = MathBN.mod(available, item.required_quantity)
      const usable = MathBN.sub(available, remainder)
      if (MathBN.lte(usable, 0)) continue

      const availableUnits = MathBN.div(usable, item.required_quantity)
      const takeUnits = MathBN.lte(availableUnits, remainingUnits)
        ? availableUnits
        : remainingUnits

      splitEntries.push({
        line_item_id: item.id,
        inventory_item_id: item.inventory_item_id,
        quantity: MathBN.mult(takeUnits, item.required_quantity),
        allow_backorder: item.allow_backorder,
        location_id: locationId,
      })

      remainingUnits = MathBN.sub(remainingUnits, takeUnits)
    }

    if (MathBN.gt(remainingUnits, 0)) {
      // Aggregate genuinely insufficient: fall back to one reservation at the
      // first location so the inventory module raises the canonical
      // "Not enough stock available…" error and the error contract is preserved.
      result.push({
        line_item_id: item.id,
        inventory_item_id: item.inventory_item_id,
        quantity: totalNeeded,
        allow_backorder: item.allow_backorder,
        location_id: item.location_ids[0],
      })
    } else {
      result.push(...splitEntries)
    }
  }

  return result
}

export const reserveInventorySplitStepId = "reserve-inventory-split-step"

export const reserveInventorySplitStep = createStep(
  reserveInventorySplitStepId,
  async (data: ReserveInventorySplitStepInput, { container }) => {
    if (!data.items.length) {
      return new StepResponse([], { reservations: [], inventoryItemIds: [] })
    }

    const inventoryService = container.resolve(Modules.INVENTORY)
    const locking = container.resolve(Modules.LOCKING)

    const inventoryItemIds = data.items.map((i) => i.inventory_item_id)
    const lockingKeys = Array.from(new Set(inventoryItemIds))

    const reservations = await locking.execute(lockingKeys, async () => {
      const inputs = await buildReservationInputs(data.items, inventoryService)
      return await inventoryService.createReservationItems(inputs)
    })

    return new StepResponse(reservations, {
      reservations: reservations.map((r) => r.id),
      inventoryItemIds,
    })
  },
  async (data, { container }) => {
    if (!data?.reservations?.length) return

    const inventoryService = container.resolve(Modules.INVENTORY)
    const locking = container.resolve(Modules.LOCKING)
    const lockingKeys = Array.from(new Set(data.inventoryItemIds))

    await locking.execute(lockingKeys, async () => {
      await inventoryService.deleteReservationItems(data.reservations)
    })

    return new StepResponse()
  }
)

Splitting the reservation is only half the job. Once one line item has two reservations at two locations, createOrderFulfillmentWorkflow has to consume them per-location. Ship the split without a matching fulfillment change and you move the failure from checkout to fulfillment.

The route override works because core resolves the workflow by id string through the workflow engine — we.run(completeCartWorkflowId, …) — so swapping the id in your own route file is sufficient and you never import the workflow function. Medusa's routes loader documents that duplicate routes are won by whichever is registered last, and application routes under src/api are scanned after core routes.

Medusa's own documentation on overriding API routes discourages same-path override and recommends replicating at a new path instead. If you can change your storefront, mount at src/api/store/carts/[id]/complete-split/route.ts and call that. Functionally identical, safer on upgrades.

Two honest costs. Cloning complete-cart.ts copies roughly four hundred lines of promotion, tax, payment and link logic that Medusa will keep changing — budget for a re-diff on every minor upgrade. And splitting the reservation is only half the job: once one line item carries two reservations at two locations, createOrderFulfillmentWorkflow has to consume them per-location. Both upstream PRs touch create-fulfillment.ts for exactly that reason. Ship the split without the fulfillment change and you have moved the failure from checkout to fulfillment.

If cloning is too much surface, the lower-effort route is patching @medusajs/core-flows directly with the reserve-inventory.ts hunk from PR #15327 via yarn patch or patch-package. That PR also ships 389 lines of unit tests, including a case named for issue #14987 — port those into your own suite so the patch is regression-guarded. The trade-off is that you are patching compiled output, patches break on every version bump, and the behaviour change applies to every consumer of reserveInventoryStep in your app, including draft-order conversion, order edits, claims and exchanges.

Before you run multi-warehouse on v2 today

The checklist below is what we work through on a Medusa store with more than one stock location per sales channel. The blockers are the ones that decide whether you have a live failure waiting to happen.

InteractiveMulti-Warehouse Pre-flightlink

Eight checks — audit channel-to-location links, find SKUs whose stock is split below a typical line quantity, add a validate-hook guard, rule out allow_backorder, reconcile orphaned orders, test fulfillment, pin your core-flows version, and watch the two open PRs.

Work through this before you run multi-warehouse on Medusa v2.18.0 in production.

Cleared

0/8

0% complete

Blockers left

4

do these first

Version

v2.18.0

bug still present

Where upstream is going

PR #16246, opened 2026-07-30, introduces a reserveInventoryWorkflow and replaces the bare step call in complete-cart.ts with a runAsStep invocation, exposing a setReservationAllocations hook to customise which stock locations quantities are reserved at — in-store pickup being the headline use case — while createOrderFulfillmentWorkflow learns to consume reservations at the fulfillment's location first. That is precisely what the community asked for on issue #14987.

It is unmerged. I read the changeset and the complete-cart.ts hunk, not the full workflow file, so the exact hook input and return contract is unverified — do not write against that signature yet. Track the PR; do not build on it.

Worth correcting two things that get repeated. Issue #16115 is closed as completed, and it is a different multi-location bug — Admin Create Fulfillment reading .location.id off an array — not this one. And issue #14987 was closed not_planned by a stale bot after three stale windows, not rejected by a maintainer; the reporter asked for it to be reopened on the day it closed and it has not been.

+Why does my Medusa v2 cart complete fail with 'Not enough stock available' when the item is in stock?

Because the availability check and the reservation disagree. confirmVariantInventoryWorkflow sums availability across every stock location linked to your sales channel, so the cart accepts the quantity. At checkout, reserveInventoryStep reserves the entire quantity from location_ids[0] only. If no single location covers the line, createReservationItems throws not_allowed: Not enough stock available for item <id> at location <id> — after the order row has already been created.

+Does Medusa v2 split an inventory reservation across multiple stock locations?

No. As of v2.18.0 it reserves the full line quantity from a single location — item.location_ids[0] in packages/core/core-flows/src/cart/steps/reserve-inventory.ts. Two community PRs implement splitting, #15327 and #16246, and neither is merged as of 5 August 2026.

+Which Medusa version fixes the multi-location reservation bug?

None yet. The bug is present in v2.18.0, released 23 July 2026 and the latest version at the time of writing, and in every earlier v2 release back to v2.1.1 where issue #10561 first reported it. PRs #15327 and #16246 are open against develop and unmerged. Watch those two PRs rather than the issue tracker — issue #14987 was auto-closed not_planned by a stale bot, not by a maintainer decision.

+Hey Google, why does my Medusa store create an order and then fail the checkout?

Because Medusa creates the order row before it reserves inventory. In completeCartWorkflow, createOrdersStep runs first, then reserveInventoryStep. If your item's stock is spread across two warehouses and neither one alone covers the order, the reservation throws and the workflow has to compensate — leaving you with a failed checkout for stock you genuinely have.

+Ok Google, how do I stop customers ordering more stock than one Medusa warehouse has?

Two options. The zero-code one is to link each sales channel to exactly one stock location, so the aggregate check and the reservation always agree. The code one is a validate hook on completeCartWorkflow — or on addToCartWorkflow — that recomputes per-location availability and rejects any line no single location can cover, so the customer gets a clear message at add-to-cart instead of a failed order at checkout.

+Can I add a hook to Medusa's reserve inventory step to choose the stock location?

Not in v2.18.0. completeCartWorkflow exposes three hooks — validate before order creation, and the undocumented beforePaymentAuthorization and orderCreated, both of which run after the reservation. There is no hook around reserveInventoryStep. You also cannot re-register a workflow named "complete-cart" with a different step graph: WorkflowManager.register throws Workflow with id "complete-cart" and step definition already exists. PR #16246 adds the setReservationAllocations hook people are asking for, but it is unmerged and its contract is not something to write against yet.

+Does allow_backorder fix the Medusa reserve-inventory-step error?

It makes the error stop, which is not the same thing. confirmInventory short-circuits to true for backorder items and the inventory module skips the validateQuantityAtLocation guard entirely with if (!!item.allow_backorder) continue. The full quantity still gets reserved at location_ids[0], driving that location's available quantity negative and corrupting per-location stock. Separately, item-level allow_backorder handling was itself buggy — PR #15731 fixed it, merged 3 August 2026, after v2.18.0 shipped.

+What happens if we don't fix this and keep running two warehouses?

Every order whose line quantity exceeds the stock at whichever location lands at index zero will fail at checkout, after the order row is created. You lose the sale on inventory you own, your order table accumulates rows from checkouts that never completed, and because location_ids[0] is query-result ordering rather than a configured priority, the set of failing SKUs shifts without any change on your side. The failure rate rises as you rebalance stock across warehouses, which is the exact operation multi-warehouse exists to enable.

There is no third-party writeup of this bug anywhere — no dev.to post, no Stack Overflow answer, no Medusa blog entry. The only record is four GitHub issues and two unmerged pull requests, and the canonical issue was closed by a bot. If you run more than one stock location per sales channel on Medusa v2.18.0, you have this bug whether or not you have noticed it yet; the simulator above will tell you in five seconds. Fix it before your warehouse team rebalances stock and discovers it for you.

Running Medusa across more than one warehouse?

We build and operate multi-location Medusa v2 stores. If your checkout is failing on stock you own, we will show you exactly which SKUs are exposed and which fix path fits your operation.

Free · By email

Get practical ERPNext & automation guides

New implementation guides, cost breakdowns and open-source tips for Indian businesses — occasionally, straight to your inbox. No spam.

M

Written by

Manoj

Founder of Mith Tech, an open-source ERP & automation studio. Hands-on ERPNext/Frappe implementation across multi-branch, multi-warehouse Indian operations — GST/TDS/PT compliance, branch-level permissions, and custom Frappe apps that give management real-time visibility.

Keep reading

See what this looks like for your business

Book a free 30-minute audit. We'll map your workflows, find where time and money leak, and design an open-source stack you actually own — no per-user licence fees.

Book a consultation
0
Published on 5 August 2026

Manoj

Comments & ratings

No comments yet. Start a new discussion.