From 18073afb52aef839a94bc52b2cd715266e394bea Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Thu, 11 Jun 2026 14:13:35 +0400 Subject: [PATCH] docs: add inventory management gap analysis + design recommendations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PRD - Inventory Management for Sellers.md | 252 ++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 PRD - Inventory Management for Sellers.md diff --git a/PRD - Inventory Management for Sellers.md b/PRD - Inventory Management for Sellers.md new file mode 100644 index 0000000..1481b00 --- /dev/null +++ b/PRD - Inventory Management for Sellers.md @@ -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 +``` + +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.