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>
This commit is contained in:
252
PRD - Inventory Management for Sellers.md
Normal file
252
PRD - Inventory Management for Sellers.md
Normal file
@@ -0,0 +1,252 @@
|
||||
---
|
||||
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: ~1–2 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: ~2–3 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:** +4–6 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 | 1–2 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 | 2–3 days |
|
||||
| 🟡 8 | G8 — Low-stock alerts | 0.5 day |
|
||||
| 🟡 9 | G9 — Mini App inventory management | 2 days |
|
||||
| ⚪ 10 | G10 — External API connector | 3–5 days |
|
||||
| ⚪ 11 | G11 — Multi-item per order | 2 days |
|
||||
| ⚪ 12 | G12 — Seller analytics | 3 days |
|
||||
|
||||
**Total for the critical + important path (G1–G8):** ~13–15 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.
|
||||
Reference in New Issue
Block a user