API — Products & Inventory
Products are batch-priced inventory. A Product aggregates physical Batches (lots); the batch is the source of truth for quantity, cost, sale price, quality metrics, GI, and photos.
Products
| Method | Path | Gating | Notes |
|---|---|---|---|
| GET | /products | requireActiveUser | Own products only. |
| POST | /products | requireActiveUser | Taxonomy leaf + quality metrics + first-lot cost. Server derives name from the leaf — client sending name → 422. Accepts source (OWN/PURCHASED) + costPerKg. |
| GET | /products/:id | requireActiveUser | |
| PATCH | /products/:id | requireActiveUser + ownership | |
| DELETE | /products/:id | requireActiveUser + ownership | Soft-delete (row + ledger preserved). |
| POST | /products/:id/buy | requireActiveUser | Buy Now — atomic direct-book. batchId required. Charges the lot's sale price. Creates a DIRECT_BOOKED SellLead + ACCEPTED Offer + Order + LeadView. |
| POST | /products/:id/buy/:offerId/cancel | requireActiveUser | Post-facto cancel within the grace window (no quota refund; trust penalty). |
| GET | /products/:id/available-batches | requireAuth | Buyer-safe lot list (id, sale price, available qty, GI, photos, metrics — no cost). |
| GET | /products/:id/pnl | requireActiveUser + ownership | Owner-only realised P&L + inventory valuation + unresolved sales. |
| POST | /products/:id/photos | requireActiveUser + ownership | Multipart → Sharp → R2. Cap 10/product. |
| DELETE | /products/:id/photos/:photoId | ownership | |
| PATCH | /products/:id/photos/reorder | ownership | { orderedIds: string[] }. |
Batches
| Method | Path | Gating | Notes |
|---|---|---|---|
| PATCH | /batches/:id/price | ownership | Update salePricePerKg (no inventory event). |
| PATCH | /batches/:id/production-cost | ownership | Owner-only editable production cost (≥0). |
| PATCH | /batches/:id/acquired-cost | ownership | Owner-only editable buy cost (≥0). |
| DELETE | /batches/:id | ownership | Soft-delete. Guards: not reserved, not on an active lead, not the last lot, no live stock (409 BATCH_HAS_STOCK). |
| POST / DELETE / PATCH | /batches/:id/photos[...] | ownership | Per-lot photos (cap 5). |
| POST / DELETE | /batches/:id/gi-certificate | ownership | GI certificate (image or PDF), GI-tagged lots only. |
Add-stock, transform, and off-platform-sale flows are additional batch surfaces (see the /batches routes and docs/flows/end-to-end-trading-flow.md).
Pricing model
Batch.salePricePerKg— the price a specific lot sells at. Buy Now charges this.displayPricePerKg— weighted average over in-stock lots (shown publicly):textdisplayPricePerKg = Σ(lot.salePricePerKg × lot.currentQtyKg) / Σ(lot.currentQtyKg)Product.referencePricePerKg— informational legacy only; never charged or shown.
Owner-only vs public fields
Cost is private
Buy cost, production cost, cost basis, blended cost, realised P&L, inventory valuation, and supplier details are owner-only — never returned by any counterparty-facing serializer (available-batches, lead/board rows, serializeBatchForLead). Guard tests enforce this.
Public (visible to any buyer): sale price, GI status/region, quality metrics, photos, and the statutory GST bracket (gstRate fraction + hsnCode).
GST
Each product resolves its GST bracket from the taxonomy (nearest-ancestor-wins). gstRate is a fraction (0.05 = 5%); 0 is a real exempt bracket, null means "no bracket". An Order snapshots the rate at accept time — a later bracket edit never rewrites a past deal, but product/lead displays read the live bracket.
Inventory ledger
InventoryEvent is append-only and batch-anchored (every event has a non-null batchId). Sale events (SELL_ACCEPTED, BUY_ACCEPTED, OFF_PLATFORM_SELL, Buy Now) stamp realised { salePricePerKg, costPerKg }. Read the timeline via the batch surfaces; filter by lot with ?batchId=.
