Inventory module
The stock console. SKU search → variant detail → Product Card editor → stock-movement ledger. Split out of the former two-face Products module in v0.7.69; the Purchasing side became its own Purchases module.
| Tab label | Inventory |
| Tab accent | #0d9488 (teal) |
| Strip position | 5th — right after Purchases (position set by setTabOrder() in src/modules/index.ts, v0.7.409+) |
| Folder | src/modules/inventory/ |
| Migrations shared | 018_products_catalog.sql, 019_inventory.sql, 022_products_perms_consolidate.sql, 024_rma.sql, 032_bundled_accessory_tax.sql, 054_inventory_units.sql, 061_kit_components.sql, 062_supplier_name_key.sql, 067_unit_rental.sql, 074_rental_rate_tiers.sql, 075_rental_rate_basis.sql, 076_rental_subday.sql, 077_rental_damage_hold.sql, 091_inventory_images.sql, 092_unit_special_price.sql |
| Steward | #inv-search — focus stewardship adopted v0.7.91 (closes the sweep across all five module tabs); scanner bursts route into the search box |
| Ledger heading | Title-case "Stock Movements" (v0.7.99) + shared kbDatePicker universal date navigator (BIKE.L1-0010, v0.7.98) — today shows the full movement history; picking a past day filters by movement date |
What it owns
src/modules/inventory/
index.ts — registers the module with the Kernel
view.ts — markup + styles for the stock console
client.ts — browser JS (search, variant detail, Product Card editor, movements)
api.ts — server-side routes (/api/m/inventory/*)
The inventory-movement engine lives at core/movements.ts — extracted to Core in v0.7.69 so Sales and Service can decrement stock through the same code path Inventory does. Every change to inventory.quantity_on_hand writes one immutable row to inventory_movements.
What it does
SKU search. Free-text across product name, variant SKU, barcode; returns matching variants with on-hand readout per row. Picking one loads the variant detail.
Variant detail — read-only. Rebuilt to prior-implementation Inventory-tab parity in v0.7.67. Sections:
| Section | Content |
|---|---|
| Hero | Product name + variant descriptor (size / colour / year) + SKU + primary image |
| Stock & pricing | On hand → Reserved → Available — the three numbers reconcile (Available = On hand − Reserved, v0.7.402). See Reserved-vs-available below |
| Vendors | The variant's supplier links (supplier_products — supplier, cost, primary flag). Rich vendor record (account #, address, website / B2B portal, logo, drop-ship) opens from a row click |
| IDs | Barcode / UPC · aim_id · internal id |
| Description | Free-form product description + specs |
Stock Card reserved-vs-available (v0.7.402)
Every non-zero allocation bucket shows (v0.7.402). Before v0.7.402 the Stock Card itemized only sale / service / rental allocations — so the holds Sales earmarks for layaway (SALES-22, v0.7.398) and special order were invisible: Available would drop below On hand with no explanation. Now:
- On hand — physical units in the shop
- Reserved — total of every non-zero allocation across seven buckets:
sale,service,layaway,special order,loaner,rental,online. Each non-zero bucket is itemized in parentheses next to the total so the operator can see which flow is holding which units. - Available — labelled "(on hand − reserved)" so the three numbers reconcile arithmetically on the card.
Intentional divergence from the prior implementation (Tom-authorized). The monolith's Stock Card itemized only sale / service / rental (renderDetail in the old public/index.html) and hid the other buckets. kvick-bike goes beyond that so the new Sales-side earmarks are visible on the surface most operators check first. Underlying quantity_available math is unchanged — the DB has always netted all seven buckets; this is a presentation fix in the read-only Stock Card, not a substrate change.
Product Card editor. The full editable surface for a product + its variants — one dialog covering every field on every section. Replaces the older Face-A section-edit chain that was removed in v0.7.68 (that chain was orphaned once the Product Card superseded it). Save writes to the catalog + inventory rows; the audit chain captures the diff.
The three stock actions. The only way quantity_on_hand changes from this surface:
| Action | Process | What it does |
|---|---|---|
| Adjust | BIKE.L2-0046 | Signed delta with a reason; writes a movement_type='adjust' row via core/movements.ts |
| Cycle count | BIKE.L2-0084 | Set the on-hand to the counted number; movement records the implied delta |
| Write-off | BIKE.L2-0085 | Removes units with a reason (damage, theft, demo) |
Every action goes through Core's movement engine — write the movement row, then update inventory.quantity_on_hand, then audit. The on-hand can never drift from the sum of its movements.
/count — live commit from the Sales till (v0.7.196). The /count endpoint accepts a cycle-count-style update (set on-hand to N), writes one immutable inventory_movements row through core/movements.ts, and returns. It's what the Sales cart's Actual Stock Count stepper (renamed from "OOS" in v0.7.272) commits to — the miscount-correction path where the cashier tells the register the real on-hand without switching to Inventory. The confirmation toast is colour-keyed to the Inventory tab (v0.7.254) so the cashier sees which tab the change landed in.
Cross-mount from Sales (v0.7.260/.261). The Inventory Product Card editor is called by Sales too — clicking a product line in Sales opens the same editor in place (not a copy). Inventory's client reparents its New-product modal to <body> on mount and exposes window.kbOpenProductEditor(vid); Sales' kbEnsureModule('inventory') mounts Inventory hidden if it hasn't been visited yet. Reused, not copied. See Sales — Product-click → Inventory editor, in place.
Bulk items are real SKUs (v0.7.136, migration 039). The product_variants.is_bulk flag distinguishes bulk consumables (grease, chain lube, small parts) from bike-serial variants; both live in the same catalog, both route through the same movement engine. The Sales bulk-item palette reads is_bulk = 1; the Backorder → Bucket flow treats bulk lines the same as any other line.
Stock-movement ledger — 7 analytical views (BIKE.L2-0149 + 0145). The ledger pane at the bottom carries a Tier-2 view Pill that selects one of seven views:
| View | Shape |
|---|---|
today | Movement-shaped: every movement in the last 24 hours |
sold_today | Movement-shaped: sales-type movements today |
last_received | Movement-shaped: most-recent receive movements |
top_sellers | Variant-shaped: highest sale volume in the window |
bottom_sellers | Variant-shaped: lowest sale volume |
out_of_stock | Variant-shaped: quantity_on_hand <= 0 |
old_stock | Variant-shaped: no movement in N days |
/movements?view=X is the unified endpoint. The ledger <thead> + rows adapt per shape; variant rows click through back into the variant detail. Replaces the older movement-type filter dropdown.
Recent purchasers (BIKE.L2-0148). The variant detail surfaces a Recent purchasers section — who bought this SKU, newest first, with the sale_id for each. Click a row to open the sale in a read-only viewer (GET /api/m/sales/transaction).
Reorder dashboard (BIKE.L2-0088 / 0089). A low-stock surface that lists every variant at or below its reorder_point, with a per-row Add to Bucket action pre-quantitied to reorder_qty. Reachable regardless of low-stock count (v0.7.60 fix; empty state reads "All stocked above reorder point").
Print tag (BIKE.L2-0047). Variant detail footer Print tag opens a preview of the SKU tag — product name, price, a hand-rolled CODE39 barcode of the SKU, SKU text — then Print opens a print window.
Bundled-accessory tax rule (v0.7.81, migration 032). Accessories bundled with a bike at Sales time drop PST (BC PST Bulletin 204); standalone they carry both GST + PST. The tax_categories seed carries is_context_dependent + context_rule for the bundled case; Sales computePerLineTaxes reads the context at cart time.
Vendor RMA. Vendor returns (migration 024, BIKE.L2-0091) — rma header (status: draft / sent / received / closed / cancelled) + rma_lines (per-variant qty / reason / disposition). Operator UI ships from this module's Vendors surface.
Units — count plus identity (v0.7.417, migration 054)
Inventory used to track a count. For 100 brake pads that's the whole truth — 100 interchangeable units, quantity_on_hand = 3 is "three anonymous pads." It's wrong for a bike, where the shop must answer which one. inventory_units adds one row per physical unit hanging off the inventory row (variant × location). The count stays on inventory.quantity_on_hand as the fast path every search / till / ledger reads; the identity lives here. For a serialized SKU the two must reconcile: on_hand = count of units in an on-hand status.
Per-unit cost_cents is the strongest reason this table exists, beyond serials. A new Norco Fluid at $1,400 and the same model taken in trade at $200 are the same product and the same variant. inventory carries one cost + a weighted average — it cannot represent two units of the same variant at two costs. Margin on used and traded stock was wrong for the whole shape of the shop's business until this. That line matters; it's what keeps a future session from simplifying the table back to a plain serial list.
Row shape. id · inventory_id · variant_id · location_id · serial_number NULL (a unit can exist before its serial is known — "ordered but not yet arrived," "arrived and not yet recorded") · cost_cents (per unit) · received_at · acquired_from_customer_id (for trade-ins) · status enum — on_order / in_stock / sold / on_rental / consigned / traded_in / written_off. Partial unique index on serial_number so a real serial cannot be entered twice while unknown NULLs don't collide.
Stock Card surface. A read-only Units section renders on the Stock Card, currently in its empty state pending downstream flows. Serial capture is a single-purpose popup on the Stock Card for adding a serial to an existing unit.
What's true now vs what isn't — the foundation exists; the flows don't. Keep the distinction sharp:
| Behaviour | Status |
|---|---|
| Inventory CAN hold individual bikes with their serial + per-unit cost | ✅ shipped v0.7.417 |
| Stock Card shows the Units section (read-only) | ✅ shipped v0.7.417 |
| Sales till KNOWS which bike (which serial) is being sold when a serialized SKU is rung | ❌ not yet. Needs Sales to consume a unit at sale — unbuilt, undecided |
| Purchases RECEIVING creates unit rows | ❌ not yet. Unbuilt |
Deliberate divergence from AIM parity — flag as intentional so a future parity sweep doesn't read it as drift. AIM's serial row is born at receiving (scsasrl.sas_daterec). Ours can be born at order — a unit with no serial yet still means a specific bike we're expecting, which makes "3 ordered, 1 arrived, 2 outstanding" expressible per unit. The lifecycle itself is still an open decision; this divergence is in the table's capability, not yet in a shipped flow.
Wipe cascade — flagged in the migration header. inventory.variant_id + .location_id are both ON DELETE CASCADE from migration 019, and inventory_units.inventory_id is CASCADE too. A catalog wipe therefore deletes product_variants → cascades to inventory → cascades to every row in inventory_units, silently. Whether units are re-derived after a reload is a Migration manifest decision, not this module's. See the Migration module.
Kits — one line, many components (v0.7.015, migration 061)
A kit is a product variant that bundles component parts. Set up on the Inventory Product Card (Kit engine slice 1); ships as ONE flat-rate line at the till; selling it consumes its components from stock, and reversing the sale (refund / void / exchange) restocks them. The kit variant itself is notional — assembled on demand — so its own on-hand is never touched; only the components move.
Rental bike — per-PHYSICAL-bike config on the unit (mig 067 → 077)
A rental bike is an individual physical bike, not a SKU. "Each bike is an individual, unique entity — you can't have two of the same bike because they are serialized" (Tom, 2026-07-28). So the rental fields moved from products → inventory_units in mig 067:
is_rental(0/1) — this specific bike is in the rental fleet.rental_category— free-form category (townie, MTB, etc.) so the Rentals fleet picker can filter.rental_rate_cents— the base per-day rate for this bike.
The Product Card's per-unit "This unit only" box carries these; the product-level fields (retired migs 063/066) are no longer authored from the UI. products.is_rental remains as a DERIVED flag (set when any of a product's bikes is a rental unit) so the Sales provenance tag ("Rental") keeps working off the product row.
Rate tiers on the bike card (mig 074 + 075 + 076) — hourly / half-day / weekly rates each get their own tier column. Basis + date-input at the till is operator discretion (see Rentals — the tab surface).
Per-bike CC damage-hold amount (mig 077, inventory_units.rental_damage_hold_cents). NULL = no hold set for this bike; a booking's total hold = SUM of its bikes' holds. Set every bike the same for a flat shop-wide hold. Superseded by the mig-097 resolution ladder (shop-default → group → bike) — the per-bike figure is now a shop-visible override, not the only source of truth (a number typed onto a bike months earlier is not a policy). See Rentals — damage-hold ladder.
Per-bike special price (mig 092, v0.7.1-era, inventory_units.special_price_cents). "A fleet bike is not worth the SKU's retail — it has been ridden — so THIS serial carries its own price" (Tom, 2026-08-13). NULL = no special price, use the SKU's. Set in the "This unit only" box; shown on the Stock Card unit row. Applies at Sales-till selling of the specific serial.
Photos on the Stock Card (mig 091)
"Add a picture upload in the inventory card" (Tom, 2026-08-11). Photos live in inventory_images — base64 in a D1 column, the same proven storage as the Shop Board announcements (no R2, no bucket). Two attach-points:
product_variants.image_id— the SKU-level default; every physical bike of this SKU inherits it.inventory_units.image_id— the per-physical-bike override (a fleet bike with a scratch worth showing, a bike with a custom decal).
Both are SET NULL on delete of the underlying image row. The Rentals Fleet List lens (v0.7.1) uses these — small inline row photos rather than the earlier tile grid (see Rentals — Fleet / List / Calendar).
Rental earnings & history accordion on the Stock Card (afb5d94)
"Associate pre-tax revenue with each bike, plus a rental history, in an accordion on the Inventory card" + James's request for a product-card line reading "dollars earned in rentals".
Per-bike money is reconstructed from two sources — neither complete alone: the fee rides on transaction_lines.rental_unit_id (the deposit handoff), but a rental booked with no deposit has no such line yet — so the accordion joins rental_booking_bikes (the source of truth for which bike, which booking, which rate) with the Sales side (the source of truth for what money actually landed). Pre-tax revenue only (a rental's late fee, damage capture, and add-ons each land as their own lines with the same rental_unit_id, so they all roll up per bike). The accordion lists every booking this bike has been on with dates + basis + fee + status.
The engine lives at core/kits.ts as one shared applyKitLine function, wired into all 7 Sales stock sites (and Service /invoice when it lands) so both modules produce identical behaviour by construction — the single-decrement-function guard from the design review. Service / labour components are skipped by stock_behavior (nothing to move; the flat-rate price covers them). Every component movement still passes through core/movements.ts, so the ledger, the on-hand invariant, and shrinkage stay truthful.
Migration 061 creates kit_components(kit_variant_id, component_variant_id, quantity) with a unique (kit_variant_id, component_variant_id) index. Wipe manifest: Migration owns folding this table into a fresh wipe/reload — the inventory_units-in-neither-list lesson.
Bike-brand autocomplete + model normalization (120ab9c)
The Brand field on the Product Card autocompletes from core/bike-brands.ts — 173 canonical Canadian bike makes — so "Norco" / "NORCO" / "norco " stop producing three catalog entries. Model text runs through normalizeModelName on save: trim, collapse internal whitespace, title-case each word without mangling model codes (digit tokens 29er / 20 / 4 and short caps FX / VLT / SS / ENVE kept as typed). Deliberately does NOT fix genuine misspellings — a curation tool's job, not a deterministic rule's.
Data model
Catalog (018). tax_categories (BC two-rate model + bundled-accessory context; migration 032 seeds the context rule), tax_rates, categories + subcategories, manufacturers, products, product_variants (the canonical SKU), product_barcodes.
Inventory (019). inventory_locations (single-shop seeds "Main Shop"), inventory (per variant per location: quantity_on_hand, reorder thresholds), inventory_movements (the immutable ledger — type, signed quantity, before / after on-hand, reason, originating reference).
Vendor RMA (024). rma + rma_lines.
Permissions
| Permission | What it gates |
|---|---|
screen.inventory | The Inventory tab is visible at all |
inventory.read | Search + variant detail + movement ledger |
inventory.edit | Product Card editor + Adjust / Cycle count / Write-off |
inventory.rma | Vendor RMA lifecycle |
See also
- Three strata — Kernel · Meta · Modules — the architectural shape (
core/movements.tsextracted here) - Purchases module — the PO / Bucket side that used to be Face B
- Sales module — sells parts that decrement through the same movement engine
- Service module — parts lines decrement through the movement engine at invoice time