Skip to main content

SMM Panel Reseller Integration — Product & Technical Requirements Document#

Project: Free Fire Tournament Platform
Feature: SMM Panel Services — an admin-managed marketplace that resells Social Media Marketing (SMM) API provider services (Instagram/YouTube/TikTok/Facebook likes, views, followers, comments, etc.) to platform users through the existing wallet.
Document type: PRD + Technical Design + Security Spec + Agent Execution Brief (all-in-one, written for direct handoff to a coding agent)
Version: 1.0
Status: Ready for implementation
Last updated: August 18, 2026

How to use this document: Give this entire file to your coding agent (Claude Code, Cursor, etc.) as context — either by placing it in the repo root as SMM-PANEL-PRD.md or pasting it into the chat. Then use the prompt in Section 22 — Agent Execution Prompt as your first instruction. The agent should build in the phase order defined in Section 20 — Rollout Phasing, not all at once. Nothing in this document was left as "figure it out later" — every open question that genuinely needs your input is isolated in Section 21, and everything else has a concrete decision made for it.


Table of Contents#

  1. Overview and Objectives
  2. Goals and Non-Goals
  3. Personas
  4. Glossary
  5. How This Fits Your Existing Codebase
  6. High-Level Architecture
  7. Data Model
  8. Provider Integration Layer
  9. Pricing Engine
  10. Order Lifecycle and State Machine
  11. Admin Panel Requirements
  12. User-Facing Requirements
  13. Internal API Contract
  14. Caching Strategy
  15. Background Jobs and Cron
  16. Security Requirements
  17. Non-Functional Requirements
  18. Edge Case Catalog
  19. Success Metrics
  20. Rollout Phasing
  21. Open Questions and Assumptions
  22. Agent Execution Prompt
  23. Reference: Provider API Documentation and Sources

1. Overview and Objectives#

The platform currently runs Free Fire tournaments with wallet-based payments. This feature adds a second product line inside the same wallet: reselling SMM engagement services (Instagram likes, YouTube views, TikTok followers, etc.) that are themselves sourced from one or more upstream SMM API providers — third-party companies that expose a standard reseller API (balance, service catalog, place order, check status, refill).

The platform sits in the middle of two systems:

  • Upstream side: one or more SMM providers, connected via API key, from whom services are imported at a wholesale rate (provider_rate).
  • Downstream side: the platform's own users, who buy those same services at a marked-up rate (sell_rate) using their existing platform wallet, exactly like they already buy tournament slots.

The margin between provider_rate and sell_rate — set per service by the admin as a profit percentage — is the entire business model of this feature. Everything else in this document exists to make that margin accurate, dynamic, and safe: safe from double-charging, safe from selling a service the provider can no longer fulfill, safe from an admin's provider balance running out silently, and safe from the usual web attack surface (CSRF, SSRF, SQL injection, race conditions).

This is a beginner-to-intermediate business feature technically, but an operationally unforgiving one: money moves twice (user → platform, platform → provider is implicit via wallet-style provider balance), and provider APIs are individually unreliable (rate limits, downtime, price drift, services quietly discontinued). The spec below treats reliability and fraud-prevention as first-class requirements, not an afterthought — per your explicit ask, nothing here is a "happy path only" design.


2. Goals and Non-Goals#

Goals#

#GoalHow we'll know
G1Admin can connect any standard SMM API provider (URL + key) and see live balance, without writing codeBalance visible within one health-check cycle of saving credentials
G2Admin can import provider services into categorized, custom-priced, custom-named platform listingsA service goes from "exists at provider" to "orderable by users" in under 5 clicks
G3Every order's profit is accurate and immutable, even if the provider changes prices later that day100% of historical orders retain their original cost/sell/profit snapshot regardless of later catalog syncs
G4The platform never loses money or double-charges due to a network failure, duplicate click, or race conditionZero duplicate-order or duplicate-debit incidents under concurrent/duplicate requests (see §16 test requirements)
G5When a provider goes down (balance exhausted or API failing), new orders stop immediately and automatically; already-placed orders queue instead of failing; the admin is alerted without needing to check the dashboardMean time from provider failure to admin notification < 2 minutes; zero new orders accepted against a DOWN provider
G6End users get a clean, mobile-first ordering experience consistent with the rest of the platform's design systemFull feature usable on a 360px-wide viewport with no horizontal scroll or broken layout
G7A service that disappears from the provider's catalog is auto-quarantined instead of silently failing user ordersZero user-visible orders placed against a service the provider no longer recognizes

Non-Goals (explicitly out of scope for v1)#

#Non-goalWhy
NG1No "Cancel Order" button anywhere in the user UI.Explicit product decision. Users can only place orders and request a refill (where supported). The provider-side cancel action still exists in the adapter (§8) for admin/internal use only, but is never exposed to end users. Agents implementing this must not add a cancel button "for completeness" — it is deliberately excluded.
NG2Full multi-currency wallet supportThe platform wallet stays in a single currency (₹). Provider billing currency (usually USD) is handled with a single admin-editable conversion rate per provider (§7.1, §9), not a full FX/multi-currency ledger.
NG3Real-time WebSocket/SSE order status pushv1 uses short-interval polling from the client plus a server cron sync (§15). This is deliberately the "lightweight, no new dependency" choice you asked for; WebSockets can be a fast-follow (P2) if needed later.
NG4Users reselling further via their own APIOut of scope. This is a storefront feature, not a white-label-of-a-white-label.
NG5Automatic dispute/chargeback workflowsOut of scope; disputes are handled manually by the admin through the Orders page (§11.4) for v1.
NG6Multiple simultaneous "default" providers with automatic load-balancing/failover between providers for the same servicev1 supports multiple providers, but each service is sourced from exactly one provider at a time. Automatic multi-provider failover for the same service is a good P2 idea, captured in §20.

3. Personas#

PersonaDescriptionPrimary pages
Platform AdminYou (or your ops team). Already has isAdmin + role permissions from the existing auth system. Connects providers, curates the catalog, sets margins, resolves stuck orders, responds to outage alerts.All pages under §11
End UserAn existing platform user with a wallet (same user who books tournament slots). Browses services by category, places orders, tracks status, requests refills.All pages under §12
Sync Worker (system)Not a human — the cron-driven background process that keeps prices, balances, and order statuses fresh (§15). Included as a persona because several requirements ("always up to date," "never stale") are really requirements on this actor.N/A (headless)

4. Glossary#

TermMeaning
ProviderAn upstream SMM API company you connect to (e.g. a "JAP-style" reseller API). You pay them; they fulfill orders.
Provider rateThe wholesale price the provider charges you, quoted per 1,000 units of the service (industry-standard unit, even for a service where the user orders a small quantity).
Sell rateYour price to the platform's users, per 1,000 units. sell_rate = provider_rate × exchange_rate × (1 + profit_percent / 100).
Profit %Admin-set markup per service, editable by slider or manual number input.
CategoryAdmin-defined grouping shown to users (e.g. "Instagram", "YouTube"), independent of the provider's own category labels.
ServiceOne orderable item (e.g. "Instagram Followers — High Quality"), mapped 1:1 to exactly one (provider, provider_service_id) pair.
RefillA provider-side action that tops up an order if delivered quantity drops after delivery (e.g. followers unfollow). Only offered to users when the provider explicitly supports it for that service.
BlocklistAn internal quarantine for a service the provider no longer recognizes (deleted/renamed at the provider). Hidden from users automatically; admin resolves it manually.
Provider DOWN / Circuit breakerA protective state: when a provider's API starts failing (auth error, timeout) or its balance is insufficient, the platform stops accepting new orders for every service on that provider, while safely queuing orders already placed. Reversed by a single admin action once the provider is fixed.
Snapshot (pricing)The provider rate, sell rate, and exchange rate are copied onto the order at the moment it's placed and never change again, even if the live catalog price changes later. This is what keeps profit reporting accurate.

5. How This Fits Your Existing Codebase#

This is the most important section for keeping the build fast and safe: almost nothing here needs a new library. Your README already documents infrastructure that this feature should reuse as-is, not duplicate. The agent should treat this table as a hard requirement, not a suggestion.

New feature needReuse this existing module (per your README)Do NOT
Encrypting provider API keys at restThe same AES-256-GCM pattern already used for UTR storage in lib/payment.tsInvent a second encryption scheme
Preventing CSRF on admin/order POST routeslib/security/csrf.tsSkip CSRF checks on "internal" SMM routes
Rate limiting order placement, search, refill requestslib/security/rate-limiter.ts (Redis-backed, in-memory fallback)Add a new rate-limit library
Validating the admin-entered provider Base URL (SSRF risk)lib/security/outbound-url.tsCall fetch() on an admin-supplied URL directly without validation
Debiting the wallet when an order is placedThe idempotent credit/debit + advisory-lock pattern in lib/wallet.tsWrite a second, parallel wallet-mutation code path
Caching category/service lists and invalidating on editlib/cache.ts cache-tag constants + lib/redis-cache.ts tagged cachingHand-roll a new cache layer
Notifying the admin when a provider goes downThe existing dual-channel (In-App + Web Push) system described under "Notify & Broadcast System," plus the existing Nodemailer/multi-SMTP email layerAdd a third-party alerting SaaS
Scheduled status sync / health checksapp/api/cron/ (your existing cron route pattern) as the default trigger, with a single optional node-cron bootstrap for standalone/Docker hosting — see §15 for exactly when each appliesAdd BullMQ, Agenda, Sidekiq-style workers, or any other heavyweight job-queue dependency — not needed for three simple scheduled functions
Admin route protectionThe existing isAdmin + role permission systemBuild a second authorization system for SMM routes only
Parameterized DB accessDrizzle ORM, exactly as db/schema.ts already does for the other 25+ tablesUse raw SQL string concatenation anywhere in this feature
Storing dynamic per-service order fieldsPostgres jsonb columns (Drizzle supports this natively)Add a NoSQL side-database just for this

If the agent finds itself reaching for a new npm dependency for something in this list, that's a signal to re-read this table before proceeding — the existing stack almost certainly already covers it.


6. High-Level Architecture#

