---
title: "Inventory & Stock"
description: "Stock invariant, checkout holds, commitSaleForOrder, cancel/refund restore, and admin/vendor stock surfaces"
locale: "en"
---
# Inventory & Stock

Ring tracks quantity with one locked rule: **product stock equals available plus reserved**. Checkout sells only `available`. PaymentConductor paid handlers call `commitSaleForOrder` so fulfill and deduct stay atomic.

> **Warning**
> Do not treat Nova Poshta `/api/shipping/novapost/warehouses` as ERP warehouses. ERP default: label `zero-warehouse` ↔ store id `1` (`DEFAULT_INVENTORY_STORE_ID`).

## Locked invariant

```
store_products.stock  ===  inventory_levels.available  +  inventory_levels.reserved
sellable              ===  inventory_levels.available
```

| Event | stock | available | reserved |
|-------|-------|-----------|----------|
| Bootstrap / restock +q | +q | +q | — |
| Reserve q | — | −q | +q |
| Fulfill (paid) q | −q | — | −q |
| Cancel / TTL q | — | +q | −q |
| Refund restore +q | +q | +q | — |

**Wave 1 (2026-07-23):** digital / `instantDelivery` / preorder lines skip reserve+deduct (commissions still settle). Authenticated carts soft-hold via `cart_${userId}` (5 min TTL) then promote at checkout. ProcessConductor `inventory-drift` asserts the invariant. MCP: `ring-stock-get` / `ring-stock-low` / `ring-stock-adjust` / `ring-reservation-list`.

After paid deduct, levels re-assert `available = stock − reserved` so paid-without-hold paths cannot leave stale available.

### For founders

## Operator checklist

1. Open `/admin/store` — see totals and pending settlements.
2. `/admin/store/stock` — filter low / critical / out; restock with a **reason**; read the movement timeline (warehouse column shows `zero-warehouse`).
3. Vendors use `/vendor/stock` — only **their** products; bulk restock supported.
4. Smoke: stock 5 → checkout 2 → available 3, reserved 2, stock still 5 → pay → stock 3, reserved 0, available 3.

> **Tip**
> If checkout returns “Insufficient inventory”, sellable (`available`) was too low — not “the product.stock field looked high.” Reserved units are already held for another open order.

### For developers

## Tables

| Table | Purpose |
|-------|---------|
| `inventory_levels` | Per product+store (`id` = `productId_storeId`) |
| `inventory_reservations` | Order holds with TTL; cancel restores available; paid fulfills reserved |
| `stock_movements` | Audit: `sale`, `return`, `adjustment`, … |

Schema: `data/schema.sql` (+ historical `008_inventory_schema.sql`). Apply via [migrations](/docs/getting-started/migrations.md).

## Key APIs (verified)

| Function | File | Behavior |
|----------|------|----------|
| `ensureInventoryLevel` | `inventory-sync.ts` | Bootstrap `{available: stock, reserved: 0}` when missing |
| `reserveInventoryForOrder` | `inventory-sync.ts` | Never skips; 409 when insufficient `available` |
| `releaseReservation(id, fulfilled)` | `inventory-sync.ts` | Cancel restores available; **fulfill decrements reserved only** (Flaw E) |
| `commitSaleForOrder` | `inventory-sync.ts` | Single txn; idempotent if sale movements exist for `orderId` |
| `restoreStockForOrder` | `inventory-sync.ts` | Refund/void; idempotent via `return` movements |
| `updateStock` | `erp-stock-service.ts` | Transactional product + levels; reject `set` below reserved |
| `POST /api/erp/stock/initialize` | `app/api/erp/stock/initialize/route.ts` | Admin bulk init |

### Paid rails (all call `commitSaleForOrder`)

- `lib/payments/conductor/handlers/store-order.ts` (WFP) — also `restoreStockForOrder` on Refunded/Voided
- `store-order-stripe.ts`, `store-order-paypal.ts`
- `app/api/store/payments/credit/route.ts`, `…/token/route.ts`

### Cancel path

`app/_actions/admin-orders.ts` → unpaid `canceled` releases holds; paid cancel restores via `restoreStockForOrder`.

TTL: ProcessConductor `cleanup-reservations` → `cleanupExpiredReservations` (cancel path).

> **Warning**
> WayForPay Refunded/Voided calls `restoreStockForOrder`. Stripe/PayPal refund event parity is still open — tracked as a FutureFeature on [Commissions](/docs/features/erp/commissions.md).

```mermaid
flowchart LR
  subgraph checkout [Checkout]
    R[reserve]
  end
  subgraph paid [Paid]
    C[commitSaleForOrder]
  end
  subgraph reverse [Reverse]
    X[release_or_restore]
  end
  R --> C
  R --> X
  C --> X
```

## Admin & vendor UI

| Surface | Route | Actions |
|---------|-------|---------|
| Hub | `/admin/store` | Summary cards + tabs |
| Stock | `/admin/store/stock` | Filters, reason restock, movements |
| Vendor stock | `/vendor/stock` | Ownership-gated restock + bulk |

## Constants

`features/store/constants/stock.ts` — `ZERO_WAREHOUSE_ID`, `DEFAULT_INVENTORY_STORE_ID`, `STOCK_THRESHOLDS`.

  
- [features/erp](/docs/features/erp.md) — Prerequisite: ERP hub overview and operator surfaces.

  
- [features/payment-conductor](/docs/features/payment-conductor.md) — Depends-on: paid webhooks invoke commitSaleForOrder.

  
- [features/erp/commissions](/docs/features/erp/commissions.md) — Next-step: settlements appear after successful stock commit.

  
- [features/store](/docs/features/store.md) — Same-workflow: catalog and checkout feed reservation items.
