Preventing Overselling: Understanding ESHOPMAN's Batch Inventory Reservation Bug

For any e-commerce business, accurate inventory management is the bedrock of customer satisfaction and operational efficiency. This holds especially true for ESHOPMAN users leveraging the power of headless commerce and HubSpot CMS to manage their storefronts. Recently, our community identified a critical issue in ESHOPMAN's core inventory reservation logic that could lead to significant overselling, particularly when dealing with product bundles or items sharing common inventory components.

The Challenge: Overselling with Shared Inventory Items

Imagine a scenario where your ESHOPMAN storefront offers product bundles, or perhaps different product variants that draw from the same underlying inventory item. A customer places an order containing two line items, both of which require units from the same inventory item at a specific location. The core issue arises when ESHOPMAN’s inventory service attempts to reserve these items in a single batch during the checkout process.

The problem is that the system, specifically the createReservationItems call, fails to correctly account for stock already claimed by earlier entries within the same batch request. Each reservation entry is validated independently against the initial, un-decremented available quantity. This means if you have 5 units in stock, and two items in the batch each request 5 units, both checks pass, even though their combined demand (10 units) far exceeds the actual stock. Consequently, the system proceeds to create reservations for the full sum, resulting in a negative available quantity and an oversold situation.

Understanding the Root Cause in ESHOPMAN's Inventory Service

The core of this behavior lies within the ensureInventoryLevels function in ESHOPMAN's inventory service. This function is designed to fetch inventory levels for items and locations, then validate reservation requests. However, the current implementation fetches the available_quantity for each (inventory_item_id, location_id) pair only once into a map. When it then iterates through the batch of reservation requests, it checks each item against this static, initial available_quantity. It never subtracts what earlier entries in the same batch have already claimed.

for (const item of data) {
  if (!!item.allow_backorder) continue
  const level = inventoryLevelItemLocationMap
    .get(item.inventory_item_id)
    ?.get(item.location_id)!
  if (MathBN.lt(level.available_quantity, item.quantity!)) {
    throw new MedusaError(... "Not enough stock available ...") // ESHOPMANError in our context
  }
}

While the later aggregation step correctly sums all entries for a given key to update the reserved_quantity in the database, this sum was never properly validated against the true available stock. This means that while creating a single reservation for the summed quantity, or increasing an existing reservation, would correctly be rejected if stock is insufficient, the multi-entry batch bypasses this critical check.

Impact on Your ESHOPMAN Storefront and Checkout Flow

This bug is directly reachable during an ordinary checkout process. ESHOPMAN's complete-cart.ts flow calls reserveInventoryStep, which in turn uses createReservationItems. The prepareConfirmInventoryInput function emits one entry per (line item, inventory item) without any prior aggregation. Therefore, if two line items in a customer's cart share an inventory item, their reservation requests are passed together in a single batch, leading to the overselling scenario described.

The consequence for ESHOPMAN merchants is clear: placed orders might exceed actual stock, leading to unfulfillable orders, customer frustration, and potential revenue loss. Developers working with ESHOPMAN's Admin API or Store API for custom checkout flows should be particularly aware of this behavior.

Reproducing the Issue

For developers, understanding how to reproduce this issue deterministically is key. The following test case, designed for ESHOPMAN's inventory module, illustrates the problem:

import { IInventoryService } from "@eshopmanjs/framework/types"
import { Modules } from "@eshopmanjs/framework/utils"
import { MockEventBusService, moduleIntegrationTestRunner } from "@eshopmanjs/test-utils"

moduleIntegrationTestRunner({
  moduleName: Modules.INVENTORY,
  injectedDependencies: { [Modules.EVENT_BUS]: new MockEventBusService() },
  testSuite: ({ service }) => {
    it("rejects a batch that reserves more than available for the same item+location", async () => {
      const item = await service.createInventoryItems({ sku: "x", origin_country: "c" })
      await service.createInventoryLevels([
        { inventory_item_id: item.id, location_id: "location-1", stocked_quantity: 5 },
      ])
      // Two entries in ONE call, same item+location, each 5 (total 10 vs 5).
      await expect(
        service.createReservationItems([
          { inventory_item_id: item.id, location_id: "location-1", quantity: 5 },
          { inventory_item_id: item.id, location_id: "location-1", quantity: 5 },
        ])
      ).rejects.toThrow(/Not enough stock available/)
    })
  },
})

On current ESHOPMAN versions exhibiting this bug, this test would *resolve* successfully instead of rejecting, leading to location-1 having reserved_quantity: 10 against a stocked_quantity: 5, resulting in an available_quantity: -5.

Moving Forward: Ensuring Robust Inventory Checks

This community insight highlights a critical area for improvement within ESHOPMAN's core inventory logic. For merchants, understanding this potential vulnerability is crucial for managing expectations around stock levels, especially with complex product configurations. For developers, it underscores the importance of robust, dynamic validation within batch operations to prevent overselling and ensure the integrity of inventory data across all ESHOPMAN-powered storefronts deployed via HubSpot CMS.

Start with the tools

Explore migration tools

See options, compare methods, and pick the path that fits your store.

Explore migration tools