graph TD
    subgraph AdminSide["Admin Panel //smm/*"]
        A1[Providers Page]
        A2[Categories Page]
        A3[Services Page + Import]
        A4[Orders Page]
        A5[Blocklist Page]
        A6[Settings Page]
    end

    subgraph UserSide["User Dashboard"]
        U1[Services Tab]
        U2[Orders / History Tab]
    end

    subgraph AppLayer["Next.js App / Server Actions / API Routes"]
        API[Internal SMM API routes]
        PE[Pricing Engine]
        SM[Order State Machine]
        ADAPT[Provider Adapter Layer]
    end

    subgraph DataLayer["Postgres via Drizzle"]
        DB[(smm_providers, smm_categories,\nsmm_services, smm_orders, ...)]
    end

    subgraph Infra["Existing Infra (reused)"]
        WALLET[lib/wallet.ts]
        CACHE[Redis + Next.js Data Cache]
        NOTIFY[Email + Web Push]
        CRON[app/api/cron]
    end

    subgraph Upstream["Upstream SMM Provider(s)"]
        P1[(Provider A API)]
        P2[(Provider B API)]
    end

    A1 --> API
    A2 --> API
    A3 --> API
    A4 --> API
    A5 --> API
    A6 --> API
    U1 --> API
    U2 --> API

    API --> PE
    API --> SM
    API --> WALLET
    API --> DB
    API --> CACHE

    ADAPT --> P1
    ADAPT --> P2
    API --> ADAPT

    CRON --> ADAPT
    CRON --> DB
    CRON --> NOTIFY
    CRON --> CACHE

Data flow in one sentence each:

  • Import: Admin → Services Page → ADAPT.listServices() → provider's live catalog shown → admin picks one → row written to smm_services with provider_rate cached and sell_rate computed.
  • Order: User → Services Tab → validated against cached smm_services row (never a live provider call on the user's request path) → wallet debited atomically → smm_orders row created PENDING_DISPATCH → dispatched to ADAPT.createOrder() synchronously if the provider is healthy, otherwise parked PENDING_PROVIDER (§10).
  • Sync: Cron → batches all non-terminal orders → ADAPT.getOrderStatus() (bulk) → updates smm_orders.status/remains/start_count → invalidates the specific order's cache tag so the user's next poll is fresh.
  • Health: Cron → ADAPT.getBalance() per provider on an interval → if balance is low/zero or the call errors repeatedly → provider flips to DOWN → new orders blocked at the API layer → NOTIFY fires (email + push, deduplicated) → admin fixes it → one click flips provider back to ONLINE → queued PENDING_PROVIDER orders are automatically dispatched.

7. Data Model#

All tables live in db/schema.ts alongside the existing 25+ tables, using the same Drizzle conventions (uuid PKs, createdAt/updatedAt timestamps, FK constraints). Nine new tables are needed. None of them replace or modify existing tables — this feature is purely additive to the schema.

7.1 smm_providers#

ColumnTypeConstraintsNotes
iduuidPK
nametextnot nullAdmin-facing label, e.g. "Main Provider"
api_base_urltextnot nullValidated HTTPS URL (§16 SSRF checks)
api_key_encryptedtextnot nullAES-256-GCM ciphertext, same pattern as payment.ts
api_key_last4textnot nullFor masked display (••••••ab12) — never show the full key again after save
currencytextnot null, default USDProvider's billing currency
exchange_rate_to_platform_currencynumeric(18,6)not null, default 1Manual admin-set rate, e.g. USD→INR. See §21 for why this is manual in v1
statusenumONLINE, DEGRADED, DOWN, DISABLEDDISABLED = admin manually turned it off; DOWN = circuit breaker tripped automatically
last_balancenumeric(18,4)nullableCached from last successful balance call
last_balance_checked_attimestamptznullable
consecutive_failure_countintnot null, default 0Resets to 0 on any successful call
last_error_messagetextnullableHuman-readable, sanitized (never the raw key)
last_error_attimestamptznullable
health_check_interval_minutesintnot null, default 5
is_defaultbooleannot null, default falsePre-selected provider in the Import flow when more than one exists
created_byuuidFK → users.id
created_at / updated_attimestamptznot null

7.2 smm_categories#

ColumnTypeConstraintsNotes
iduuidPK
nametextnot nulle.g. "Instagram"
slugtextunique, not nullURL-safe, used in user-facing filters
image_urltextnullableOriginal source URL admin pasted in
cached_image_urltextnullableInternally-hosted, optimized copy (§14) — this is what the UI actually renders
icontextnullableOptional icon-set key as an alternative/fallback to an image
display_orderintnot null, default 0Drag-and-drop order in both admin and user UI
is_activebooleannot null, default trueHides the whole category (and its services) from users without deleting data
created_at / updated_attimestamptznot null

7.3 smm_services#

ColumnTypeConstraintsNotes
iduuidPK
provider_iduuidFK → smm_providers.id, not null
provider_service_idtextnot nullThe provider's own service ID from their services action
category_iduuidFK → smm_categories.id, nullableNullable until admin assigns one during import
provider_service_nametextnot nullRaw name from the provider — read-only, shown to admin only, never to users
display_nametextnot nullAdmin-editable — this is what users actually see
descriptiontextnullableAdmin-editable, shown on the user order form
provider_typetextnot nullRaw provider type, e.g. Default, Custom Comments, Mentions with Hashtags, Poll, Subscriptions
field_schemajsonbnot nullDynamic order-form definition derived from provider_type (see §8.4) — admin can override
provider_rate_per_1000numeric(18,6)not nullLast-synced upstream rate, in provider currency
provider_min / provider_maxintnot nullHard ceiling/floor from the provider — platform values can never exceed these
platform_min / platform_maxintnot nullAdmin-set, defaults suggested at 100 / 1,000,000, clamped into [provider_min, provider_max]
profit_percentnumeric(6,2)not null, default 0Admin-set via slider or manual input
sell_rate_per_1000Intentionally not a columnSee §9.0 — the sell price is never persisted for the catalog. It is computed live, in application code, every single time it's needed, from provider_rate_per_1000 and profit_percent below. This guarantees the price shown to a user is never one edit behind an admin's profit-% change.
provider_supports_refillbooleannot null, default falseSynced from provider's refill flag — read-only
refill_enabledbooleannot null, default falseAdmin toggle — can only be set true when provider_supports_refill is true
is_activebooleannot null, default falseAdmin visibility toggle — new imports default to false until admin reviews and publishes
is_blockedbooleannot null, default falseSet by the sync job or order-dispatch failure handler (§10.5)
blocked_reason / blocked_attext / timestamptznullable
order_countintnot null, default 0Denormalized counter, incremented on order creation, used for "Most Ordered" sort
last_synced_attimestamptznullable
created_at / updated_attimestamptznot null

Unique constraint: (provider_id, provider_service_id) — prevents importing the same provider service twice.

7.4 smm_orders#

ColumnTypeConstraintsNotes
iduuidPK
order_numbertextunique, not nullHuman-friendly, e.g. SMM-000123
user_iduuidFK → users.id, not null
service_iduuidFK → smm_services.id, not null
provider_iduuidFK → smm_providers.id, not nullDenormalized on purpose — filtering/reporting must survive a service being reassigned or blocked later
provider_order_idtextnullableSet only once the provider's add call succeeds
target_linktextnullableURL/username submitted by the user, validated (§16)
quantityintnot null
form_datajsonbnot nullFull dynamic-field payload (comments list, usernames, answer_number, etc.)
provider_rate_snapshotnumeric(18,6)not nullImmutable copy of the rate at order time
sell_rate_snapshotnumeric(18,6)not nullImmutable
exchange_rate_snapshotnumeric(18,6)not nullImmutable
cost_to_platformnumeric(18,4)not nullprovider_rate_snapshot/1000 × quantity × exchange_rate_snapshot
amount_chargednumeric(18,4)not nullsell_rate_snapshot/1000 × quantity — what left the user's wallet
profit_amountnumeric(18,4)not nullamount_charged − cost_to_platform
wallet_balance_beforenumeric(18,4)not null
wallet_balance_afternumeric(18,4)not null
statusenumsee §10.1PENDING_DISPATCH, PENDING_PROVIDER, IN_PROGRESS, PARTIAL, COMPLETED, PROVIDER_CANCELLED, BLOCKED, FAILED
provider_status_rawtextnullableLast raw status string returned by the provider, kept for support/debugging
start_count / remainsintnullableFrom provider status responses
failure_reasontextnullableSanitized, user-safe message when status is FAILED/BLOCKED
retry_countintnot null, default 0
idempotency_keytextunique, not nullClient-generated per submit attempt (§16)
dispatched_at / last_synced_at / completed_attimestamptznullable
created_at / updated_attimestamptznot null

Indexes: (user_id, created_at desc), (status), (service_id), (provider_order_id).

Illustrative Drizzle shape (follow this convention for the other eight tables too):

export const smmOrderStatusEnum = pgEnum("smm_order_status", [
  "PENDING_DISPATCH",
  "PENDING_PROVIDER",
  "IN_PROGRESS",
  "PARTIAL",
  "COMPLETED",
  "PROVIDER_CANCELLED",
  "BLOCKED",
  "FAILED",
]);

export const smmOrders = pgTable("smm_orders", {
  id: uuid("id").primaryKey().defaultRandom(),
  orderNumber: text("order_number").notNull().unique(),
  userId: uuid("user_id").notNull().references(() => user.id),
  serviceId: uuid("service_id").notNull().references(() => smmServices.id),
  providerId: uuid("provider_id").notNull().references(() => smmProviders.id),
  providerOrderId: text("provider_order_id"),
  targetLink: text("target_link"),
  quantity: integer("quantity").notNull(),
  formData: jsonb("form_data").notNull(),
  providerRateSnapshot: numeric("provider_rate_snapshot", { precision: 18, scale: 6 }).notNull(),
  sellRateSnapshot: numeric("sell_rate_snapshot", { precision: 18, scale: 6 }).notNull(),
  exchangeRateSnapshot: numeric("exchange_rate_snapshot", { precision: 18, scale: 6 }).notNull(),
  costToPlatform: numeric("cost_to_platform", { precision: 18, scale: 4 }).notNull(),
  amountCharged: numeric("amount_charged", { precision: 18, scale: 4 }).notNull(),
  profitAmount: numeric("profit_amount", { precision: 18, scale: 4 }).notNull(),
  walletBalanceBefore: numeric("wallet_balance_before", { precision: 18, scale: 4 }).notNull(),
  walletBalanceAfter: numeric("wallet_balance_after", { precision: 18, scale: 4 }).notNull(),
  status: smmOrderStatusEnum("status").notNull().default("PENDING_DISPATCH"),
  providerStatusRaw: text("provider_status_raw"),
  startCount: integer("start_count"),
  remains: integer("remains"),
  failureReason: text("failure_reason"),
  retryCount: integer("retry_count").notNull().default(0),
  idempotencyKey: text("idempotency_key").notNull().unique(),
  dispatchedAt: timestamp("dispatched_at", { withTimezone: true }),
  lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }),
  completedAt: timestamp("completed_at", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});

