Skip to content

fix(core-flows): split reservations across stock locations + setReservationAllocations hook - #16246

Open
andrewgreenh wants to merge 1 commit into
medusajs:developfrom
andrewgreenh:feat/reservation-allocation-hook
Open

fix(core-flows): split reservations across stock locations + setReservationAllocations hook#16246
andrewgreenh wants to merge 1 commit into
medusajs:developfrom
andrewgreenh:feat/reservation-allocation-hook

Conversation

@andrewgreenh

Copy link
Copy Markdown
Contributor

Summary

What — What changes are introduced in this PR?

Closes #14987.

When a cart needs more stock than any single location holds, but the sales channel has enough across several locations combined, completion previously failed in reserveInventoryStep with Not enough stock available for item ... at location ... — inventory confirmation checks the aggregate, but the reservation was always created against location_ids[0].

This PR fixes the mismatch and, following the discussion in #14987, adds a hook so applications can customize where quantities are reserved:

  1. Default multi-location split. When no single candidate location covers an item's full quantity, the reservation is split greedily across the candidate locations (in their existing preference order), allocating only whole multiples of required_quantity per location.
  2. setReservationAllocations hook. A new reserveInventoryWorkflow wraps reserveInventoryStep and exposes the hook. completeCartWorkflow now reserves through this workflow, passing the cart and created order as hook context. Handlers return allocations per (line_item_id, inventory_item_id); unreturned items keep the default behavior. The pure, exported computeReservationAllocations utility computes the default plan so handlers can start from it and adjust.
  3. Fulfillment consumption of split reservations — without breaking existing flows. createOrderFulfillmentWorkflow now consumes a line item's reservations same-location-first and only up to the fulfilled quantity, per inventory item. Reservations at other locations are still consumed as a fallback with the previous adjustment semantics, so the existing "fulfill from a different location than the reservation" behavior keeps working and no new errors are introduced.

Why — Why are these changes relevant or necessary?

For a valid multi-warehouse setup — variant with 1 unit at location A and 1 unit at B, cart quantity 2 — confirmation passes (1+1 = 2) but order placement then fails. Beyond the bug, allocation policy is a business decision (in-store pickup, preferred warehouse, shipping-cost minimization); the core shouldn't hardcode it, and today it's buried too deep in the workflow to work around. The hook keeps the core logic minimal (simple greedy default) while making the policy replaceable.

How — How have these changes been implemented?

