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>
13 KiB
title, tags, created, status
| title | tags | created | status | |||||
|---|---|---|---|---|---|---|---|---|
| Inventory Management for Sellers — Gap Analysis & Design Recommendations |
|
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_inventoryDrizzle/PG table with enumsdigital_good_type(code | file | image) anddigital_good_status(available | reserved | delivered)DigitalGoodsService: inventory CRUD,deliverToOrder(from stock or ad-hoc),getOrderDelivery(content hidden until buyer reveals),revealForBuyer(setsrevealed_at, advances order todelivered)- REST routes at
/api/digital-goods - Socket event
delivery-updateemitted on deliver + reveal
Frontend — 4d526ae + 4a9d44b
ShopSettingsInventorydashboard 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 detailRequestDigitalReceiveCard— 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:
-
Auto-fulfillment is deeply coupled. The
FUNDEDstate transition lives insidePurchaseRequestServiceand 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. -
Auth and tenant context are already there. The inventory must be tenant-aware (each white-label shop has isolated inventory).
tenantAuthServiceand the seller'spgIdare in scope in the existing middleware stack. -
Notification and socket are in-process.
NotificationServiceandemitToRoomare already being called inDigitalGoodsService. Moving to a microservice would require HTTP or pubsub round-trips for those. -
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.
-- 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:
- Loads the request's template → checks
defaultInventoryPool - If no pool is bound, returns immediately (manual delivery flow, no change)
- Picks the oldest non-expired
availableitem from the pool (atomicUPDATE ... WHERE status='available' ORDER BY created_at LIMIT 1 RETURNING *to prevent double-draw under concurrent orders) - Marks it
reserved, links topurchaseRequestId - Calls the existing
deliverToOrdernotification + socket emit - 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.