npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hartl-services/medusa-batch-management

v0.4.2

Published

Batch management plugin for Medusa.

Readme

Batch Management Plugin

FEFO (first-expired, first-out) batch and expiry tracking on top of Medusa's inventory module, for Medusa v2.19.0 and later 2.x releases.

Goods receipts are booked as named batches (lots) with an expiry date at a stock location. Batches carry their own quantity ledger; withdrawals (manual or fulfillment-triggered) draw down the earliest-expiring eligible batch first.

Features

  • Book incoming stock as a batch; re-booking the same name/expiry/location merges into the existing batch (unique active charge per inventory item + location + name).
  • FEFO withdrawal with a configurable minimum remaining shelf life for fulfillment-triggered withdrawals.
  • Correct an order's active FEFO allocation in the Order detail view by replacing its batches manually. Reassignments keep the original quantity and stock location, are atomic, and retain the reversed FEFO rows for audit.
  • Manual batch withdrawal with a fixed reason set (manual, expired, damaged, correction).
  • Immutable purchase cost per batch plus a UTC booking date for every stock movement.
  • Transfer a complete batch or a partial available quantity to another stock location while preserving its LOT and expiry date.
  • Order lifecycle integration: each native fulfillment withdraws exactly its own managed line quantities; fulfillment cancellation restores only that fulfillment's still-open allocation, while a confirmed Medusa return restores only its undamaged received quantity to the originally allocated batch.
  • Admin page under Inventory plus an inventory-item detail widget.
  • Guards on the native inventory routes so stock can only be moved through batch management.

Installation in a shop

pnpm add @hartl-services/medusa-batch-management @hartl-services/medusa-base @tanstack/react-query

