Skip to main content

@happyvertical/smrt-inventory

Multi-location stock tracking for the s-m-r-t framework. Strictly industry-neutral — the same primitives serve apparel, furniture, automotive, CPG, electronics, and any other vertical that counts discrete units across locations.

Installation​

pnpm add @happyvertical/smrt-inventory

Usage​

Set up SKUs, locations, and a stock service​

import {
createStockService,
InventoryLocationCollection,
} from '@happyvertical/smrt-inventory';
// The `@happyvertical/smrt-products` root entry pulls in Vite virtual
// modules (`@smrt/client` etc.) and won't resolve under plain Node /
// tsx. Import from the `/collections` subpath for server-side scripts,
// tests, and SSR runtimes that don't run the Vite plugin.
import { SkuCollection } from '@happyvertical/smrt-products/collections';

const db = { type: 'sqlite', url: 'app.db' };
const skus = await SkuCollection.create({ db });
const locations = await InventoryLocationCollection.create({ db });
const stock = await createStockService({ db });

const widget = await skus.create({
productId: 'prod-1',
code: 'WIDGET-001',
barcode: '0123456789012',
attributes: { finish: 'matte' },
});
await widget.save();

const warehouse = await locations.create({
code: 'WH-EAST',
name: 'Warehouse East',
kind: 'warehouse',
});
await warehouse.save();

Move stock through its lifecycle​

Every method writes one (or two, for transfers) StockMovement audit rows so the ledger stays in lockstep with the materialized levels.

// Inbound receipt — +qty available.
await stock.receive(widget.id!, warehouse.id!, 100, {
sourceType: 'PurchaseOrder',
sourceId: po.id,
});

// Reserve against an order — available → allocated.
// Throws InsufficientStockError if there isn't enough available.
await stock.reserve(widget.id!, warehouse.id!, 10, {
sourceType: 'Contract',
sourceId: order.id,
});

// Ship — removes from allocated, leaves the building.
await stock.fulfill(widget.id!, warehouse.id!, 10, {
sourceType: 'Fulfillment',
sourceId: shipment.id,
});

// Cycle count caught five extra units — non-zero signed delta.
await stock.adjust(widget.id!, warehouse.id!, 5, {
sourceType: 'CycleCount',
sourceId: count.id,
});

// Move stock between locations — writes transfer_out + transfer_in legs.
await stock.transfer(widget.id!, warehouse.id!, store.id!, 12, {
sourceType: 'TransferOrder',
sourceId: xfer.id,
});

Query balances and the audit log​

import {
StockLevelCollection,
StockMovementCollection,
} from '@happyvertical/smrt-inventory';

const levels = await StockLevelCollection.create({ db });
const movements = await StockMovementCollection.create({ db });

// What's on hand at this location across every state?
const here = await levels.findByLocation(warehouse.id!);

// What's the available total for a SKU across all locations?
const availableTotal = await levels.totalForSku(widget.id!, 'available');

// What movements were caused by a particular contract?
const audit = await movements.findBySource('Contract', order.id);

Catch the insufficient-stock error​

import { InsufficientStockError } from '@happyvertical/smrt-inventory';

try {
await stock.reserve(widget.id!, warehouse.id!, 9999);
} catch (err) {
if (err instanceof InsufficientStockError) {
console.log(
`Only ${err.available} available for ${err.skuId} at ${err.locationId}, requested ${err.requested}`,
);
} else {
throw err;
}
}

Multi-tenancy​

The inventory models (InventoryLocation, StockLevel, StockMovement, and the lot models Lot, LotUsage, LotCorrection, LotDocument, LotStockLevel) use @TenantScoped({ mode: 'optional' }) with a nullable tenantId. The catalog shapes (Sku, Product, ProductVariant, Material) live in @happyvertical/smrt-products and carry their own tenant decoration there; the cross-package id refs flow through unchanged. Wrap mutations in withTenant() from @happyvertical/smrt-tenancy to scope queries automatically.

import { withTenant } from '@happyvertical/smrt-tenancy';

await withTenant({ tenantId: 'tenant-a' }, async () => {
// Every read/write through skus, locations, levels, movements, and the
// StockService is filtered by tenant_id = 'tenant-a'.
await stock.receive(widget.id!, warehouse.id!, 100);
});

