--- 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.