Files
nick-doc/PRD - Inventory Management for Sellers.md
Siavash Sameni 18073afb52 docs: add inventory management gap analysis + design recommendations
Covers gaps in Mojtaba's digital-goods work (G1–G12), architecture
decision (module vs microservice), pool schema additions, and
auto-fulfillment hook design. Priority order for the next 13–15 eng-days.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:13:54 +04:00

253 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Inventory Management for Sellers — Gap Analysis & Design Recommendations
tags: [prd, inventory, digital-goods, seller, architecture]
created: 2026-06-11
status: draft
---
# Inventory Management for Sellers
## Gap Analysis & Design Recommendations
---
## 1. Current State (as of 2026-06-11)
Mojtaba shipped the first slice of this feature today across two commits:
### Backend — `e48c7f3`
- `digital_goods_inventory` Drizzle/PG table with enums `digital_good_type` (`code | file | image`) and `digital_good_status` (`available | reserved | delivered`)
- `DigitalGoodsService`: inventory CRUD, `deliverToOrder` (from stock or ad-hoc), `getOrderDelivery` (content hidden until buyer reveals), `revealForBuyer` (sets `revealed_at`, advances order to `delivered`)
- REST routes at `/api/digital-goods`
- Socket event `delivery-update` emitted on deliver + reveal
### Frontend — `4d526ae` + `4a9d44b`
- `ShopSettingsInventory` dashboard page at `/dashboard/shop-settings/inventory` — list / add / delete stock items (code, file, image with upload)
- `RequestDigitalDeliverCard` — seller picks from inventory or delivers ad-hoc inside a request detail
- `RequestDigitalReceiveCard` — buyer reveals and copies/downloads the delivered good
- Nav updated: "Products" group, "Inventory" tab
### What the schema actually supports
Each row in `digital_goods_inventory` is a single item. `sellerId` links to the seller. `purchaseRequestId` is null until delivery. No concept of a pool, batch, expiry, or external source yet.
---
## 2. Gaps
The gaps are grouped by severity.
---
### 2.1 Critical — blocks the core digital-goods flow being useful
**G1 — Digital goods delivery not wired in the Mini App**
`RequestDigitalDeliverCard` and `RequestDigitalReceiveCard` exist only in the web dashboard request detail. `TelegramRequestDetailView` does not render them. Sellers who work entirely from Telegram cannot deliver a digital good from Telegram; buyers cannot reveal it there either.
*Effort: ~12 days.* The components exist; the Mini App detail view needs to detect `deliveryType === 'online'` or `productType === 'digital_product'` and branch to them.
---
**G2 — Content stored in plaintext**
`content` is a `text` column with no encryption. For gift card codes and license keys this is a significant exposure risk: any DB read (SQL console, backup restore, accidental log) exposes live secrets.
*Effort: ~1 day.* AES-256-GCM encrypt on write, decrypt on read in `DigitalGoodsService`. The pattern is already in `tenantBotService.ts` (AES-256-GCM on bot tokens). The key can live in env as `DIGITAL_GOODS_ENC_KEY`. Existing rows need a one-off migration script.
> **Recommendation: do this before any real gift cards go in.** It is a one-way door — once sellers upload live codes, retroactive encryption requires a data migration.
---
**G3 — No auto-fulfillment on FUNDED**
Today the seller must manually open the request, pick an inventory item, and click deliver. For high-volume digital goods (gift cards, license keys, game codes) this defeats the purpose of having a stock. The platform should draw from inventory automatically when payment is confirmed.
This requires a hook in the payment/escrow `FUNDED` state transition to call `digitalGoodsService.deliverToOrder`. Currently there is no such hook. Without it, a seller selling 50 Amazon gift cards a day still has to manually deliver each one.
*Effort: ~23 days.* Covered in detail under §4.
---
### 2.2 Important — limits usefulness significantly
**G4 — No pool / batch concept**
The schema has individual items with no grouping. A seller who uploads 100 `$50 Amazon gift cards` has 100 unrelated rows. There is no "pool" with a name, stock count, or low-stock threshold. This makes:
- Bulk import awkward (100 individual POST calls)
- Stock-level monitoring impossible
- Template-to-inventory binding (which pool auto-fulfils this template?) ambiguous
*Effort: ~3 days.* See §4 for the recommended schema addition.
---
**G5 — No bulk import**
Gift card codes are typically exported from a supplier as a CSV of codes. The current UI adds items one by one. A seller with 500 codes cannot use this flow.
*Effort: ~1 day.* CSV upload endpoint that parses codes line-by-line, creates rows in bulk under a named pool.
---
**G6 — No expiry handling**
Gift cards and license keys expire. The schema has no `expires_at` column. An expired code delivered to a buyer is a dispute and a refund. No background job checks for expiry.
*Effort: ~1 day.* Add `expires_at` to the table, exclude expired items from `deliverToOrder`'s available-item query, add a nightly cron that flags items expiring within 7 days and notifies the seller.
---
**G7 — No template-to-pool binding**
There is no way for a seller to say "when someone orders from template X, auto-draw from pool Y". The deliver step always requires the seller to pick manually. This is the missing link between the inventory and the marketplace.
*Effort: ~1 day.* A `templateInventoryPool` join table or a `defaultPoolId` FK on the `RequestTemplate` model.
---
**G8 — No low-stock alerts**
No notification or warning when a pool is running low. A seller goes to sleep with 5 items left, sells out overnight, and buyers start getting failed deliveries with no notice.
*Effort: half a day.* Threshold column on the pool, checked after each delivery; emit a notification when remaining stock drops below it.
---
### 2.3 Nice-to-have — for future iterations
**G9 — No Mini App inventory management**
Sellers can't view or manage their stock from inside Telegram. Currently desktop-only. Not blocking but limits the mobile-first vision.
**G10 — No external API connector**
No way to connect to a gift card supplier API to auto-restock. The schema has no `fetchUrl` / credentials concept. Straightforward to add once the pool model exists — an `externalSource` JSONB column on the pool row with a fetcher called on low-stock.
**G11 — Single item per order**
`getOrderDelivery` returns the first matching row (`LIMIT 1`). Multi-item digital orders (e.g., "3 game codes") are not supported. The schema can hold multiple items per request but the delivery and reveal flows assume one.
**G12 — No seller analytics on inventory**
No visibility into which templates consume which pools fastest, redemption rates, expiry waste, etc.
---
## 3. Architecture Recommendation: Module vs. Separate Service
This is a real decision. Here are the honest trade-offs.
---
### Option A — Keep inside the monolith (recommended for now)
**What it means:** `backend/src/services/digital-goods/` stays where Mojtaba put it. The pool concept, auto-fulfillment hook, and external API connector are all added to this directory.
**Why it is the right call today:**
1. **Auto-fulfillment is deeply coupled.** The `FUNDED` state transition lives inside `PurchaseRequestService` and the payment webhook handlers. Hooking into it from a separate service requires either an event bus (Kafka, Redis Streams, etc.) or polling — neither of which exists on this infra. Doing it as a direct function call within the monolith is one line.
2. **Auth and tenant context are already there.** The inventory must be tenant-aware (each white-label shop has isolated inventory). `tenantAuthService` and the seller's `pgId` are in scope in the existing middleware stack.
3. **Notification and socket are in-process.** `NotificationService` and `emitToRoom` are already being called in `DigitalGoodsService`. Moving to a microservice would require HTTP or pubsub round-trips for those.
4. **Operational cost.** The team runs one backend container. A second service means a second container, second CI pipeline, second set of env vars, second Caddy route, second Arcane project entry, and a shared-secret inter-service auth scheme. For a feature this size that overhead is not justified.
**Trade-off to accept:** The inventory module is co-deployed with the rest of the backend. A runaway migration or bug in inventory code can affect the escrow flow. Mitigate with clean domain isolation (no circular imports, no shared mutable state) and feature-flag the auto-fulfillment hook.
---
### Option B — Separate microservice
**When this becomes the right call:**
- External API connectors start requiring long-running polling jobs that would block the monolith's event loop if poorly written
- Inventory SLA needs diverge (e.g., batch import of 10k codes should not compete with payment webhooks for DB connections)
- Team size grows to the point where the inventory module has its own owner who deploys independently
**What it would need:** A message broker (Redis Streams is already present as a Redis instance) to receive the `order.funded` event from the monolith. The monolith publishes the event; the inventory service subscribes, draws from pool, delivers, and calls back via `/api/digital-goods/internal/deliver` with a shared secret.
**Honest cost estimate:** +46 days to set up properly vs. 0 additional infrastructure for the module approach. Not worth it at current scale.
**Conclusion:** Build as a module now. Design the pool model and auto-fulfillment hook so the event boundary is obvious (the payment service calls a single `InventoryFulfillmentService.fulfillOnFunded(requestId)` — that one call is easy to replace with a pubsub publish later if you ever extract it).
---
## 4. Recommended Schema Additions
The following is what the next iteration should add. It does not replace the current `digital_goods_inventory` table — it adds a `digital_goods_pools` table above it and a FK on templates.
```sql
-- Pool: a named batch of homogeneous items
CREATE TABLE digital_goods_pools (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
seller_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL,
name varchar(200) NOT NULL,
description text,
type digital_good_type NOT NULL, -- reuse existing enum
low_stock_threshold int NOT NULL DEFAULT 5,
-- Future: external supplier fetch config (null = manual only)
external_source jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Link existing items to a pool
ALTER TABLE digital_goods_inventory ADD COLUMN pool_id uuid REFERENCES digital_goods_pools(id) ON DELETE SET NULL;
-- Expiry
ALTER TABLE digital_goods_inventory ADD COLUMN expires_at timestamptz;
-- Template default pool (auto-fulfillment binding)
ALTER TABLE request_templates ADD COLUMN default_inventory_pool_id uuid REFERENCES digital_goods_pools(id) ON DELETE SET NULL;
```
---
## 5. Auto-Fulfillment Hook (G3 design)
The hook belongs in `DigitalGoodsService` as a new method:
```
InventoryFulfillmentService.fulfillOnFunded(requestId: string): Promise<void>
```
Called from the payment state machine after escrow is confirmed funded. It:
1. Loads the request's template → checks `defaultInventoryPool`
2. If no pool is bound, returns immediately (manual delivery flow, no change)
3. Picks the oldest non-expired `available` item from the pool (atomic `UPDATE ... WHERE status='available' ORDER BY created_at LIMIT 1 RETURNING *` to prevent double-draw under concurrent orders)
4. Marks it `reserved`, links to `purchaseRequestId`
5. Calls the existing `deliverToOrder` notification + socket emit
6. If pool stock drops below `lowStockThreshold`, fires a low-stock notification to the seller
The call site in `PurchaseRequestService` (or wherever the FUNDED transition is triggered) is one line. Wrap it in try/catch so a fulfillment failure does not block the payment confirmation.
---
## 6. Priority Order
| Priority | Gap | Effort |
|----------|-----|--------|
| 🔴 1 | G2 — Encrypt content at rest | 1 day |
| 🔴 2 | G1 — Mini App delivery/reveal wiring | 12 days |
| 🟠 3 | G4 — Pool/batch schema | 3 days |
| 🟠 4 | G5 — Bulk CSV import | 1 day |
| 🟠 5 | G6 — Expiry handling | 1 day |
| 🟠 6 | G7 — Template-to-pool binding | 1 day |
| 🟠 7 | G3 — Auto-fulfillment hook | 23 days |
| 🟡 8 | G8 — Low-stock alerts | 0.5 day |
| 🟡 9 | G9 — Mini App inventory management | 2 days |
| ⚪ 10 | G10 — External API connector | 35 days |
| ⚪ 11 | G11 — Multi-item per order | 2 days |
| ⚪ 12 | G12 — Seller analytics | 3 days |
**Total for the critical + important path (G1G8):** ~1315 engineer-days.
---
## 7. What to Defer
- **Separate microservice**: defer until G10 (external API connectors) creates genuine pressure, or team size makes co-deployment a problem. Revisit at that point — the module boundary will be clean enough to extract.
- **G10 (external APIs)**: meaningful only after pools exist and auto-fulfillment is working. Don't design the connector before the data model is stable.
- **G12 (analytics)**: build after the happy path is solid and you have real transaction data to show.