Opt-in DispatchBus wiring​

The package ships handlers that bridge contract:created → reserve() and fulfillment:shipped → fulfill(). Off by default; install them explicitly in your smrt.ts to enable automatic stock motion:

import { createDispatchBus } from '@happyvertical/smrt-core';
import { installInventoryDispatchHandlers } from '@happyvertical/smrt-inventory';

const bus = await createDispatchBus({ db });
const handlers = await installInventoryDispatchHandlers({
dispatchBus: bus,
db,
});

// In smrt-commerce (or your own code):
await bus.emit('contract:created', {
contractId: order.id,
lines: [{ skuId, locationId, qty }],
});

// Later, on shipment:
await bus.emit('fulfillment:shipped', {
fulfillmentId: shipment.id,
lines: [{ skuId, locationId, qty }],
});

Per-handler toggles (installContractReserved, installFulfillmentShipped) let consumers pick exactly the signals they care about. The production_order:posted handler is intentionally not installed here; that bridge lives in @happyvertical/smrt-manufacturing.

API​

Models​

ExportDescription
InventoryLocationPhysical or virtual stocking site with open-ended kind.
StockLevelMaterialized (skuId, locationId, state) → qty row. Never mutate directly — use StockService.
StockMovementAppend-only audit row. One per mutation; two for transfers. Carries a nullable lotId.
LotA batch (kind: 'lot') or single unit ('serial') with a number, attributes and status. Write via LotService.
LotStockLevelMaterialized (lotId, locationId, state) → qty row. Written only by StockService.
LotUsageA lot used by a consumer (consumerType + consumerId; 'Lot' for assemblies). Never deleted; unlinked instead.
LotCorrectionAppend-only record of every correction, with reason, old and new value.
LotDocumentA stored file (an Asset id) attached to a lot. Detached, never deleted.

Collections​

ExportDescription
InventoryLocationCollectionfindByCode, findByKind, findByPlace, findActive
StockLevelCollectiongetLevel, findBySku, findByLocation, totalForSku, totalForLocation
StockMovementCollectionfindBySku, findByLocation, findBySource, findByReason
LotCollectionfindByLotNumber(number, { skuId? }) (case-insensitive)
LotStockLevelCollectiongetLevel(lotId, locationId, state?), onHand(lotId, state?)
LotUsageCollectionfindActiveForLot, findForConsumer
LotCorrectionCollection, LotDocumentCollectionRead access

All catalog shapes (Product, Material, ProductVariant, Sku) live in @happyvertical/smrt-products. This package adds the stock-motion layer (InventoryLocation, StockLevel, StockMovement, StockService) on top.

Service​

ExportDescription
StockServiceThe only sanctioned mutation surface. Six methods: receive, reserve, release, fulfill, transfer, adjust.
createStockService({ db })Convenience factory.
InsufficientStockErrorThrown by reserve / fulfill / transfer / negative adjust when stock would go negative. Carries skuId, locationId, state, requested, available.
LotService / createLotService({ db })Lots and serials: receiveLot, correctLotNumber, correctAttributes, setStatus, recordUsage, unlinkUsage, attachDocument, detachDocument, history, traceForward, traceBack, withTransaction.
DuplicateLotError, LotInUseError, LotMismatchError, LotNotFoundError, LotCorrectionErrorLot failures.
installInventoryDispatchHandlers({ dispatchBus, db })Opt-in DispatchBus wiring for contract:created and fulfillment:shipped.

Lots and serials​

Pass lotId to any StockService mutation and the same deltas are applied per lot (with the same non-negative rule) and the lot is stamped on every movement; SKU-level balances behave as before. LotService creates the lots. Receiving a lot moves no stock, so compose both in one transaction:

await stock.withTransaction(async (tx) => {
const lots = await createLotService({ db: tx.db });
const lot = await lots.receiveLot({ lotNumber: 'B-1042', skuId, receivedQty: 40 });
await tx.receive(skuId, locationId, 40, { lotId: lot.id });
});
await lots.recordUsage(lot.id, { consumerType: 'ProductionOrder', consumerId });
const trace = await lots.traceForward(lot.id); // where it went
const sources = await lots.traceBack('ProductionOrder', consumerId); // where it came from

