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

13 KiB
Raw Blame History

title, tags, created, status
title tags created status
Inventory Management for Sellers — Gap Analysis & Design Recommendations
prd
inventory
digital-goods
seller
architecture
2026-06-11 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.


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


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.

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