@hartl-services/medusa-base is a required peer: it stores the plugin switch (see "Feature toggle" below) and must be registered as a plugin as well; without it every /admin/batch-management/* route fails with UNEXPECTED_STATE.

@medusajs/admin-sdk, @medusajs/framework, @medusajs/js-sdk, @medusajs/medusa, @medusajs/ui, react, and react-router-dom are peer dependencies already provided by a standard Medusa 2.19 shop and admin install. @tanstack/react-query is not part of a bare Medusa scaffold and must be added explicitly.

// medusa-config.ts
export default defineConfig({
  // ...
  plugins: [
    { resolve: "@hartl-services/medusa-base", options: {} },
    {
      resolve: "@hartl-services/medusa-batch-management",
      options: {
        orderMinShelfLifeMonths: 3, // optional, non-negative integer, defaults to 3
      },
    },
  ],
});
  • orderMinShelfLifeMonths is the only option and is optional; it defaults to 3.
  • No plugin-specific environment variables are required.
  • Optional coupling: the Affiliate plugin (@hartl-services/medusa-affiliate) detects this plugin at runtime through the batch_management container registration and uses it for lot/expiry-aware FEFO consignment transfers when both the transfer and batch-lookup capabilities are present; Batch Management itself has no dependency on Affiliate.
  • Run medusa db:migrate after installing (the module ships its own migrations), then medusa db:sync-links after changing link definitions or enabled components.

Feature toggle

The plugin declares a plugin switch without sub-features to @hartl-services/medusa-base under the plugin key batch_management. An Admin switches it under Settings → "Hartl Services Plugins"; the effective value is readable at GET /store/plugin-features/batch_management.

| Key | Label | Default | Gates | Keeps running when off | | --------------- | ----------------- | ------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | (plugin switch) | Chargenverwaltung | on | every /admin/batch-management/* route (Admin page and API answer 404 after Medusa's /admin authentication; 401 without login) | batch allocation on fulfillment (workflow hook), the fulfillment-canceled and return-received subscribers, and the native inventory route guards — they protect stock integrity and never read the toggle |

Admin surfaces of a disabled feature are hidden. The inventory-item and order widgets stay registered but render nothing while the plugin is off (PluginFeatureGate from @hartl-services/medusa-base/admin-components); the Admin page's sidebar entry cannot be hidden, so it shows PluginFeatureDisabledNotice instead of the Chargenverwaltung content.

Shop integration

Batch Management is published on npm as @hartl-services/medusa-batch-management; any Medusa 2.19 shop can install and configure it directly (see "Installation in a shop" above). The demo shop apps/backend in this repository wires it in as a local workspace dependency and lists it directly in apps/backend/medusa-config.ts's plugins array with empty options; it is active because it is listed there, not through a separate feature flag.

Add any future shop-specific Batch-Management option directly to its plugin entry in apps/backend/medusa-config.ts; do not introduce an ad-hoc environment flag or a separate plugin list.

Führe die Migrationen im Shop-Backend aus. Synchronisiere Links nach Änderungen an Link-Definitionen im Backend oder an aktivierten Shop-Komponenten:

pnpm --dir apps/backend exec medusa db:migrate
pnpm --dir apps/backend exec medusa db:sync-links

Die Admin-Erweiterung wird von Medusa über die lokale Plugin-Registrierung geladen. Ihre öffentlichen HTTP-Verträge liegen ausschließlich im zentralen @hartl-services/medusa-food-supplements-contracts-Paket; die Plugin-Implementierung wird nicht von Consumer-Anwendungen importiert.

Configuration

| Option | Type | Default | Description | | ------------------------- | ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------- | | orderMinShelfLifeMonths | number (non-negative integer) | 3 | Minimum remaining shelf life a batch must have (from UTC "today") to be withdrawn for a fulfillment. |

The option is a plugin option; Medusa forwards it to the batch-management module, which validates it and exposes it to the fulfillment-withdrawal workflow.

Fulfillment ownership and native inventory

Placing an order or creating a native reservation does not change any batch. The Batch ledger moves only inside Medusa's native fulfillment workflow. Its compensatable fulfillment-created hook resolves managed inventory lines at the fulfillment location and withdraws their exact quantities through FEFO. Digital or otherwise unmanaged lines still commit an empty fulfillment run, so callers can distinguish a completed zero-allocation fulfillment from work that has not finished.

Every withdrawal belongs to one fulfillment_id. Partial fulfillments therefore own independent allocations even when they share an Order line, inventory item, or Stock Location. Cancelling fulfillment A restores only A's still-active rows; fulfillment B remains allocated. Cancelling the Order relies on Medusa's native fulfillment cancellation lifecycle rather than an order-wide Batch writer.

On a confirmed return receipt (order.return_received), Batch Management reads the return line's managed variant inventory links and expands Medusa's cumulative sellable quantities by each link's required_quantity. It settles each concrete order-line/inventory-item allocation independently before a later fulfillment cancellation can restore it. Batch quantity increases only by the received, undamaged concrete portion. Medusa's Beschädigt amount is deliberately not sellable batch stock, and neither sellable nor damaged returned units can be restored twice. Fulfillment packing-list snapshots retain the original picked quantities by reconstructing returned units from the immutable return receipts; returns do not create a new allocation revision.

Each charge has one fixed unit_cost. Re-booking the same charge with a different price is rejected without changing the batch or native inventory ledger. Receipt, withdrawal, and transfer booking dates default to the current UTC day when omitted. The movement journal stores the dated, valued entries needed to prepare a future inventory-value report; this plugin does not expose that report yet.

Changing a charge assignment in the Order detail view only moves one fulfillment's committed batch quantity from the formerly assigned charge(s) to the selected charge(s). It does not change the order quantity, stock location, fulfillment ownership, or Medusa's native inventory movement. It is available after fulfillment creation as long as that fulfillment still has an active batch allocation.

Native inventory route guards

To keep the batch ledger authoritative, the plugin blocks manual stock changes on the native admin inventory routes (create item with location levels, create/ update/delete a location level, and the batch location-level endpoints, including delete/force). Stock must be moved through batch management.

Authorization

Every /admin/batch-management/* route is intentionally available to any authenticated admin actor. Batch bookings and withdrawals are back-office stock operations with no per-actor ownership, so they are not gated by a custom RBAC policy — authentication is inherited from the admin API. Revisit this if batch management ever becomes tenant- or team-scoped.

Concurrency & idempotency

  • Withdrawals run inside a module transaction and take a FOR UPDATE row lock on the candidate batches, so concurrent withdrawals for the same stock cannot oversell.
  • An order-allocation correction locks both the active withdrawal rows and all affected batches. It restores the old quantities and applies the replacement quantities in that one transaction; a competing cancellation or correction observes the completed state and can safely reload it.
  • Fulfillment-triggered withdrawals are guarded by a durable run record unique on fulfillment_id plus a transaction-scoped lock on its normalized value; order_id remains indexed for document and lifecycle reads. A concurrent repeat waits for the winning transaction and returns its existing active rows without withdrawing again, while reusing a fulfillment ID for another Order is rejected. Failed fulfillment creation reverses its active rows and removes this run in one transaction; ordinary fulfillment cancellation retains it.
  • Affiliate consignment approval and positive corrections create native fulfillments and use this same hook. They never call a parallel Batch withdrawal path, so their retries have the same fulfillment-scoped exactly-once behavior.

Public batch transfer workflow

The plugin exports the durable transferBatchesWorkflow. Integrations provide a stable transaction/idempotency key and explicit source batch, destination location and quantity lines. The workflow locks the transfer run and involved batches, preserves LOT name and expiry date, and returns source/destination batch IDs plus the immutable LOT/MHD snapshot. A retry returns the completed result without moving stock again, while reusing the key for a different source, destination or quantity returns a conflict; a failed surrounding workflow can reverse an unfinished run. If the target location does not yet have a native inventory level for the item, the workflow creates a zero-level before booking the movement and removes only that newly created level again when the transfer fails.

The Affiliate plugin detects Batch support structurally at runtime. An installed Batch Management version must expose both the transfer capability and batch lookup used for pre-mutation source/item validation. Incompatible versions are rejected before inventory is changed.

Documents integration

The public module service also exposes getOrderDocumentBatchSnapshot(orderId) for the optional Documents plugin. It returns pending until at least one committed fulfillment run exists for the Order. A completed zero-managed-item run returns ready with no allocations; managed and partial fulfillments return every active withdrawal, including reassigned rows, using only the immutable batch name and expiry snapshots captured when each row was written. There is no live-Batch fallback or reason-based filter. Documents can therefore print the original charge and MHD without observing later Batch edits. Run the Batch migration before enabling Documents on an existing installation; legacy withdrawals without these snapshots are rejected clearly rather than producing an inaccurate invoice.

For internal packing lists the service exposes the narrower structural getFulfillmentPackingListBatchSnapshot(fulfillmentId) capability. The durable fulfillment run freezes ownership of the order and stock location and carries a positive allocation revision. Its result contains active and already returned immutable LOT snapshots for the requested fulfillment, including line item, inventory item and the reconstructed original picked quantity. A committed zero-managed-item run is ready with an empty allocation list; a missing run is pending.

An allocation reassignment locks that run with the affected withdrawals, increments its revision in the same transaction and emits batch-management.fulfillment-allocation-revised best effort after the transaction commits. Its name and payload schema are exported from @hartl-services/medusa-food-supplements-contracts/events/batch-management. Event failures are logged but do not turn the committed stock change into a failed request; periodic consumer reconciliation can repair the projection. The optional Documents plugin can therefore render a replacement packing-list revision without importing this plugin or reading its tables directly.

Admin UI

The plugin adds:

  • /batch-management (nested under Inventory): search an inventory item, book goods receipts as batches, reduce batch stock manually, and transfer a full batch or a partial available quantity to another warehouse.
  • Inventory-item detail side widget: open batches for the item and a shortcut to the batch page.
  • Order detail side widget: it is empty until a fulfillment commits its Batch run. Afterwards, inspect each fulfillment's automatic FEFO allocation and replace one fulfillment/line/location group's charge split manually. The selection accepts multiple charges, but its total must stay equal to that fulfillment's committed quantity.

The admin UI strings are German. See src/admin/i18n/texts.ts for the rationale (the installed admin's plugin-i18n path is available but would require adding react-i18next as a plugin dependency, which the workspace avoids).

Public Admin contracts

Batch Management currently exposes only custom Admin routes. The central Contracts package provides their DTOs, Zod request/query schemas and a path object, but not a second SDK client:

import type { CreateBatchPayload } from "@hartl-services/medusa-food-supplements-contracts/types/batch-management-admin";
import { batchManagementAdminPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/batch-management";

const payload: CreateBatchPayload = {
  name: "LOT-2026-001",
  expiry_date: "2026-12-31",
  quantity: 10,
  unit_cost: 4.95,
  booking_date: "2026-08-18",
  inventory_item_id,
  location_id,
};

await sdk.client.fetch(batchManagementAdminPaths.post.createBatch, {
  method: "POST",
  body: payload,
});

The same public contract exposes the active FEFO allocations of an order and their permitted replacements. The reassignment endpoint deliberately accepts only a complete fulfillment/line-item/inventory-item/location group, so it cannot move another fulfillment's reservation or change its item or warehouse:

import type { ReassignOrderBatchAllocationsPayload } from "@hartl-services/medusa-food-supplements-contracts/types/batch-management-admin";
import { batchManagementAdminPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/batch-management";

const payload: ReassignOrderBatchAllocationsPayload = {
  fulfillment_id,
  line_item_id,
  inventory_item_id,
  location_id,
  allocations: [{ batch_id: replacementBatchId, quantity: 2 }],
};

await sdk.client.fetch(
  batchManagementAdminPaths.post.reassignOrderAllocations(orderId),
  { method: "POST", body: payload },
);

To transfer a batch, use the source batch ID in the path and keep the same idempotency key when retrying the same booking. A successful response contains the durable transfer run and the actual source/destination batch line:

import type {
  BatchTransferResponse,
  TransferBatchPayload,
} from "@hartl-services/medusa-food-supplements-contracts/types/batch-management-admin";
import { batchManagementAdminPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/batch-management";

const payload: TransferBatchPayload = {
  destination_location_id: destinationLocationId,
  quantity: 3,
  idempotency_key: crypto.randomUUID(),
};

const response = await sdk.client.fetch<BatchTransferResponse>(
  batchManagementAdminPaths.post.transfer(sourceBatchId),
  { method: "POST", body: payload },
);

Use @hartl-services/medusa-food-supplements-contracts/schemas/batch-management only when the Admin needs an explicit runtime guard; type-only imports add no backend code to a consumer bundle. Route middleware remains the source of Medusa-side validation. Add future Store contracts only with a dedicated safe central DTO and path entry.