A lot number is not unique by default (one supplier lot can arrive on two receipts); pass unique: true, or kind: 'serial', to make it so, or idempotencyKey to make a receipt retry-safe. A repeated idempotencyKey returns the first lot even if other fields differ, and a repeated recordUsage for one consumer returns the existing usage unchanged. StockService ignores lot.status; callers decide what a quarantined lot may do. Corrections always need a reason. No authorization is checked in the package; callers authorize and pass actorProfileId.

Existing deployments must generate and apply their normal s-m-r-t schema migration before using lots: five new tables (inventory_lots, inventory_lot_stock_levels, inventory_lot_usages, inventory_lot_corrections, inventory_lot_documents) and a nullable lot_id on inventory_stock_movements (UUID on PostgreSQL/DuckDB, text on SQLite). Existing rows and StockLevel's unique key are unchanged; runtime reads do not create tables.

Types​

ExportDescription
StockState'available' | 'allocated' | 'wip' | 'qc_hold' | 'damaged'
InventoryLocationKindOpen-ended classifier string
StockMovementReasonCanonical reason vocabulary ('receipt', 'reservation', 'release', 'fulfillment', 'transfer_out', 'transfer_in', 'adjustment', 'production_consume', 'production_produce') plus free-form strings

Dependencies​

PackagePurpose
@happyvertical/smrt-coreSmrtObject / SmrtCollection / DispatchBus
@happyvertical/smrt-tenancyOptional tenant scoping
@happyvertical/sqlDatabase adapter

License​

MIT

Database initialization​

StockService.create() and transaction-scoped service construction initialize their three collections sequentially. Each initialization checks framework system tables on the shared connection; parallel probes can interfere on DuckDB. This ordering does not change the single-transaction stock/audit contract or provision application schema at runtime.

The package test suite covers cold SQLite/DuckDB initialization and repeated transaction rollback. Set SMRT_TEST_POSTGRES_URL to an isolated PostgreSQL database to exercise the same fixture on PostgreSQL.

Reorder policies and movement actors​

stock.setReorderPolicy(skuId, locationId, reorderPoint, reorderQuantity?) sets nullable decimal metadata on the available stock row. It creates a zero-balance row if necessary without inventing an audit movement. Values must be finite and non-negative; null disables monitoring, and omitting the reorder quantity clears its suggestion. Stock mutations preserve these settings. stock.levels.findBelowReorderPoint(locationId?) returns visible available rows whose quantity is strictly less than their configured point. Equality, unset thresholds, and other states do not count. Both methods retain normal tenant scoping and the setter participates in stock.withTransaction().

Every mutation accepts actorProfileId alongside source attribution. It is a nullable qualified reference to @happyvertical/smrt-profiles:Profile, copied to every ledger row including both transfer legs. Omission records null for legacy callers and unattended automation. Supply the profile ID from trusted server context; this attribution field does not authorize the caller.

Existing deployments must generate and apply their normal s-m-r-t schema migration before using these fields: add nullable decimal reorder_point and reorder_quantity to inventory_stock_levels, and nullable profile-reference actor_profile_id to inventory_stock_movements (UUID on PostgreSQL/DuckDB, text on SQLite). Existing rows remain unset; runtime reads do not migrate tables.

Svelte components​

The optional @happyvertical/smrt-inventory/svelte entry exports:

ComponentData / callback contract
StockLevelslevels, optional locationId; shows balance, reorder settings and low-stock status
MovementHistorymovements; read-only history with actor, source and note
AdjustStockFormskuId, locationId, onsubmit({ skuId, locationId, delta, reasonCode, note })
LocationListlocations, optional onedit(location); includes inactive locations
LocationFormoptional location, onsubmit({ id?, code, name, kind, placeId, active })

The entry also exports structural browser-safe data types. Hosts load authorized records and implement callbacks; components do not import database models, fetch endpoints, or manage authentication. Route adjustment submissions through StockService.adjust, attaching the trusted actor on the server. Save location forms through InventoryLocationCollection; deactivate using active: false rather than deleting history. Both forms accept disabled, prevent duplicate submissions while awaiting callbacks, and display callback failures. Reorder policy editing uses the service method above; stock quantities and movements remain read-only outside the adjustment workflow.