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 three inventory models (InventoryLocation, StockLevel, StockMovement) 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:createdreserve() and fulfillment:shippedfulfill(). 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.

Collections

ExportDescription
InventoryLocationCollectionfindByCode, findByKind, findByPlace, findActive
StockLevelCollectiongetLevel, findBySku, findByLocation, totalForSku, totalForLocation
StockMovementCollectionfindBySku, findByLocation, findBySource, findByReason

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.
installInventoryDispatchHandlers({ dispatchBus, db })Opt-in DispatchBus wiring for contract:created and fulfillment:shipped.

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