packages/core/core-flows/src/cart/utils/prepare-confirm-inventory-input.ts
The prepared items now include location_availability (per-location available quantity). This data was already computed in this function and previously discarded — so the reserve step needs no additional inventory query (the inventory module still validates availability atomically when the reservation is created, so stale availability degrades to today's insufficient-inventory error, never a wrong reservation).

packages/core/core-flows/src/cart/utils/compute-reservation-allocations.ts (new)
Pure function computing the default allocation plan: full quantity at the first location when it suffices (or for backorder / single-location / no-availability-data items), greedy split in whole required_quantity multiples otherwise, and a fallback to the first location when even the aggregate can't cover the item — preserving the canonical INSUFFICIENT_INVENTORY error contract. Exported for reuse by hook handlers.

packages/core/core-flows/src/cart/steps/reserve-inventory.ts
Accepts optional allocations overrides (validated: positive quantities adding up to required_quantity * quantity) and otherwise computes the default plan per item. Locking keys are unchanged.

packages/core/core-flows/src/cart/workflows/reserve-inventory.ts (new)
reserveInventoryWorkflow with the setReservationAllocations hook (zod-validated result, getResult() wired into the step, same pattern as setPricingContext). Used by completeCartWorkflow via runAsStep inside the existing parallelize. The other callers of reserveInventoryStep (draft order conversion, claims, exchanges, order edits) automatically get the default split fix through the step; migrating them to the workflow (and thus the hook) can follow in a separate PR.

packages/core/core-flows/src/order/workflows/create-fulfillment.ts

  • prepareFulfillmentData creates one fulfillment item per inventory item instead of one per reservation (identical output for non-split reservations; avoids duplicated fulfillment items for split ones).
  • prepareInventoryUpdate groups reservations per inventory item and plans consumption via the new planReservationConsumption util: same-location reservations first, then others, capped at the fulfilled quantity. Inventory adjustments and reservation updates keep the previous input.location_id ?? reservation.location_id semantics. The pre-existing "Quantity to fulfill exceeds the reserved quantity" error is thrown in exactly the situations it was before.

packages/core/core-flows/src/order/utils/plan-reservation-consumption.ts (new)
Pure consumption planner, exported and unit-tested.

Testing — How have these changes been tested?

  • New unit suites: compute-reservation-allocations.spec.ts (9 tests), plan-reservation-consumption.spec.ts (6 tests), reserve-inventory.spec.ts (5 tests incl. an end-to-end run of reserveInventoryWorkflow with a registered setReservationAllocations handler).
  • prepare-confirm-inventory-input.spec.ts extended for the new location_availability output.
  • Full @medusajs/core-flows unit suite passes (49 tests).
  • turbo run build for @medusajs/medusa, @medusajs/test-utils and all their dependencies passes (71/71 tasks).

Examples

// Variant manages inventory; 1 unit at sl_a, 1 unit at sl_b, cart quantity 2.

// Before: reservation attempted [{ location_id: "sl_a", quantity: 2 }]
//   → "Not enough stock available for item ii_1 at location sl_a"
// After: reservations [{ sl_a, 1 }, { sl_b, 1 }]

// Custom allocation — reserve at the pickup store the customer selected:
import { reserveInventoryWorkflow } from "@medusajs/medusa/core-flows"
import { StepResponse } from "@medusajs/framework/workflows-sdk"

reserveInventoryWorkflow.hooks.setReservationAllocations(
  ({ items, cart }) => {
    const pickupLocationId = cart?.metadata?.pickup_location_id
    if (!pickupLocationId) {
      return new StepResponse(undefined) // keep default behavior
    }

    return new StepResponse(
      items.map((item) => ({
        line_item_id: item.id,
        inventory_item_id: item.inventory_item_id,
        allocations: [
          {
            location_id: pickupLocationId,
            quantity: item.required_quantity * item.quantity,
          },
        ],
      }))
    )
  }
)

Checklist

  • I have added a changeset for this PR (patch)
  • The changes are covered by relevant tests
  • I have verified the code works as intended locally
  • I have linked the related issue(s)

Additional Context

Relation to earlier work: #11538 fixed the "some single location has full stock" case in prepareConfirmInventoryInput; this PR handles the remaining aggregate-only case. PR #15327 attempted the same fix but embedded the allocation policy in the step (no hook), re-queried inventory levels inside the reserve step, and made createOrderFulfillmentWorkflow throw INVALID_DATA whenever a line item had no reservation at the fulfillment location — breaking the existing (and common) flow of fulfilling from a different location than the one reserved at. This PR keeps that flow working: same-location reservations are simply consumed first, and cross-location consumption remains a fallback with unchanged semantics.

A design note for reviewers — why a new reserveInventoryWorkflow instead of a hook directly on completeCartWorkflow: the reservation happens inside the when("create-order") block, and the hook's input (items keyed by order line-item IDs) only exists there. Hooks created inside when() blocks can run, but can't be included in the WorkflowResponse hooks: [...] array (the hook variable is scoped to the closure), so they can't be typed or picked up by the docs generator — which is why orderCreated and beforePaymentAuthorization are @ignored today. Wrapping the step in a small workflow gives the hook the standard top-level, typed, documented shape (same pattern as setPricingContext), and lets the other reserveInventoryStep callers (draft order conversion, claims, exchanges, order edits) adopt the same hook later by switching to the workflow. Happy to restructure if you'd prefer a different shape.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EGisZW2K4M3h6XX7ybXtqj

…tReservationAllocations hook

When completing a cart, inventory is confirmed against the aggregate
availability of the sales channel's stock locations, but the reservation
was always created against the first location only. When no single
location could cover a line item's quantity, cart completion failed with
"Not enough stock available" even though the aggregate sufficed (medusajs#14987).

- prepareConfirmInventoryInput now carries each item's per-location
  availability (already computed there) into the reserve step, so no
  extra inventory query is needed.
- reserveInventoryStep splits an item's reservation across its candidate
  locations (greedily, in whole multiples of required_quantity) when no
  single location covers the full quantity, and accepts optional custom
  allocations.
- The new reserveInventoryWorkflow wraps the step and exposes a
  setReservationAllocations hook so applications can decide where
  quantities are reserved (e.g. in-store pickup). completeCartWorkflow
  now reserves through this workflow. The exported pure
  computeReservationAllocations util lets hook handlers start from the
  default plan.
- createOrderFulfillmentWorkflow consumes reservations at the
  fulfillment's location first and only up to the fulfilled quantity, so
  split reservations are consumed by their respective per-location
  fulfillments. Reservations at other locations are still consumed as a
  fallback, preserving the existing fulfil-from-another-location
  behavior; no new errors are introduced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGisZW2K4M3h6XX7ybXtqj
@andrewgreenh
andrewgreenh requested a review from a team as a code owner July 30, 2026 15:29
@changeset-bot

changeset-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7ae6ccf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 79 packages
Name Type
@medusajs/core-flows Patch
@medusajs/medusa Patch
@medusajs/test-utils Patch
integration-tests-http Patch
@medusajs/loyalty-plugin Patch
@medusajs/medusa-oas-cli Patch
@medusajs/analytics Patch
@medusajs/api-key Patch
@medusajs/auth Patch
@medusajs/caching Patch
@medusajs/cart Patch
@medusajs/currency Patch
@medusajs/customer Patch
@medusajs/file Patch
@medusajs/fulfillment Patch
@medusajs/index Patch
@medusajs/inventory Patch
@medusajs/link-modules Patch
@medusajs/locking Patch
@medusajs/notification Patch
@medusajs/order Patch
@medusajs/payment Patch
@medusajs/pricing Patch
@medusajs/product Patch
@medusajs/promotion Patch
@medusajs/rbac Patch
@medusajs/region Patch
@medusajs/sales-channel Patch
@medusajs/settings Patch
@medusajs/stock-location Patch
@medusajs/store Patch
@medusajs/tax Patch
@medusajs/translation Patch
@medusajs/user Patch
@medusajs/workflow-engine-inmemory Patch
@medusajs/workflow-engine-redis Patch
@medusajs/draft-order Patch
@medusajs/oas-github-ci Patch
@medusajs/cache-inmemory Patch
@medusajs/cache-redis Patch
@medusajs/event-bus-local Patch
@medusajs/event-bus-redis Patch
@medusajs/analytics-local Patch
@medusajs/analytics-posthog Patch
@medusajs/auth-emailpass Patch
@medusajs/auth-github Patch
@medusajs/auth-google Patch
@medusajs/caching-redis Patch
@medusajs/file-local Patch
@medusajs/file-s3 Patch
@medusajs/fulfillment-manual Patch
@medusajs/locking-postgres Patch
@medusajs/locking-redis Patch
@medusajs/notification-local Patch
@medusajs/notification-sendgrid Patch
@medusajs/payment-stripe Patch
@medusajs/framework Patch
@medusajs/js-sdk Patch
@medusajs/modules-sdk Patch
@medusajs/orchestration Patch
@medusajs/query Patch
@medusajs/types Patch
@medusajs/utils Patch
@medusajs/workflows-sdk Patch
@medusajs/http-types-generator Patch
@medusajs/cli Patch
@medusajs/deps Patch
@medusajs/eslint-plugin Patch
@medusajs/telemetry Patch
@medusajs/admin-bundler Patch
@medusajs/admin-sdk Patch
@medusajs/admin-shared Patch
@medusajs/admin-vite-plugin Patch
@medusajs/dashboard Patch
@medusajs/icons Patch
@medusajs/toolbox Patch
@medusajs/ui-preset Patch
create-medusa-app Patch
@medusajs/ui Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@medusa-os-bot

medusa-os-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

Thanks for the contribution! Initial automated review looks good.

Fixes the multi-location reservation split bug: when no single location covers a cart item's full quantity, the reservation is now split greedily across candidate locations. Adds a new reserveInventoryWorkflow with a setReservationAllocations hook for operator customization, and updates createOrderFulfillmentWorkflow to consume same-location reservations first. Template complete, changeset present (patch), unit tests added for all new utilities and the step. No security, performance, or correctness issues found. Heads up: PR #15327 also references issue #14987 and was opened earlier — if it is merged first, this PR may need to be closed as a duplicate.

Triggered by: manual workflow dispatch

@andrewgreenh

Copy link
Copy Markdown
Contributor Author

I believe the failed pipeline is not caused by the changes in this PR...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Complete cart confirms aggregate multi-location inventory but reserve-inventory-step reserves only from first location

1 participant