7.5 smm_order_status_history#

Full audit trail so both the admin and support can reconstruct exactly what happened to any order — this is what makes order tracking "full human-easy understanding" instead of just a single current-status field.

ColumnTypeNotes
iduuidPK
order_iduuidFK → smm_orders.id
from_status / to_statusenum
changed_byenumSYSTEM_SYNC, ADMIN, USER, CIRCUIT_BREAKER
actor_iduuid, nullableAdmin's user id, when changed_by = ADMIN
notetext, nullablee.g. "Provider balance restored, order re-dispatched"
created_attimestamptz

7.6 smm_refill_requests#

ColumnTypeNotes
iduuidPK
order_iduuidFK, not null
provider_refill_idtext, nullable
statusenumREQUESTED, COMPLETED, REJECTED, FAILED
requested_byuuidFK → users.id
requested_at / resolved_attimestamptz
raw_responsejsonb, nullable

7.7 smm_blocklist_log#

ColumnTypeNotes
iduuidPK
service_iduuidFK, not null
reasonenumSERVICE_NOT_FOUND, PROVIDER_REMOVED, MANUAL, PRICE_ANOMALY
raw_errortextSanitized provider error text
detected_attimestamptz
resolved_attimestamptz, nullable
resolved_byuuid, nullableAdmin who resolved it
resolution_actionenum, nullableREMAPPED, DELETED, FALSE_POSITIVE_UNBLOCKED

7.8 smm_provider_incidents#

Drives both the "provider down" banner and the admin notification pipeline.

ColumnTypeNotes
iduuidPK
provider_iduuidFK, not null
typeenumINSUFFICIENT_BALANCE, AUTH_ERROR, TIMEOUT, RATE_LIMITED, UNKNOWN_ERROR
messagetext
detected_attimestamptz
resolved_attimestamptz, nullableSet when admin brings the provider back online
admin_notified_attimestamptz, nullableUsed to deduplicate notifications — see §10.6
notification_channelsjsonbe.g. ["email", "push"]

7.9 smm_settings#

A single-row settings table (or key/value rows — either is fine, single-row is simpler given the small number of fields).

ColumnTypeNotes
iduuidPK
is_platform_visibleboolean, default falseMaster on/off switch — controls sidebar item + page access for all users
sidebar_labeltext, default 'Services'Admin-editable label shown in the user sidebar
notification_emailsjsonbArray of admin email addresses to alert on provider-down
push_notifications_enabledboolean, default true
default_profit_percentnumeric, default 20Pre-filled value when importing a new service
updated_attimestamptz

8. Provider Integration Layer#

8.1 The industry-standard "SMM API" contract#

This was independently verified against multiple current SMM provider API docs (see §23). Despite being sold by dozens of different companies, almost all SMM reseller APIs expose one HTTP endpoint and use an action parameter to select the operation — this is a de-facto industry standard, not one provider's quirk. Build the adapter against this shape and it will work with the large majority of providers with zero code changes, which directly satisfies your "connect any provider" requirement.

