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:
Siavash Sameni
2026-06-11 14:13:35 +04:00
parent efc3af71d8
commit 18073afb52

View 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: ~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.