Catalog & Products
Every stock adjustment writes to the audit log with a reason. Shrinkage becomes a git query.
The foundation slice of the ecommerce vertical. Where Sales handles customers and invoices, and Purchasing handles vendors and bills, Catalog owns the merchant's *stock-keeping units* — the things they sell. Two entities carry the whole story: Product (the parent, with name / brand / base_price / thresholds) and ProductVariant (the SKU-level record, with stock_count and optional price_override).
Payment processing stays entirely with Stripe (see Slice M — StripeAdapter). This module handles the catalog + inventory story only. Same reason healthcare doesn't do drug interactions and legal doesn't do UPL-adjacent drafting: stay operational, use the specialist for the specialist's job.
The three views
Catalog — every product with its variant + stock rollup. Category, price range, total on-hand, low-stock variant count, status. Click a row to open the drawer: full metadata + variant table with inline *Adjust* actions.
Storefront preview — what the customer sees. Only active products with at least one in-stock variant. Query filters archived + out-of-stock *by construction* — same shape as the healthcare Portal preview.
Inventory — flat list of every variant sorted by stock band (out → critical → low → normal). The "about to sell out" view. Filter to low-stock only for the fulfillment prep list.
The two entities
Product ProductVariant
───────── ──────────────
slug product_id
name sku (tenant-unique)
description variant_name
category price_override ─ null = inherit base_price
brand stock_count
base_price weight_grams
currency barcode
status status
image_url is_default
low_stock_threshold
critical_stock_threshold
Every Product gets a default variant at creation time — merchants selling a single-variant product don't need to think about variants. Add variants (size / color / material tier) from the product drawer when the SKU needs to split.
Inventory adjustment as an audit-log primitive
Stock changes NEVER go through update_variant — that path only touches name / price_override / status / weight. Stock moves through adjust_inventory with a signed delta and a required reason:
| Reason | When | |---|---| | receipt | Received from supplier | | sale | Sold to customer (usually invoked by Order fulfillment in Slice L) | | return | Customer returned; back to sellable stock | | damage | Damaged in warehouse; not sellable | | shrinkage | Unexplained loss (theft, count error) | | count_correction | Physical count reconciled to system | | transfer_out | Shipped to another location | | transfer_in | Received from another location |
Every adjustment writes an InventoryAdjustment record with before_count, delta, after_count, reason, actor, optional external reference (PO number, RMA number, order id). The audit log becomes the shrinkage-defense report: git log data/<tenant>/InventoryAdjustment/ --grep="reason=shrinkage" gives you every unexplained loss with when + who + how much.
Refused paths:
- Adjustments that would take stock_count negative — refuses with
inventory_adjustment_would_go_negative. Overselling gets caught here, not at fulfillment time. - Unknown reasons — refuses with the valid-reasons list. No mystery deltas in the log.
Inventory bands
Same three-band shape as the deadline / SLA clocks in the other verticals — the merchant learns one signal language and reads it everywhere:
| Band | Rule | |---|---| | out | stock_count == 0 | | critical | stock_count <= critical_stock_threshold (default 2) | | low | stock_count <= low_stock_threshold (default 10) | | normal | otherwise |
Both thresholds live on the Product (not the variant), so raising them at the product level applies to all variants at once. Individual variants can be overridden by future extension if needed; the demo doesn't need it.
Variant pricing
Every variant's effective price is variant.price_override || product.base_price. Set the override only when a variant genuinely costs differently — signed editions, limited runs, size-based pricing. Otherwise leave it null and inherit.
The one place this matters at query time is the storefront preview, which shows a price *range* if variants differ ($34.00 – $49.00) or a single price if they don't.
What's wired
| Entity | Purpose | |---|---| | Product | Parent SKU record with name / brand / base_price / thresholds | | ProductVariant | Child SKU-level record with stock_count + optional price_override | | InventoryAdjustment | Audit-log record for every stock change with reason + before/after |
Tools (8): list_products, create_product (auto-creates default variant), update_product, list_variants, create_variant, update_variant (metadata only — not stock), adjust_inventory, catalog_overview.
What's NOT in this slice
- Cart + Order lifecycle — Slice L. Orders live in their own state machine with inventory reservations at cart-add and commits at fulfillment.
- Stripe payment integration — Slice M. Payment state comes from Stripe webhooks; Order.payment_status mirrors.
- Fulfillment + Returns / RMA — Slice N. Shipment tracking, tracking numbers, the return-authorization workflow.
- Discounts + Coupons + Segments — Slice O. Promo codes, VIP tiers, cart-abandonment nudge workflow.
- Product images at scale — image_url is a single field for the demo. Multi-image galleries + variant-specific images fold in when the CDN story lands.
- Multi-location inventory — variants have a single
stock_count. Multi-warehouse merchants need per-location breakdowns; that's a schema evolution when the demand appears.
The catalog is the foundation. Everything else in the ecommerce vertical builds on top.