Request shape: POST with body { key: "<api_key>", action: "<action>", ...params } (form-encoded or JSON depending on provider — the adapter should support both and detect from the provider's docs at setup, or just always send form-encoded, which is accepted almost universally).

ActionPurposeKey request paramsKey response fields
balanceCheck remaining fundsbalance, currency
servicesList the full live catalogarray of { service, name, type, category, rate, min, max, refill, cancel }
addPlace a new orderservice, plus type-specific fields (see §8.4)order (the provider's order id) — or { error: "..." }
statusCheck one orderorder{ charge, start_count, status, remains, currency }
status (bulk)Check many orders in one callorders (comma-separated ids, most providers cap this around 100)Object keyed by order id, each value either a status object or { error: "..." } for a bad id — the adapter must handle a mixed batch where some ids succeed and some fail
refillRequest a refill for one orderorder{ refill: "<refill_id>" } or { error: "..." }
refill (bulk)Refill status for manyrefillsArray of { refill, status }
cancelRequest cancellationorder{ success: "..." }admin/internal use only per NG1, never called from a user action

Order status values returned by status: Pending, In progress / Processing, Completed, Partial, Canceled (spelling and exact casing vary by provider — the adapter must normalize case-insensitively into the internal enum from §10.1, and log/flag any unrecognized string rather than crashing).

8.2 Field-naming variance — defensive parsing is mandatory#

Different providers spell the same field slightly differently (start_count vs start_counter was observed across providers during research). Never assume exact field names from one provider's docs will match another's. The adapter's response parser must:

  • Accept known aliases for each field (maintain a small alias map per provider or globally: start_count | start_counter, charge | cost, etc.)
  • Treat any field it doesn't recognize as ignorable, not fatal
  • Validate the shape of every provider response with a Zod schema before trusting any of it — never pass raw provider JSON straight into a DB write

8.3 Adapter interface#

Implement one internal TypeScript interface, with a concrete class per provider "dialect" if a future provider ever deviates (v1 only needs the standard implementation):

interface SmmProviderAdapter {
  getBalance(): Promise<{ balance: number; currency: string }>;
  listServices(): Promise<ProviderService[]>;
  createOrder(params: CreateOrderParams): Promise<{ providerOrderId: string }>;
  getOrderStatuses(providerOrderIds: string[]): Promise<Record<string, OrderStatusResult | { error: string }>>;
  requestRefill(providerOrderId: string): Promise<{ refillId: string }>;
  getRefillStatuses(refillIds: string[]): Promise<Record<string, { status: string } | { error: string }>>;
}

All admin pages and cron jobs talk to SmmProviderAdapter, never to fetch() directly — this is what makes "connect any provider" realistic and keeps the security/SSRF checks (§16) in exactly one place.

8.4 Dynamic order-form schema (this is what makes "nothing incomplete" actually true)#

Providers expose many order types (Default, Custom Comments, Mentions Custom List, Mentions with Hashtags, Poll, Subscriptions, Drip-feed add-on, and provider-specific specials). Hardcoding a UI branch per type is exactly the kind of thing that becomes "incomplete" the moment a 6th or 7th type shows up. Instead, store a data-driven field schema on each smm_services row (field_schema jsonb, §7.3), auto-derived from provider_type at import time using this default mapping, and admin-editable afterwards:

provider_typeDefault field_schema
Defaultlink (url, required), quantity (number, required, bounded by service min/max)
Custom Commentslink (url, required), comments (textarea, required, one per line — line count must equal quantity)
Mentions Custom List / Mentionslink (url, required), usernames (textarea, required, one per line)
Mentions with Hashtagslink (url, required), hashtags (text, required)
Polllink (url, required), answer_number (number, required)
Subscriptionsusername (text, required), posts (number, required), delay (number, optional)
Package / provider specialslink (url, required), quantity (number, required) as a safe default — admin can add fields manually if the provider needs more

Both the admin's "Add Service" screen and the user's order form render from this schema, not from a hardcoded switch statement. Adding support for a brand-new provider type in the future means adding one row to this mapping table, not shipping a new UI.

8.5 Outbound call discipline (from provider best-practice research, §23)#

  • Rate limit outbound calls per provider — serialize or throttle to a small, configurable requests/second (start at 1/sec) per provider; back off with exponential delay on HTTP 429.
  • Batch status checks — always use the bulk status/refill actions when checking more than one order; never loop single-order calls in the sync job.
  • Cache the service cataloglistServices() is called on-demand only when the admin opens the Import screen (with a short cache to avoid re-fetching on every keystroke of a search box) and on the periodic catalog-sync cron (§15); it is never called on a user's order-placement request path — the user always orders against the platform's own cached smm_services row.
  • Never retry a timed-out add call blindly. If the network fails before a response is received, the order may or may not have been created at the provider (this is the single most common source of double-charging in SMM reseller platforms). Before retrying, the adapter must attempt to disambiguate — e.g. check provider balance delta, or simply surface it to the order as PENDING_PROVIDER for manual/cron reconciliation rather than firing a second add automatically. This governs the retry logic required in §10.4.
  • Never expose api_key_encrypted or the decrypted key to the client. All provider calls happen server-side only, exactly like the existing UPI/email-parsing payment flow already does for provider secrets.

9. Pricing Engine#

9.0 Core rule: price is computed, never stored — and that's what makes it both dynamic and fast#

This is a deliberate, specific design decision, because it's the exact point you flagged: the sell price the user sees must never be a number sitting in the database that could go stale. The way to guarantee that isn't to cache it and remember to recalculate it everywhere (that's how staleness bugs happen) — it's to never store it at all and instead compute it, every time, from a single shared pure function:

// lib/smm/pricing.ts — the ONLY place this formula is allowed to exist
export function computeSellRatePer1000(
  providerRatePer1000: number,
  exchangeRate: number,
  profitPercent: number,
): number {
  return providerRatePer1000 * exchangeRate * (1 + profitPercent / 100);
}

Every single place that shows or charges a price — the user's service list, the service detail panel, the live admin preview, and the order-placement handler — calls this same function with whatever provider_rate_per_1000 and profit_percent are right now in the database. There is no separate sell_rate_per_1000 column to forget to update (§7.3). The instant an admin changes profit_percent and hits save, the very next API response to any user reflects the new price — not "eventually, on the next sync," but immediately, because it was never cached in the first place.

Important distinction — "dynamic" does not mean "calls the provider live on every click." provider_rate_per_1000 itself is a periodically-synced, cached value (updated by the catalog-sync cron, §15) — calling the provider's API on every page view would be slow, would hammer their rate limits, and directly contradicts the provider best-practice you'll see cited in §23 ("cache the service list, refresh periodically"). What's dynamic is that nothing sits between that cached provider rate and the price shown to the user except one multiplication — no second stale cache layer, no forgotten recalculation step. That's what keeps the experience "lightning fast" (a multiply is sub-millisecond; a network call to the provider is not) while still guaranteeing the price is never wrong relative to your current settings.

9.1 Formula#

sell_rate_per_1000  = computeSellRatePer1000(provider_rate_per_1000, exchange_rate, profit_percent)   // always live, never cached

order_amount_charged   = (sell_rate_snapshot / 1000) × quantity      # what leaves the user's wallet
order_cost_to_platform = (provider_rate_snapshot / 1000) × quantity × exchange_rate_snapshot
order_profit            = order_amount_charged − order_cost_to_platform

Round amount_charged to the wallet's smallest unit (paise, i.e. 2 decimal places) using standard rounding — always round the charge to the user up and never down, so the platform's margin is never silently eroded by rounding.

9.2 Order placement always recalculates from scratch, server-side — never trusts a displayed number#

This is the "very secure, full recalculation every time" requirement, and it applies specifically at the moment of order placement, layered on top of §9.0:

  1. The client shows a price (computed via §9.0, moments earlier, from the same API).
  2. When the user hits "Place Order," the client submits serviceId + quantity + form fields — it never submits a price. There is nothing to tamper with, because the price isn't client-supplied data in the first place.
  3. The server independently loads the current provider_rate_per_1000, exchange_rate, and profit_percent for that service from the database (not from any request payload, not from any cache the client could have influenced), runs them through the exact same computeSellRatePer1000() function, and only that freshly-computed number is what gets charged and written into the order's immutable snapshot (§7.4).
  4. If the provider rate or the admin's profit % changed in the split second between the user loading the page and clicking submit, the freshly submitted order simply uses the newer, correct number — there's no window where an old number can be exploited, because the server was never trusting the old number to begin with.

This is what "double-checking" means here in concrete terms: not two calculations that might disagree, but one calculation whose only trusted inputs are values the server itself owns.

9.3 Why every order still snapshots its own numbers (this part is unchanged)#

Once §9.2's server-side calculation happens, its result is frozen onto the order (provider_rate_snapshot, sell_rate_snapshot, exchange_rate_snapshot, §7.4) and never recalculated again for that order. This is different from — and layered on top of — the "don't store the catalog price" rule above:

  • The catalog price (what's shown before an order exists) is never stored — always fresh, per §9.0.
  • An order's price, once placed, is deliberately frozen forever, so that a provider price change next week doesn't rewrite history or make last week's profit reporting drift. Old orders don't reference the live catalog at all; they carry their own numbers permanently.

9.4 Admin UI behavior for setting margin#

  • One control, two synchronized inputs: a slider (0–500%, step 1) and a numeric input, each driving the other live (drag the slider → number updates; type a number → slider jumps).

  • A live, read-only preview panel next to it, calling computeSellRatePer1000() client-side for instant feedback as the admin types/drags (debounced ~150ms), then re-confirmed against the server's own calculation on save so the admin never sees a preview that doesn't match what actually gets persisted:

    Per 1,000Example at qty = 1,000
    Provider price₹X.XX₹X.XX
    Your price (what user pays)₹Y.YY₹Y.YY
    Your profit₹Z.ZZ (and effective %)₹Z.ZZ
  • Changing profit_percent never touches historical orders — it only changes the two stored inputs (provider_rate_per_1000 stays as last-synced, profit_percent updates), so every future computation of the catalog price picks it up instantly, everywhere, automatically.


10. Order Lifecycle and State Machine#

10.1 States#

stateDiagram-v2
    [*] --> PENDING_DISPATCH: user places order,\nwallet debited
    PENDING_DISPATCH --> IN_PROGRESS: provider add succeeds
    PENDING_DISPATCH --> PENDING_PROVIDER: provider errored\n(balance/timeout/down)
    PENDING_PROVIDER --> IN_PROGRESS: admin brings provider\nback online, auto-retry succeeds
    PENDING_PROVIDER --> BLOCKED: service turns out to be\ndeleted at provider
    IN_PROGRESS --> PARTIAL: provider reports\npartial delivery
    IN_PROGRESS --> COMPLETED: provider reports\ncompleted
    PARTIAL --> COMPLETED: refill / provider\ntops it up
    IN_PROGRESS --> PROVIDER_CANCELLED: provider cancels\n(their side, rare)
    COMPLETED --> [*]
    PARTIAL --> [*]
    PROVIDER_CANCELLED --> [*]
    BLOCKED --> [*]
    PENDING_DISPATCH --> FAILED: unrecoverable\nvalidation error
    FAILED --> [*]
StatusMeaningUser sees
PENDING_DISPATCHOrder accepted, wallet already debited, about to be sent to provider"Order received — starting soon"
PENDING_PROVIDERProvider was down/erroring at dispatch time; order is queued, not lost, not re-charged"Order received — processing may take a little longer" (never "failed")
IN_PROGRESSProvider is actively delivering"In progress" + remains if available
PARTIALProvider delivered less than ordered"Partially delivered" + Refill button if refill_enabled
COMPLETEDFully delivered"Completed" + Refill button if refill_enabled and within any provider refill window
PROVIDER_CANCELLEDProvider cancelled on their own side (rare, e.g. against their ToS)"Unable to complete, refunded" — auto-refunded to wallet the instant this status is set (via the existing idempotent wallet-credit function, same transaction), since this is a definitive, system-confirmed "will never be fulfilled" signal, not a temporary one
BLOCKEDThe specific service was deleted/renamed at the provider after the order was placedSame as above — auto-refunded immediately, in the same transaction that sets is_blocked/BLOCKED. Admin still resolves the underlying catalog problem via the Blocklist page (§11.5) at their own pace, but never needs to remember to refund this specific order — the money is already back with the user by the time they see it
FAILEDRejected before ever reaching the provider (e.g. failed validation) — should be rare since validation happens before wallet debitOrder was never created / wallet was never touched

10.2 Order placement — the exact sequence#

  1. Client sends serviceId, formData, idempotencyKey (client-generated UUID, regenerated only on a genuinely new submit, not on retry).
  2. Server: reject if smm_settings.is_platform_visible = false, if the service is_active = false or is_blocked = true, or if the service's provider status ≠ ONLINE. This is the enforcement point for "no new orders while a provider/service is down" (§10.5).
  3. Server: validate formData against the service's field_schema and quantity bounds server-side (never trust client-side validation alone).
  4. Server: in a single DB transaction, using the same advisory-lock pattern as the existing wallet debit — (a) check idempotency_key doesn't already exist (if it does, return the existing order instead of creating a duplicate — this is what makes double-clicks and network retries safe), (b) debit the wallet, (c) insert the smm_orders row with status = PENDING_DISPATCH and the full pricing snapshot from §9.
  5. Immediately after the transaction commits, attempt adapter.createOrder().
    • Success: update the same row to status = IN_PROGRESS, store provider_order_id, dispatched_at.
    • Failure classified as balance/timeout/down: update to PENDING_PROVIDER, trip the circuit breaker for that provider (§10.5), leave the wallet debit as-is (the order was legitimately accepted — see your own requirement that "the user's order should still go through").
    • Failure classified as service-not-found: blocklist the service (§10.6) immediately and set this order to BLOCKED.
  6. Return the order (with its status) to the client. The client then polls (§12.3) or relies on the next cron sync.

10.3 Reconciling PENDING_PROVIDER orders once the provider is back#

When the admin flips a provider from DOWN/DEGRADED back to ONLINE (§11.1), the same code path used by the cron's retry loop runs immediately and synchronously for that provider: fetch all its PENDING_PROVIDER orders, oldest first, and re-attempt adapter.createOrder() for each, respecting the outbound rate limit from §8.5. Each one individually succeeds into IN_PROGRESS or, if it fails again, stays PENDING_PROVIDER and increments retry_count (cap retries — e.g. after 5 failed attempts across a rolling 24h window, surface it to the admin as needing manual attention rather than retrying forever).

10.4 Idempotency and race-condition guarantees (ties to §16)#

  • idempotency_key has a DB-level unique constraint — this is the ultimate backstop even if application logic has a bug.
  • Wallet debit + order insert happen in one transaction using the existing advisory-lock-per-user pattern, so two simultaneous submits from the same user can never both succeed against a balance that only covers one.
  • The add call to the provider is not part of that DB transaction (you cannot roll back an HTTP call to a third party) — this is exactly why step 5 above is a separate, explicitly-handled step with its own failure states, rather than assuming it always succeeds.

10.5 Circuit breaker — provider-level "down" detection#

A provider trips to DOWN when either:

  • getBalance() returns a balance below a configurable threshold (default: below the cost of the cheapest active service's minimum order), or
  • consecutive_failure_count (auth errors, timeouts, 5xx) crosses a threshold (default: 3 consecutive failures across calls).

The instant a provider is DOWN:

  • Every smm_services row on that provider is treated as unorderable — enforced at the API layer (step 2 in §10.2), not just hidden in the UI, so a direct API call can't bypass it either.
  • The Services tab shows those services with an "Unavailable" badge and a disabled order button, rather than hiding them outright (so users aren't confused about where a familiar service went).
  • One smm_provider_incidents row is created and the admin is notified (§10.6).
  • The admin's one-click "Bring Provider Online" button (§11.1) clears status → ONLINE, resets consecutive_failure_count, timestamps resolved_at on the incident, and triggers the reconciliation in §10.3.

10.6 Blocklist — service-level quarantine (distinct from provider-down)#

This is a narrower, per-service version of the same idea, for when the provider itself is fine but one specific service was deleted or renamed upstream (detected via an add error matching a "service not found/invalid" pattern, or via the service no longer appearing in a catalog sync). On detection:

  • smm_services.is_blocked = true, hidden entirely from the user catalog (unlike provider-down, which shows a disabled state — a blocklisted service is gone until an admin actively fixes it).
  • A smm_blocklist_log row records the reason and raw (sanitized) provider error.
  • Any order that was in-flight against it becomes BLOCKED and is auto-refunded (§10.1) — no admin step required for the money to move back.
  • The admin resolves it from the Blocklist page (§11.5): remap to a different provider service ID, permanently delete the listing, or mark it a false positive and unblock.

10.7 Notification deduplication#

Provider incidents must alert the admin once per incident, not once per failed order. Before sending email/push, check whether an unresolved smm_provider_incidents row of the same type already exists for that provider; if so, don't send again (optionally re-notify on a long backoff, e.g. hourly, if still unresolved) — this prevents a burst of user orders during an outage from turning into a burst of duplicate emails.


11. Admin Panel Requirements#

All pages live under the existing admin slug, e.g. //smm/*, protected by the existing isAdmin + role permission check. Organized as a tabbed section: Providers · Categories · Services · Orders · Blocklist · Settings.

11.1 Providers Page — //smm/providers#

Purpose: connect, monitor, and control upstream providers.

RequirementPriorityAcceptance criteria
Add provider form: name, base URL, API keyP0Given valid credentials, when saved, then a balance call is made immediately and the result (or a clear error) is shown before the admin leaves the form
Provider list: name, status badge (ONLINE/DEGRADED/DOWN/DISABLED), live balance, last checked timeP0Status badge color-coded (green/amber/red/gray); balance refreshes on page load without a hard reload
Manual "Refresh Balance" button per providerP0Click triggers an immediate getBalance() call, updates in place, disabled while in-flight to prevent double-calls
One-click "Bring Provider Online" buttonP0Visible only when status is DOWN; triggers §10.5 recovery flow; button shows a loading state and a success/failure toast; if reconciliation still fails (balance still too low), the provider stays DOWN and the admin sees why
API key shown masked after save (••••••ab12), never re-displayed in fullP0Security requirement — see §16
Currency + exchange rate fieldsP0Defaults to USD / rate 1; since sell price is always computed live (§9.0), changing this rate takes effect for every service on that provider the instant it's saved — no separate recalculation step needed, just a confirmation prompt because it immediately changes live prices
Health-check interval settingP1Editable minutes, feeds the cron in §15
Incident history (from smm_provider_incidents) per providerP1Chronological list with type, detected/resolved timestamps
Manual "Disable Provider" toggle (distinct from automatic DOWN)P1Admin-initiated maintenance mode — same blocking behavior as DOWN but never auto-clears; only an explicit admin action re-enables it
Support multiple providers simultaneouslyP1Each service still maps to exactly one provider (NG6)

11.2 Categories Page — //smm/categories#

RequirementPriorityAcceptance criteria
Create/edit/delete category: name, slug (auto-generated, editable), image URLP0Slug auto-derives from name, must remain unique; deleting a category with active services requires confirmation and reassigns or blocks those services rather than silently orphaning them
Image handling: paste a URL → server fetches, validates it's actually an image, and stores an internally-hosted cached_image_urlP0See §14 for caching/preload details; if the fetch fails, the admin sees a clear inline error, not a broken image later
Drag-and-drop reordering (display_order)P1Order persists and reflects immediately in both admin and user category bars
Active/inactive toggleP0Inactive category and all its services disappear from the user catalog without deleting data

11.3 Services Page — //smm/services#

This is the largest admin page and matches your description closely.

Import flow (top of page, "Import from Provider" button):

RequirementPriorityAcceptance criteria
Opens a browser of the selected provider's live service catalog (paginated, searchable by name/ID, filterable by provider's own category)P0List loads from adapter.listServices(), not a stale cache, so the admin always imports against current provider data
Already-imported services are visibly marked so the admin doesn't double-importP0Cross-reference against the (provider_id, provider_service_id) unique constraint
Selecting a service opens the full Add Service form pre-filled from provider dataP0See table below
Bulk-select + import multiple at once (defaults applied, then admin refines)P1

Add/Edit Service form:

FieldPriorityBehavior
Provider + original service name (read-only reference)P0Shown so the admin knows exactly what they're mapping, but never shown to users
Category selectP0Required before the service can be set active
Display NameP0What users see; defaults to provider name but fully editable
DescriptionP0Free text, shown on the user order form
Profit % (slider + manual, live price preview)P0Exactly as specified in §9.3
Min / Max order quantityP0Defaults suggested at 100 / 1,000,000, hard-clamped within the provider's own min/max, inline validation error if the admin tries to exceed provider bounds
Refill toggleP0Rendered only if provider_supports_refill = true for this service (§7.3); simply absent from the form otherwise, not shown-disabled
Active/Inactive toggleP0New imports default to inactive so nothing goes live half-configured
Field schema editor (advanced, collapsed by default)P1Lets the admin tweak the auto-derived dynamic form (§8.4) for edge-case provider types
Delete serviceP0Soft-delete (deactivate) rather than hard-delete if it has order history; hard-delete only allowed for services with zero orders

Services list/table:

RequirementPriorityAcceptance criteria
Search by name and by IDP0Debounced search-as-you-type
Filter by category, provider, active/blocked statusP0
Sort by most-ordered (order_count), price, profit %P1
Columns: provider price, sell price, profit %, status, refill supportP0
Toggle active/inactive inline from the list (not just inside the edit form)P0Matches your "toggle services on/off from the provider/services area" requirement

11.4 Orders Page — //smm/orders#

RequirementPriorityAcceptance criteria
Full order table: user, service, quantity, link, status, provider order id, wallet before/after, cost, charged, profit, timestampsP0Every field from §7.4 is visible, not just a subset
Filter by status, provider, category, date rangeP0
Search by order number, user, linkP0
Expandable row → full smm_order_status_history timeline for that orderP0This is what delivers "full human-easy understanding," not just a single status field
Manual "Retry Dispatch" action on a PENDING_PROVIDER/FAILED orderP1Reuses the exact §10.3 reconciliation logic for a single order
Manual refund action (credits wallet via the existing wallet-credit function) for any other admin-discretion case (goodwill, dispute, support escalation)P0BLOCKED/PROVIDER_CANCELLED orders never need this — they already auto-refunded (§10.1); this is for everything else. Requires a reason note, writes to smm_order_status_history
CSV exportP2

11.5 Blocklist Page — //smm/blocklist#

RequirementPriorityAcceptance criteria
List of all is_blocked = true services with reason, raw error, detected dateP0
"Remap" action — point the listing at a different provider_service_id without losing the category/pricing/orders historyP0
"Delete permanently" actionP0Only allowed after admin confirms; existing orders against it keep their history regardless
"Unblock (false positive)" actionP0Returns the service to its previous active/inactive state

11.6 Settings Page — //smm/settings#

RequirementPriorityAcceptance criteria
Master "Platform Visible" toggleP0Drives §12.1 sidebar + route guard; also mirrored as a convenience toggle on the Providers page header, both read/write the same smm_settings row
Sidebar label text fieldP0What the user sees in their nav, e.g. "SMM Services" or a custom brand name
Notification recipient emails (add/remove)P0Feeds §10.7
Push notification on/offP0
Default profit % (pre-fill for new imports)P1

12. User-Facing Requirements#

12.1 Sidebar visibility — server-enforced, not just hidden UI#

RequirementPriorityAcceptance criteria
Sidebar item only renders when smm_settings.is_platform_visible = trueP0Read at the layout/server-component level, not a client-side conditional that could flash content
Direct navigation to the page while disabled returns a 404/blocked stateP0This must be enforced in the route handler itself, so typing the URL directly, bookmarking it, or calling the API directly all behave identically to "the feature doesn't exist" — matches your explicit "nothing should happen there at all" requirement
Label text reflects the admin-configured sidebar_labelP0

12.2 Services Tab#

RequirementPriorityAcceptance criteria
Horizontal category bar at top: icon/image + name per category, scrollable on mobileP0Images served from cached_image_url (§14), preloaded for the first visible set
Search bar (by service name)P0Debounced, searches within the selected category or globally if no category selected
Service selector (searchable dropdown/combobox) within the chosen categoryP0Selecting a service loads its detail panel
Service detail panel: description, current price per 1,000, min/maxP0Price computed live via §9.0 on every API response — never read from a stored column — and unconditionally recomputed again server-side at order time regardless of what the panel displayed (§9.2)
Dynamic required fields rendered from field_schema (§8.4)P0Link, quantity, comments, usernames, etc. — whatever the specific service needs, nothing hardcoded
Live price calculation as quantity changes, with inline min/max validationP0
"Place Order" → confirmation step (service, quantity, total cost, resulting wallet balance) → confirm → submitP0Submit button disables immediately on click and uses the idempotency key from §10.4, so a double-tap on a slow connection cannot create two orders
Success confirmation with order number + link into Orders tabP0
"Unavailable" state for services whose provider is DOWN or whose category/provider is disabledP0Disabled order button + clear message, not a hidden service (§10.5); a blocklisted service (§10.6) is fully hidden, not shown-disabled — the two states look different on purpose
No cancel affordance anywhere on this tabP0Per NG1

12.3 Orders / History Tab#

RequirementPriorityAcceptance criteria
List of the user's own orders, newest firstP0
Filter by status, date range, categoryP0
Search by order number or linkP0
Status badge per order, matching §10.1's user-facing copyP0
Expandable detail: link, quantity, start count, remains, timestampsP0
"Refill" buttonP0Visible only when service.refill_enabled = true and order status is COMPLETED or PARTIAL; clicking opens a confirm modal, then calls adapter.requestRefill(), then shows the resulting smm_refill_requests status
Live-ish status updates while an order is non-terminalP0Client polls a lightweight, non-cached status endpoint every 5–10s for orders in PENDING_DISPATCH/PENDING_PROVIDER/IN_PROGRESS; polling stops automatically once a terminal state is reached (§14 explains why this endpoint must bypass the normal cache)
No cancel button anywhereP0Per NG1 — do not add one "for completeness"

12.4 Responsiveness#

RequirementPriorityAcceptance criteria
Fully usable at 360px width (mobile-first, matching the rest of the platform)P0Category bar scrolls horizontally; order form and history list reflow to single-column; no fixed-width elements that force horizontal scroll
Consistent with existing design system (shadcn/ui + Tailwind v4 tokens already in the project)P0No new component library introduced for this feature

13. Internal API Contract#

All routes below sit under app/api/, protected by the existing CSRF (lib/security/csrf.ts) and rate-limiting (lib/security/rate-limiter.ts) middleware already used by the rest of the app. "Admin" routes additionally require isAdmin + the relevant role permission.

MethodPathAuthPurpose
POST/api/admin/smm/providersAdminCreate provider (encrypts key before storage)
PATCH/api/admin/smm/providers/[id]AdminUpdate provider / toggle status / bring online
POST/api/admin/smm/providers/[id]/refresh-balanceAdminForce a getBalance() call
GET/api/admin/smm/providers/[id]/catalogAdminLive listServices() for the Import screen
POST/api/admin/smm/categories / PATCH .../[id] / DELETE .../[id]AdminCategory CRUD
POST/api/admin/smm/servicesAdminImport a service from a provider catalog entry
PATCH/api/admin/smm/services/[id]AdminEdit pricing, category, toggles, field schema
GET/api/admin/smm/ordersAdminFiltered/paginated order list
POST/api/admin/smm/orders/[id]/retryAdminManual reconciliation retry
POST/api/admin/smm/orders/[id]/refundAdminManual wallet refund with reason
GET/POST/api/admin/smm/blocklistAdminList + resolve blocklist entries
GET/PATCH/api/admin/smm/settingsAdminVisibility toggle, label, notification config
GET/api/smm/categoriesUser (auth)Active categories only, cached (§14)
GET/api/smm/servicesUser (auth)Active, non-blocked, provider-online services only; search/filter query params
GET/api/smm/services/[id]User (auth)Single service detail incl. field_schema
POST/api/smm/ordersUser (auth)Place order — body includes idempotencyKey (§10.2)
GET/api/smm/ordersUser (auth)The current user's own orders only, filterable
GET/api/smm/orders/[id]/statusUser (auth)Lightweight, cache-bypassing status check for polling (§12.3)
POST/api/smm/orders/[id]/refillUser (auth)Only permitted per §12.3's visibility rule, re-checked server-side
GET/api/cron/smm-status-syncCron secretBulk order status sync (§15)
GET/api/cron/smm-health-checkCron secretProvider balance/health check (§15)
GET/api/cron/smm-catalog-syncCron secretPeriodic price/min/max/refill-flag resync + blocklist detection (§15)

Every user-facing route double-checks server-side everything the UI already checked client-side (active status, provider status, min/max, ownership of the order being queried) — the client-side checks are for UX only and carry zero trust.


14. Caching Strategy#

Reuses lib/cache.ts (tag constants) and lib/redis-cache.ts (tagged Redis caching) exactly as the rest of the app already does — no new caching layer.

DataCacheTTL / invalidation
Category list (user-facing)Redis, tag smm:categoriesLong TTL (e.g. 1h); invalidated immediately on any category CRUD
Category imagesDownloaded once into cached_image_url on save (not re-fetched per request), served via next/image with priority for above-the-fold icons and a blur placeholderRe-fetched only when the admin changes the source image_url
Service catalog (user-facing)Redis, tag smm:servicesMedium TTL (e.g. 5–10 min) as a safety-net upper bound only; immediately invalidated on any service edit — including a profit_percent change — toggle, block, or catalog-sync price update, so a saved pricing change is never left visible-but-wrong for the rest of the TTL window
Single order status (/api/smm/orders/[id]/status)No cache (or a sub-second TTL at most)This endpoint exists specifically because "status should never look stale" — polling must always hit fresh data, not the same cached response repeatedly
Order list (history tab)Redis, tag smm:orders:user:, short TTLInvalidated on order creation and on every status change written by the sync cron
Provider balance (admin display)Redis, short TTL matching the health-check intervalInvalidated immediately after any manual "Refresh Balance"

The one rule that matters most: anything the user is actively watching (order status) must never be served from a cache that a background job hasn't just written to, and anything price-related must never be served from a cache an admin edit hasn't just invalidated. Everything else (categories, images, the parts of the catalog that aren't the price itself) can and should be cached aggressively, since it changes rarely. Note this caching layer only ever wraps the response, never the price computation itself — the underlying number is still always produced by the single computeSellRatePer1000() function from §9.0, recomputed fresh whenever the cache is (re)built.


15. Background Jobs and Cron#

15.0 The constraint that has to be named honestly#

You asked for scheduling that (a) needs no third-party external service, (b) is handled by a lightweight library that "does the job itself," and (c) works identically on Vercel, Render, Cloudflare, and any other hosting. (a) and (b) are fully achievable. (c), taken completely literally, runs into one real fact about how serverless/edge hosting works everywhere — not just on your stack: Vercel's serverless/edge functions and Cloudflare Workers do not keep a process running in the background. Code only executes while it's actively handling a request; the instant that request finishes, the runtime can freeze or destroy that instance. A library's internal timer (whatever it's called — setInterval, node-cron, anything) cannot tick "in the background" on those platforms, because there is no background — this is true for every application on Vercel or Cloudflare, not a gap specific to this feature. No npm package changes this; it's the execution model itself.

The good news: this only matters on serverless hosts. Your own README already lists Docker (standalone) as a supported deployment path alongside Vercel — and a standalone Docker container is a normal, always-on Node process, where a lightweight in-process scheduler works exactly the way you're picturing, with zero external moving parts. So the honest, best-of-both-worlds answer is to support both correctly instead of pretending one mechanism covers everything:

HostingHow the jobs actually get triggeredNew dependency?External third-party service?
VercelVercel Cron — a native, free, built-into-the-platform feature (a crons array in vercel.json that calls your /api/cron/* routes on a schedule)NoneNo — this is Vercel's own infrastructure, not a third-party SaaS
Cloudflare (Workers/Pages)Cloudflare Cron Triggers — the same idea, native to Cloudflare, configured in wrangler.tomlNoneNo
Render / Railway / Fly.io / a VPS / your existing Docker (standalone) pathAn in-process scheduler that starts when the Node server boots, since the process genuinely stays alive hereOne tiny library: node-cron (a few KB, effectively zero transitive dependencies, does exactly one job: run a callback on a cron schedule)No — this is the literal "library handles it itself" setup you asked for, and here it's actually true

The application code is identical everywhere — three plain, framework-agnostic functions (runStatusSync(), runHealthCheck(), runCatalogSync()) that don't know or care who called them. Only the trigger differs, and it's a few lines of config, not new business logic:

// lib/smm/jobs.ts — the actual work, called identically by either trigger mechanism
export async function runStatusSync() { /* §15.1 */ }
export async function runHealthCheck() { /* §15.1 */ }
export async function runCatalogSync() { /* §15.1 */ }
// instrumentation.ts (or your existing server bootstrap) — ONLY runs when CRON_MODE=internal
import cron from "node-cron";
import { runStatusSync, runHealthCheck, runCatalogSync } from "@/lib/smm/jobs";

if (process.env.CRON_MODE === "internal") {
  cron.schedule("*/2 * * * *", runStatusSync);
  cron.schedule("*/5 * * * *", runHealthCheck);
  cron.schedule("0 */4 * * *", runCatalogSync);
}
// app/api/cron/smm-status-sync/route.ts — used when CRON_MODE=platform (Vercel Cron / Cloudflare Cron Trigger points here)
import { runStatusSync } from "@/lib/smm/jobs";
export async function GET(req: Request) {
  // verify the platform's cron secret/signed header exactly like your existing cron routes already do
  await runStatusSync();
  return new Response("ok");
}

One environment variable, CRON_MODE=platform | internal, defaults to platform (safe on Vercel/Cloudflare). Set it to internal only when you know the deployment is a persistent process (your Docker standalone path). This delivers exactly what you asked for in the one place it's technically real, and the zero-cost native equivalent everywhere else — never a paid or third-party cron-ping SaaS either way.

15.1 The three jobs#

JobSuggested intervalWhat it does
smm-status-syncEvery 1–2 minutesFetch all smm_orders in a non-terminal status, batch by provider (respecting the bulk status action and per-provider rate limit from §8.5), update status/remains/start_count, write smm_order_status_history rows, invalidate the relevant order caches
smm-health-checkEvery health_check_interval_minutes (default 5) per providerCall getBalance(), evaluate the circuit-breaker thresholds from §10.5, flip status and fire notifications as needed
smm-catalog-syncEvery few hours (configurable)Re-fetch each provider's listServices(), update provider_rate_per_1000/provider_min/provider_max/provider_supports_refill on matching smm_services rows (this is the only thing that changes — remember from §9.0 there is no sell_rate_per_1000 column to separately recompute, it's derived live on every read), and blocklist any imported service that no longer appears in the provider's catalog (§10.6)

15.2 Concurrency safety (matters in both modes, critical in internal mode)#

Guard every job with a short-lived Redis lock (SET NX EX) keyed by job name before it does any work, and release it when done. This needs no new library — ioredis already supports this directly — and it's what prevents two overlapping runs from double-writing status history or racing on the same order. It matters most in internal mode if you ever scale to more than one container replica (e.g. two Docker instances behind a load balancer), since each replica would otherwise independently fire its own node-cron timer for the same job at the same time; the lock ensures only one replica's tick actually executes.

15.3 On the "status library" question specifically#

No state-machine library is needed either. The state machine in §10.1 is small and fixed — implement it as a plain TypeScript union type plus one pure canTransition(from, to): boolean function used everywhere a status is written. This is zero-dependency, fully type-checked, and trivially testable, which is a better fit here than pulling in a general-purpose state-machine library for eight states.


16. Security Requirements#

Security is not a separate pass at the end — every control below maps to a specific requirement you raised. Treat this whole section as a checklist the agent must satisfy before this feature is considered done, not aspirational guidance.

16.1 Secrets and encryption#

  • Provider API keys encrypted at rest with AES-256-GCM, using the same key-management approach as the existing UTR encryption in lib/payment.ts (do not invent a second encryption utility).
  • Decrypted keys exist only in server memory for the duration of the outbound call; never logged, never included in error messages, never sent to the client in any API response.
  • The admin UI only ever displays the last 4 characters of a saved key.
  • Rotating a key requires re-entering the full new value; there is no "edit in place" of a partial key.

16.2 SSRF protection on provider Base URLs#

  • Every admin-supplied api_base_url is validated through the existing lib/security/outbound-url.ts before it is ever called — reject private/loopback/link-local IP ranges, non-HTTPS URLs, and DNS names that resolve to internal infrastructure.
  • This check runs both at save-time and at call-time (DNS can change between the two), consistent with how the existing outbound-URL protection is already used elsewhere in the app.
  • Category image_url inputs get the same treatment before the server fetches them to build cached_image_url — an admin-supplied image URL is just as much an SSRF vector as a provider URL.

16.3 AuthN/AuthZ#

  • Every /api/admin/smm/* route requires isAdmin plus the appropriate role/permission, exactly like existing admin routes.
  • Every /api/smm/* (user) route requires an authenticated session and scopes all reads/writes to session.userId — a user must never be able to view or act on another user's order by guessing/incrementing an order ID (verify ownership server-side on every single-order route, not just in the list query).
  • Cron routes (/api/cron/smm-*) are protected the same way your existing cron routes already are (shared secret / signed header) — they must not be publicly triggerable.

16.4 CSRF, rate limiting, input validation#

  • All state-changing routes go through the existing CSRF origin check (lib/security/csrf.ts).
  • Rate limits (via the existing Redis-backed limiter) on: order placement (per user), refill requests (per user), service search (per user/IP), and the admin catalog-import browse endpoint.
  • Every request body validated with Zod before touching the database — including every field inside the dynamic formData payload, validated against that service's specific field_schema, not just "is it an object."
  • target_link and any URL-shaped formData field are validated as well-formed URLs and are never used to construct a server-side outbound request themselves (they're stored/forwarded to the provider only, never fetched by your own server).

16.5 SQL injection and data access#

  • All queries go through Drizzle's parameterized query builder. No raw SQL string concatenation anywhere in this feature — including the admin Orders page's filters/search, which is exactly the kind of feature where a raw LIKE '%' + input + '%' string-concat mistake tends to sneak in.
  • If a raw sql template is ever genuinely needed, only Drizzle's tagged sql template with parameter placeholders is acceptable — never string interpolation of user input into SQL text.

16.6 Race conditions and double-spend#

  • Order placement reuses the exact advisory-lock + transaction pattern already proven in lib/wallet.ts (§10.4) — do not write a second, parallel debit code path for this feature.
  • idempotency_key carries a DB-level unique constraint as a hard backstop, independent of application logic correctness.
  • Load-test (or at minimum, write an automated test for) the scenario of two simultaneous identical submit requests from the same user — exactly one order and exactly one debit must result.

16.7 Pricing integrity#

  • The client never sends a price in the order-placement request — only serviceId, quantity, and form fields. There is nothing resembling a price for a tampered request to alter.
  • The server computes the charge for every order via computeSellRatePer1000() (§9.0) against the database's current provider_rate_per_1000/profit_percent/exchange_rate at the exact moment of placement — never against a value read from cache, from the client, or from an earlier step in the same request.
  • Automated test: change a service's profit_percent mid-flow (between a test client "loading" the price and "submitting" the order) and assert the resulting order charges the new rate, proving there's no stale-price window to exploit.

16.8 Trusting external (provider) data#

  • Every provider API response is validated against a Zod schema before use — a malicious or malfunctioning provider must not be able to inject unexpected data shapes into your database.
  • Provider-supplied text (provider_service_name, category names from listServices(), status messages) is treated as untrusted user content for XSS purposes — rendered with normal React escaping (never dangerouslySetInnerHTML), even though it technically comes from a "trusted" upstream vendor. A compromised or careless provider account is still an attacker-controlled string from your app's point of view.

16.9 Audit logging#

  • Every admin action that changes money-relevant state — profit % edits, min/max edits, provider status changes, manual refunds, blocklist resolutions — is captured with actor, timestamp, before/after values, either in smm_order_status_history (for order-scoped actions) or a general admin audit log if one already exists in the codebase (reuse it if so).

16.10 General web hardening (already partially in place per your README — verify, don't rebuild)#

  • Existing CSP/HSTS/X-Frame-Options headers continue to apply to all new routes and pages (no new inline scripts introduced that would require loosening CSP).
  • No secrets, provider URLs, or internal service names leak into client-side JS bundles — provider configuration is fetched and used server-side only.
  • Standard bot/DDoS protection (already fronted by Cloudflare per the existing email-worker setup) covers these new routes with no additional configuration assumed needed, but confirm the new API routes are within Cloudflare's proxy scope.

17. Non-Functional Requirements#

CategoryRequirement
PerformanceCatalog/category API responses (cached) return in well under 200ms server time; order placement (uncached, includes wallet transaction + provider dispatch attempt) targets under 2s end-to-end even when the provider call is in the critical path; price display is effectively instant since it's pure computation (§9.0)
ResponsivenessMobile-first; fully functional at 360px width; no feature in this spec is desktop-only
AccessibilityCategory icons carry alt text; status badges are not color-only (icon/text alongside color); forms are keyboard-navigable; matches whatever accessibility baseline the rest of the platform already holds itself to
ObservabilityEvery provider call (success or failure) is logged with provider id, action, latency, and outcome (never the raw API key); every state transition is captured in smm_order_status_history; provider incidents are queryable in the admin UI, not just in logs
ScalabilityDesigned for a small number of providers (single digits) and a catalog in the low thousands of services — no requirement here demands a queue/worker cluster; if order volume grows enough that synchronous provider dispatch becomes a bottleneck, moving dispatch to a background step is a natural P2 evolution, not a v1 requirement
Data integrityEvery monetary figure on an order is immutable once written (§7.4); nothing about historical orders can be edited by a later catalog or settings change
Legal/compliance (informational, not a build task)Since this resells third-party API services to your own users, it's worth having your Terms of Service explicitly cover this offering (delivery isn't guaranteed by you but by the upstream provider, no cancellation policy, refund policy) — flagged here as a business to-do, not something the coding agent can complete

18. Edge Case Catalog#

You asked for nothing to be incomplete — here is every edge case identified during design, with the required behavior for each. Treat this as required test coverage, not optional reading.

#ScenarioRequired behavior
1Provider's add call times out — no response ever receivedDo not blindly retry (§8.5). Leave the order PENDING_PROVIDER; the reconciliation job/admin retry checks status or balance drift first before attempting another add, so a slow-but-actually-successful order is never duplicated
2User double-clicks "Place Order"Submit button disables on first click; same idempotencyKey on any resubmit within that page session returns the existing order rather than creating a second one (§10.4)
3Same user submits the same order from two different browser tabs within millisecondsEach tab generates its own idempotencyKey (they're genuinely separate submit actions) — both may succeed as two legitimate orders if the wallet covers both; if it only covers one, the second is correctly rejected by the atomic balance check, not by idempotency (this is correct behavior, not a bug)
4Service is deleted at the provider between the user loading the order page and clicking submitDispatch fails with a "service not found"-style error → service auto-blocklisted (§10.6), this order set BLOCKED and auto-refunded
5Provider balance hits zero mid-way through a burst of user ordersFirst failure trips the circuit breaker (§10.5); that specific order → PENDING_PROVIDER; every subsequent order attempt against that provider is rejected before wallet debit, with a clear "temporarily unavailable" response, until the admin brings the provider back online
6Admin adds funds at the provider and clicks "Bring Provider Online"Status flips to ONLINE; every PENDING_PROVIDER order for that provider is retried oldest-first, respecting the outbound rate limit (§10.3); the UI shows this as a visible bulk progress action, not a silent background process the admin has to guess is happening
7A quantity is submitted at exactly the service's platform_min or platform_maxAccepted — bounds are inclusive
8A quantity one unit outside platform_min/platform_maxRejected server-side with a specific error, even if a client-side bug would have allowed the submit
9User requests a refill on an order that is not COMPLETED/PARTIAL, or whose service has refill_enabled = falseRejected server-side regardless of what the UI showed — the button's visibility is a UX nicety, not the security boundary (§16)
10Provider's rate for a service drops to a level where profit_percent would produce a sell price below the provider's own cost (e.g. a data entry error, or the admin set a negative/zero profit)Not blocked outright (admin may deliberately loss-lead), but the admin preview panel (§9.4) visibly flags a zero-or-negative profit in a warning color so it's never accidental
11Two admins edit the same service's profit_percent at nearly the same timeLast-write-wins is acceptable here (low stakes, single-tenant admin panel) — no optimistic-locking requirement, but the audit log (§16.9) shows both edits with actor and timestamp so it's traceable after the fact
12Bulk order-status sync gets a mix of valid and invalid provider order IDs in one batch responsePer-ID errors ({"error": "..."} inside a batch) are handled individually — one bad ID never aborts updating the rest of the batch (§8.2)
13Admin deletes a category that still has active services assignedBlocked with a confirmation requiring reassignment or explicit cascade-deactivation — never a silent orphan
14User's wallet balance is checked as sufficient on the client, but has changed (e.g. spent elsewhere) by the time the server processes the orderServer re-checks the live balance inside the same atomic transaction as the debit (§10.4) — the client-side check is UX only
15Browser back button after a successful order, then hitting submit again on the (stale) formSame idempotency key handling as #2 applies if the key is still in the form state; if the page was reloaded and a new key was generated, this is treated as a new, independent order — same as #3
16Provider's services sync reduces a service's max below what an in-flight (not-yet-completed) order already requestedThe existing order is unaffected (already dispatched/snapshotted, §9.3); only the catalog min/max used for future orders changes, clamped to stay within the new provider bounds
17Notification storm: 50 orders fail against the same down provider within a minuteExactly one admin alert per unresolved incident, not 50 (§10.7 deduplication)
18Provider comes back online while there are 200 queued PENDING_PROVIDER ordersProcessed respecting the per-provider outbound rate limit (§8.5) — a large backlog drains steadily rather than firing 200 simultaneous requests that would themselves trip the provider's own rate limiting
19Exchange rate is edited by the adminEvery future price computation (§9.0) picks it up instantly; historical order snapshots are unaffected
20Provider API key is rotated or revoked outside the platform (directly on the provider's own site)Manifests as repeated AUTH_ERROR incidents (§7.8) → circuit breaker trips → admin is notified with a message that specifically distinguishes "auth error" from "insufficient balance" so they know to check the key, not their funds
21Custom Comments-type service where the submitted comment line count doesn't match quantityRejected client-side and server-side before dispatch, per the field_schema validation rule for that field (§8.4/§16.4)
22Admin sets platform_min below the provider's provider_min (or platform_max above provider_max)Rejected inline in the Add/Edit Service form (§11.3) — cannot be saved
23A provider is manually set to DISABLED by the admin (maintenance), not automatically DOWNBehaves identically to DOWN for order-blocking purposes, but never auto-clears — only an explicit admin action re-enables it (§11.1), so a provider doesn't silently "heal" itself off a health check while under intentional maintenance
24Cron job (internal mode) overlaps its own next tick because a run took longer than the intervalRedis lock (§15.2) causes the overlapping tick to no-op rather than run concurrently
25A currently-PENDING_PROVIDER order's service gets blocklisted before the provider ever comes back onlineOrder transitions PENDING_PROVIDER → BLOCKED and auto-refunds, same as scenario #4 — a queued order is not exempt from being re-evaluated against catalog reality

19. Success Metrics#

MetricTypeTarget
Time from provider connection to first visible balanceLeading< 10 seconds
Time from provider outage to admin notificationLeading< 2 minutes (bounded by the health-check interval, §7.1)
% of orders reaching a terminal state (COMPLETED/PARTIAL) without manual admin interventionLeading> 95% after the first month, excluding orders during a known provider outage
Duplicate-order / duplicate-debit incidentsLeading (should be zero)0
Orders incorrectly left in a non-terminal status for > 24h with no explanation visible to the adminLeading (should be zero)0
Average order-placement response timeLeading< 2s (§17)
Net profit margin realized vs. configured profit_percent (sampled)LaggingWithin rounding tolerance of the configured %, every time — this is a correctness check as much as a business metric
Support/complaint volume related to "stuck" or "wrong price" ordersLaggingTrending toward zero within the first month post-launch

20. Rollout Phasing#

P0 — Ship-blocking (the feature isn't usable without these)#

  • Provider connection + balance check + encryption (§7.1, §16.1)
  • Category + Service CRUD, including the Import flow and dynamic field_schema (§8.4, §11.2, §11.3)
  • Live pricing engine exactly as specified in §9 (compute-don't-store, server-side recompute at order time)
  • Order placement with full idempotency/race-condition handling (§10.2, §10.4, §16.6, §16.7)
  • Circuit breaker (provider-down detection, order blocking, admin notification, one-click recovery) (§10.5–§10.7)
  • Blocklist detection + auto-refund on BLOCKED/PROVIDER_CANCELLED (§10.1, §10.6)
  • Status sync + health-check jobs in platform cron mode (§15)
  • Sidebar visibility toggle, server-enforced (§12.1)
  • User Services tab + Orders/History tab, including Refill button and the explicit absence of any cancel action (§12.2, §12.3)
  • Full §16 security checklist

P1 — Fast follow (ship shortly after v1, doesn't block launch)#

  • internal cron mode for Docker/standalone hosting (§15.0) — only needed once you've decided on a persistent-process deployment target
  • Admin manual "Retry Dispatch" and CSV-adjacent conveniences on the Orders page (§11.4)
  • Drag-and-drop category reordering (§11.2)
  • Bulk-import multiple services at once (§11.3)
  • Advanced field_schema editor UI for admin overrides (§11.3)
  • Multiple simultaneous providers with per-provider health-check interval tuning (§11.1)

P2 — Explicitly future, not designed against yet#

  • Automatic multi-provider failover for the same logical service (NG6)
  • Real-time push (WebSocket/SSE) order status instead of polling (NG3)
  • Public reseller API for the platform's own users (NG4)
  • Automated currency-rate fetching instead of manual entry (§21)

21. Open Questions and Assumptions#

Genuinely open items — everything else in this document is a firm decision, not a placeholder.

#QuestionTagCurrent assumption if left unanswered
1Which hosting will this actually run on — Vercel, or the Docker (standalone) path?EngineeringDefaults to CRON_MODE=platform (Vercel Cron / Cloudflare Cron Triggers). If it's Docker/standalone, flip to internal per §15.0 — either way needs a one-time decision, not a rebuild
2Should the provider→platform currency exchange rate be entered manually by the admin, or fetched automatically from a live FX API?Productv1 assumes manual entry (§7.1) — this avoids adding yet another external API dependency for something that changes slowly enough to not need automation, but it does mean the admin is responsible for keeping it current
3Which specific SMM provider(s) will you actually connect first?ProductNot required to answer before building — the adapter targets the de-facto standard API shape (§8.1) that the large majority of providers share; if your specific provider deviates, the adapter's Zod validation (§16.7) will surface exactly where, rather than failing silently
4Should PENDING_PROVIDER orders have a maximum queue time before the platform proactively refunds them even if the admin hasn't acted?ProductNot implemented in v1 — currently these wait indefinitely for the admin to resolve the provider. Worth deciding a cutoff (e.g. 48h) as a P1 addition once you see real-world outage durations
5Notification channel details — which admin email address(es), and does the existing Web Push setup need any new subscription scope for this feature?EngineeringAssumed to reuse the existing notification infrastructure as-is (§5); recipient list is admin-configurable in Settings (§11.6)

22. Agent Execution Prompt#

Copy everything inside the fenced block below and send it as your first message to your coding agent, in the same repo/session where it can already see README.md, db/schema.ts, lib/wallet.ts, lib/security/, and lib/cache.ts. Attach or paste this entire PRD alongside it.

You are implementing a new feature, "SMM Panel Services," in an existing Next.js 15 + TypeScript + Drizzle + PostgreSQL + Redis project (Free Fire Tournament Platform). The full specification is in SMM-PANEL-PRD.md — read it in full before writing any code. It is long on purpose: this feature moves real money twice (user wallet → platform, platform's standing balance → upstream provider) and the spec exists to prevent the failure modes that matter for that (double-charging, race conditions, stale pricing, silent provider outages).

Before touching any code, do these in order:
1. Read SMM-PANEL-PRD.md in full, especially Section 5 ("How This Fits Your Existing Codebase"), Section 9 ("Pricing Engine" — the pricing rule is compute-live-never-store, this is not optional), Section 10 (order state machine, including which terminal states auto-refund and which don't), and Section 16 (security checklist).
2. Read the actual current contents of db/schema.ts, lib/wallet.ts, lib/security/csrf.ts, lib/security/rate-limiter.ts, lib/security/outbound-url.ts, lib/cache.ts, and lib/redis-cache.ts. Match their existing conventions exactly — naming, error handling style, how they're imported and composed — rather than introducing a different style for this feature.
3. Confirm with me which cron trigger mode applies (Section 15, Section 21 Q1) before wiring up scheduling — default to CRON_MODE=platform (Vercel Cron / Cloudflare Cron Triggers) unless told this is deploying as the existing Docker (standalone) path, in which case use CRON_MODE=internal with node-cron as specified.

Build in this order, and treat each phase as a separate reviewable unit of work (its own commit(s), not one giant diff) — this is Section 20's phasing, repeated here because it's the order that matters most:

Phase 1 — Data layer: all nine tables from Section 7, migrations generated via the project's existing `npm run db:generate` workflow, no application logic yet.
Phase 2 — Provider adapter (Section 8): the SmmProviderAdapter interface, the standard-API implementation, Zod validation of every provider response, the alias-tolerant field parser, rate limiting/backoff. Write this with unit tests against mocked provider responses (including malformed ones) before wiring it to any real network call.
Phase 3 — Pricing engine (Section 9): the single computeSellRatePer1000() function and everywhere it's called from. Write the test described in Section 16.7 (mid-flight profit-percent change) before moving on — this is the test that proves the "no stale price" requirement actually holds.
Phase 4 — Order placement + state machine (Section 10): wallet debit, idempotency, dispatch, and the PENDING_PROVIDER / BLOCKED / auto-refund paths. Write the concurrent-duplicate-submit test from Section 16.6 here.
Phase 5 — Admin pages (Section 11), in the sub-order: Providers → Categories → Services (incl. Import) → Orders → Blocklist → Settings.
Phase 6 — User-facing pages (Section 12): sidebar guard first (Section 12.1, and verify it blocks direct navigation, not just hides a nav link), then Services tab, then Orders/History tab.
Phase 7 — Cron jobs (Section 15) and the notification pipeline (Section 10.7), wired to whichever CRON_MODE was confirmed in step 3 above.
Phase 8 — Full pass against the Section 16 security checklist and the Section 18 edge-case table as actual test cases, not just a manual read-through.

Hard rules, restated because they're easy to accidentally violate while coding quickly:
- No cancel-order UI anywhere for end users (Section 2, NG1). The provider adapter can still expose a cancel method for internal/admin use, but nothing in the user-facing Orders tab calls it.
- There is no sell_rate_per_1000 (or equivalent) column stored anywhere in the catalog tables. Price is always computed at read-time from provider_rate_per_1000 and profit_percent via one shared function (Section 9.0). If you find yourself adding a persisted "current price" field to smm_services, stop — that's the exact bug this spec is designed to prevent.
- The client never sends a price to the order-placement endpoint. The server computes it from its own current data, every time (Section 9.2).
- BLOCKED and PROVIDER_CANCELLED orders auto-refund in the same transaction as the status change (Section 10.1) — this is not admin-discretionary.
- Every new dependency you're tempted to add, check Section 5's reuse table first. The only new runtime dependency this entire feature should need is node-cron, and only if CRON_MODE=internal applies.
- All new DB access goes through Drizzle's parameterized query builder — no raw SQL string concatenation, including in admin search/filter endpoints.
- Run the project's existing `npm run check` (typecheck + lint + format + tests) after every phase, not just at the end.

If you hit a genuine ambiguity not resolved by Section 21 ("Open Questions and Assumptions"), stop and ask rather than guessing silently — but note that almost everything has already been decided in this document specifically so that shouldn't happen often.

Quick "definition of done" checklist (mirrors Section 16 and Section 18 — use this to sanity-check the finished feature)#

  • A provider can be connected, its balance shown, and the key is never retrievable in full again after save
  • A service can be imported, categorized, priced, and toggled live, end to end, without touching the database directly
  • Placing an order recomputes price server-side from current data every time, never trusts the client, and is provably idempotent under duplicate submits
  • Killing the provider's balance mid-test causes new orders to be rejected pre-debit, the in-flight order to queue instead of fail, and an admin notification to fire exactly once
  • Bringing the provider back online via the one-click button drains the queued orders automatically
  • A service the provider no longer recognizes gets blocklisted and its affected order auto-refunds, with zero admin action required for the refund specifically
  • The sidebar item and the page itself both respect the visibility toggle — tested by direct URL navigation while disabled, not just by checking the nav link is hidden
  • No cancel button exists anywhere in the user-facing order flow
  • The whole feature is usable at 360px width

23. Reference: Provider API Documentation and Sources#

Verified via live research while writing this spec (August 2026). These aren't endorsements of any specific provider — they're cited because their public API documentation independently confirms the standard "SMM API" shape this PRD's adapter (Section 8) is built against, which is what makes the adapter realistically provider-agnostic.

  • General "SMM API" standard shape (single endpoint, action parameter, balance/services/add/status/refill actions) — cross-confirmed across SMM Africa's API docs (smm.africa/api-docs), SMM Junction (smmjunction.com/api), and several similarly-structured provider docs.
  • Order types and type-specific fields (Default, Custom Comments, Mentions, Mentions with Hashtags, Poll, Subscriptions, Drip-feed) — confirmed via SMM Junction's published service-type list and a public open-source SMM API client library on GitHub (github.com/SpawneR99/-smm-panel-api) documenting the same de-facto "v2 API" standard.
  • Integration best practices (cache the catalog and refresh periodically, never poll single-order status in a tight loop, batch status checks, back off on HTTP 429, never expose the key client-side, and — most relevant to Section 8.5's retry guidance — treat a timed-out add call as ambiguous and check balance/status before ever retrying it) — from SMM Africa's API documentation and PrimeSmmHub's public API guide (primesmmhub.com/api).
  • Typical error conditions to design around ("Not enough funds," "Service not found"/invalid service ID, quantity outside min/max) — confirmed via PrimeSmmHub's API documentation.

When you've picked your actual provider(s), the one thing worth doing before Phase 2 of the execution prompt above is pulling up that specific provider's own API page and diffing it against Section 8.1's table — the shape will almost certainly match closely, but confirm exact field names (Section 8.2 already assumes some variance, like start_count vs start_counter) and whether they require JSON or form-encoded POST bodies.


End of document.