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.mdor 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#
- Overview and Objectives
- Goals and Non-Goals
- Personas
- Glossary
- How This Fits Your Existing Codebase
- High-Level Architecture
- Data Model
- Provider Integration Layer
- Pricing Engine
- Order Lifecycle and State Machine
- Admin Panel Requirements
- User-Facing Requirements
- Internal API Contract
- Caching Strategy
- Background Jobs and Cron
- Security Requirements
- Non-Functional Requirements
- Edge Case Catalog
- Success Metrics
- Rollout Phasing
- Open Questions and Assumptions
- Agent Execution Prompt
- 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#
Non-Goals (explicitly out of scope for v1)#
3. Personas#
4. Glossary#
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.
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 tosmm_serviceswithprovider_ratecached andsell_ratecomputed. - Order: User → Services Tab → validated against cached
smm_servicesrow (never a live provider call on the user's request path) → wallet debited atomically →smm_ordersrow createdPENDING_DISPATCH→ dispatched toADAPT.createOrder()synchronously if the provider is healthy, otherwise parkedPENDING_PROVIDER(§10). - Sync: Cron → batches all non-terminal orders →
ADAPT.getOrderStatus()(bulk) → updatessmm_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 toDOWN→ new orders blocked at the API layer →NOTIFYfires (email + push, deduplicated) → admin fixes it → one click flips provider back toONLINE→ queuedPENDING_PROVIDERorders 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#
7.2 smm_categories#
7.3 smm_services#
Unique constraint: (provider_id, provider_service_id) — prevents importing the same provider service twice.
7.4 smm_orders#
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.
7.6 smm_refill_requests#
7.7 smm_blocklist_log#
7.8 smm_provider_incidents#
Drives both the "provider down" banner and the admin notification pipeline.
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).
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).
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:
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/refillactions when checking more than one order; never loop single-order calls in the sync job. - Cache the service catalog —
listServices()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 cachedsmm_servicesrow. - Never retry a timed-out
addcall 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 asPENDING_PROVIDERfor manual/cron reconciliation rather than firing a secondaddautomatically. This governs the retry logic required in §10.4. - Never expose
api_key_encryptedor 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:
- The client shows a price (computed via §9.0, moments earlier, from the same API).
- 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. - The server independently loads the current
provider_rate_per_1000,exchange_rate, andprofit_percentfor that service from the database (not from any request payload, not from any cache the client could have influenced), runs them through the exact samecomputeSellRatePer1000()function, and only that freshly-computed number is what gets charged and written into the order's immutable snapshot (§7.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: -
Changing
profit_percentnever touches historical orders — it only changes the two stored inputs (provider_rate_per_1000stays as last-synced,profit_percentupdates), 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 --> [*]
10.2 Order placement — the exact sequence#
- Client sends
serviceId,formData,idempotencyKey(client-generated UUID, regenerated only on a genuinely new submit, not on retry). - Server: reject if
smm_settings.is_platform_visible = false, if the serviceis_active = falseoris_blocked = true, or if the service's providerstatus ≠ ONLINE. This is the enforcement point for "no new orders while a provider/service is down" (§10.5). - Server: validate
formDataagainst the service'sfield_schemaand quantity bounds server-side (never trust client-side validation alone). - Server: in a single DB transaction, using the same advisory-lock pattern as the existing wallet debit — (a) check
idempotency_keydoesn'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 thesmm_ordersrow withstatus = PENDING_DISPATCHand the full pricing snapshot from §9. - Immediately after the transaction commits, attempt
adapter.createOrder().- Success: update the same row to
status = IN_PROGRESS, storeprovider_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.
- Success: update the same row to
- 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_keyhas 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
addcall 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), orconsecutive_failure_count(auth errors, timeouts, 5xx) crosses a threshold (default: 3 consecutive failures across calls).
The instant a provider is DOWN:
- Every
smm_servicesrow 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_incidentsrow is created and the admin is notified (§10.6). - The admin's one-click "Bring Provider Online" button (§11.1) clears
status → ONLINE, resetsconsecutive_failure_count, timestampsresolved_aton 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_logrow records the reason and raw (sanitized) provider error. - Any order that was in-flight against it becomes
BLOCKEDand 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.
11.2 Categories Page — //smm/categories#
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):
Add/Edit Service form:
Services list/table:
11.4 Orders Page — //smm/orders#
11.5 Blocklist Page — //smm/blocklist#
11.6 Settings Page — //smm/settings#
12. User-Facing Requirements#
12.1 Sidebar visibility — server-enforced, not just hidden UI#
12.2 Services Tab#
12.3 Orders / History Tab#
12.4 Responsiveness#
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.
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.
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:
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#
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_urlis validated through the existinglib/security/outbound-url.tsbefore 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_urlinputs get the same treatment before the server fetches them to buildcached_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 requiresisAdminplus the appropriate role/permission, exactly like existing admin routes. - Every
/api/smm/*(user) route requires an authenticated session and scopes all reads/writes tosession.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
formDatapayload, validated against that service's specificfield_schema, not just "is it an object." -
target_linkand any URL-shapedformDatafield 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
sqltemplate is ever genuinely needed, only Drizzle's taggedsqltemplate 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_keycarries 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 currentprovider_rate_per_1000/profit_percent/exchange_rateat 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_percentmid-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 fromlistServices(), status messages) is treated as untrusted user content for XSS purposes — rendered with normal React escaping (neverdangerouslySetInnerHTML), 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#
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.
19. Success Metrics#
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
platformcron 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)#
internalcron 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_schemaeditor 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.
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,
actionparameter,balance/services/add/status/refillactions) — 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
addcall 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.