# SMM Reselling Service — Full Technical Specification

This document is a complete, self-contained extraction of the SMM (Social Media Marketing panel reselling) feature as implemented in the refer2bundle.com codebase. It is written so that a **new, standalone project** can be built from it without needing to read the original codebase — hand this file to an AI/developer and it has everything needed: provider API contract, database schema, pricing/currency logic, sync algorithm, order lifecycle, status distribution, and known gaps to fix in v2.

---

## 1. What This Feature Does

Users browse a catalog of social-media-engagement services (Instagram followers, TikTok views, YouTube subscribers, etc.) purchased wholesale in **USD** from an upstream SMM panel provider ("ResellerSMM"-style API), marked up and sold to end users in a **local currency** (GHS — Ghana Cedis, in the reference implementation), and fulfilled automatically by forwarding the order to the provider's API.

This is a classic **SMM reseller panel** pattern: you are a reseller of a reseller. The upstream provider does the actual work (bots/workers that deliver followers/views/likes); your app is a markup + wallet + storefront layer on top of their API.

---

## 2. High-Level Architecture

```
┌─────────────┐     buy services      ┌──────────────────┐     forward order      ┌─────────────────────┐
│   End User   │ ─────────────────────▶│  Your SMM App     │ ───────────────────────▶│  Upstream Provider   │
│ (web/mobile) │  (wallet balance,     │  (this project)   │   (HTTP POST, API key)  │  (ResellerSMM API)   │
│              │◀───────────────────── │                    │◀─────────────────────── │                      │
└─────────────┘   order status, GHS    └──────────────────┘   order id / status      └─────────────────────┘
                    pricing shown                │
                                                  │ admin manages
                                                  ▼
                                       ┌──────────────────────┐
                                       │   Admin Panel          │
                                       │ - sync service catalog │
                                       │ - set markup/currency  │
                                       │ - manage orders        │
                                       │ - check provider bal.  │
                                       └──────────────────────┘
```

Key architectural facts to preserve:
- **No webhooks/IPN from the provider.** Everything is pull-based: your app polls the provider for status on demand.
- **No background cron job** exists in the reference implementation (a real gap — see §13).
- Service catalog is **cached locally** in your DB and refreshed only when an admin clicks "Sync Services". Orders are **not** placed live against the provider catalog — always against your locally cached, locally priced copy.
- Currency conversion is a single global multiplier (`dollar_rate`), not a live FX feed.

---

## 3. Upstream Provider API (ResellerSMM v2 style)

This is a very common SMM-panel API shape (shared by dozens of providers — JAP, SMM panels, etc. all clone roughly this contract). Base URL in the reference implementation: `https://resellersmm.com/api/v2` (configurable per-tenant).

### 3.1 Transport

- **Method:** `POST` (all actions, including reads)
- **Content-Type:** `application/x-www-form-urlencoded`
- **Auth:** API key passed as a POST field named `key` on every request (not a header/bearer token)
- **Response:** JSON body
- TLS verification is disabled in the reference client (`CURLOPT_SSL_VERIFYPEER/HOST = false`) — **do not carry this over**, it's a security shortcut that should be fixed in the new project (only disable if the provider's cert genuinely can't be validated, and prefer a proper CA bundle fix instead).

### 3.2 Actions

| `action` | Purpose | Required params | Notes |
|---|---|---|---|
| `services` | List all services in the provider's catalog | `key` | Returns array of service objects (see 3.3) |
| `add` | Place a new order | `key`, `service`, plus order-type-specific params (see 3.4) | Returns `{ "order": <id> }` or `{ "error": "..." }` |
| `status` | Get status of one order | `key`, `order` | Returns status object (see 3.5) |
| `status` (bulk) | Get status of multiple orders | `key`, `orders` (comma-separated IDs) | Returns map keyed by order id |
| `refill` | Request a refill for one order | `key`, `order` | Returns `{ "refill": <id> }` or `{ "error": "..." }` |
| `refill` (bulk) | Request refill for multiple orders | `key`, `orders` (comma-separated) | Returns array of `{order, refill}` |
| `refill_status` | Get refill status | `key`, `refill` | |
| `refill_status` (bulk) | Get multiple refill statuses | `key`, `refills` (comma-separated) | |
| `cancel` | Cancel one or more orders | `key`, `orders` (comma-separated) | Not all services/providers support cancel — check the service's `cancel` capability flag first |
| `balance` | Get your reseller account balance | `key` | Returns `{ "balance": "12.34", "currency": "USD" }` |

### 3.3 Service object (from `services` action)

```json
{
  "service": 7991,
  "name": "Twitter Followers [ 100% Real | 2K/Day ]",
  "type": "Default",
  "category": "Twitter (X) Followers",
  "rate": "0.52",
  "min": "10",
  "max": "100000",
  "dripfeed": false,
  "refill": false,
  "cancel": false
}
```

- `rate` is **USD per 1000 units**.
- **Pseudo-service / category-divider rows:** providers frequently inject fake "services" into the flat list purely to act as visual section headers in their own dashboards, marked by a sentinel `rate` value. The reference implementation detects and **skips** any service where `rate` is exactly `9999`, `99999`, or `999999` during sync. Always apply this same filter — importing these creates garbage unorderable "services" in your catalog.
- `dripfeed` / `refill` / `cancel` are capability flags — surface them in the UI so users know what's supported per service, but they are informational only; the API doesn't reject unsupported actions differently, so don't rely on them for validation.

### 3.4 Order types and their parameters (`action=add`)

The provider supports several distinct order "types" depending on the service, differentiated purely by which extra fields you send alongside `service`. This is the **full parameter surface** the provider supports — the reference implementation only wires up a subset (see §7); a from-scratch build should support all of these:

| Order type | Extra params | Example |
|---|---|---|
| Default | `link`, `quantity` | `{service:1, link:"...", quantity:100}` |
| Drip-feed | `link`, `quantity`, `runs`, `interval` | `{service:1, link:"...", quantity:100, runs:2, interval:5}` |
| SEO / keyword-based | `link`, `quantity`, `keywords` | `{service:1, link:"...", quantity:100, keywords:"test, testing"}` |
| Custom comments | `link`, `comments` (newline-separated) | `{service:1, link:"...", comments:"good pic\ngreat photo"}` |
| Mentions (hashtag) | `link`, `quantity`, `hashtag` | `{service:1, link:"...", quantity:100, hashtag:"test"}` |
| Mentions (media likers) | `link`, `quantity`, `media` | `{service:1, link:"...", quantity:1000, media:"https://.../p/xxx"}` |
| Package (fixed bundle) | `link` only | `{service:1, link:"..."}` |
| Web traffic | `link`, `quantity`, `country`, `device`, `type_of_traffic`, `google_keyword` | `{service:1, link:"...", quantity:100, country:"US", device:"Desktop", type_of_traffic:1, google_keyword:"test"}` |
| Subscriptions (old posts only) | `username`, `min`, `max`, `posts`, `delay`, `expiry` | `{service:1, username:"user", min:100, max:110, posts:0, delay:30, expiry:"11/11/2022"}` |
| Subscriptions (unlimited new + N old posts) | `username`, `min`, `max`, `old_posts`, `delay`, `expiry` | `{service:1, username:"user", min:100, max:110, old_posts:5, delay:30, expiry:"11/11/2022"}` |
| Comment likes | `link`, `quantity`, `username` | `{service:1, link:"...", quantity:100, username:"test"}` |
| Poll | `link`, `answer_number` | `{service:1, link:"...", answer_number:"7"}` |
| Comment replies | `link`, `username`, `comments` | `{service:1, link:"...", username:"user", comments:"good pic\ngreat photo"}` |
| Invites from groups | `link`, `quantity`, `groups` (newline-separated) | `{service:1, link:"...", quantity:100, groups:"group1\ngroup2"}` |

### 3.5 Status object (from `status` action)

```json
{
  "charge": "0.27819",
  "start_count": "3572",
  "status": "Partial",
  "remains": "157",
  "currency": "USD"
}
```

Observed real-world status strings (**not normalized by the provider** — pass through as-is): `Pending`, `In progress`, `Processing`, `Completed`, `Partial`, `Canceled` (American spelling, one "l"). See §8 for why this matters.

### 3.6 Reference client implementation (PHP, port as needed)

```php
class SmmApiService
{
    private string $apiUrl;
    private string $apiKey;

    public function __construct(string $apiUrl, string $apiKey) {
        $this->apiUrl = rtrim($apiUrl, '/');
        $this->apiKey = $apiKey;
    }

    public function services(): array {
        $data = json_decode($this->connect(['key'=>$this->apiKey,'action'=>'services']), true);
        return is_array($data) ? $data : [];
    }

    public function order(array $data): ?object {
        $post = array_merge(['key'=>$this->apiKey,'action'=>'add'], $data);
        $decoded = json_decode($this->connect($post));
        return $decoded ?: null;
    }

    public function status(int $orderId): ?object {
        return json_decode($this->connect(['key'=>$this->apiKey,'action'=>'status','order'=>$orderId])) ?: null;
    }

    public function multiStatus(array $orderIds): array {
        $data = json_decode($this->connect(['key'=>$this->apiKey,'action'=>'status','orders'=>implode(',', $orderIds)]), true);
        return is_array($data) ? $data : [];
    }

    public function balance(): ?object {
        return json_decode($this->connect(['key'=>$this->apiKey,'action'=>'balance'])) ?: null;
    }

    public function cancel(array $orderIds): array {
        $data = json_decode($this->connect(['key'=>$this->apiKey,'action'=>'cancel','orders'=>implode(',', $orderIds)]), true);
        return is_array($data) ? $data : [];
    }

    public function refill(int $orderId): ?object {
        return json_decode($this->connect(['key'=>$this->apiKey,'action'=>'refill','order'=>$orderId])) ?: null;
    }

    public function multiRefill(array $orderIds): array {
        $data = json_decode($this->connect(['key'=>$this->apiKey,'action'=>'refill','orders'=>implode(',', $orderIds)]), true);
        return is_array($data) ? $data : [];
    }

    public function refillStatus(int $refillId): ?object {
        return json_decode($this->connect(['key'=>$this->apiKey,'action'=>'refill_status','refill'=>$refillId])) ?: null;
    }

    private function connect(array $post): string|false {
        $fields = [];
        foreach ($post as $name => $value) { $fields[] = $name.'='.urlencode((string)$value); }
        $ch = curl_init($this->apiUrl);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => implode('&', $fields),
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_USERAGENT => 'Mozilla/4.0 (compatible; MSIE 5.01; Windows NT 5.0)',
            // NOTE: reference impl disables SSL verification here — fix in new project,
            // use a proper CA bundle instead of CURLOPT_SSL_VERIFYPEER/HOST = false.
        ]);
        $result = curl_exec($ch);
        if (curl_errno($ch) !== 0 && empty($result)) { curl_close($ch); return false; }
        curl_close($ch);
        return $result;
    }
}
```

---

## 4. Database Schema

Three tables. `smm_orders.user_id` references your host app's users table (loose FK, not enforced — see §13).

### 4.1 `smm_settings` — single-row config table

```sql
CREATE TABLE smm_settings (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    api_key VARCHAR(255) NOT NULL DEFAULT '',
    api_url VARCHAR(255) NOT NULL DEFAULT 'https://resellersmm.com/api/v2',
    dollar_rate DECIMAL(10,4) NOT NULL DEFAULT 12.50,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

INSERT INTO smm_settings (api_key, api_url, dollar_rate) VALUES ('', 'https://resellersmm.com/api/v2', 12.50);
```

- **Exactly one row is expected to ever exist.** The reference app hardcodes `WHERE id = 1` on save — it never inserts a second row and never deletes. Preserve this assumption or move to a proper key-value settings table if you want multi-tenancy.
- `dollar_rate` = how many units of local currency (GHS) equal 1 USD. This is a **manually admin-set global multiplier**, not a live FX rate. In the reference deployment it drifted from the migration default of `12.50` to a live value of `14.00` over time — expect admins to update this periodically as the real exchange rate moves.
- `api_key` is stored **in plaintext** in the database — flagged as a gap in §13.

### 4.2 `smm_services` — locally cached, locally priced service catalog

```sql
CREATE TABLE smm_services (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    provider_id INT UNSIGNED NOT NULL,
    name VARCHAR(255) NOT NULL,
    category VARCHAR(100) NOT NULL DEFAULT '',
    type VARCHAR(100) NOT NULL DEFAULT '',
    rate_per_1000 DECIMAL(10,4) NOT NULL DEFAULT 0.0000,   -- USD, from provider
    min_order INT NOT NULL DEFAULT 0,
    max_order INT NOT NULL DEFAULT 0,                       -- 0 = provider set no explicit cap; treated as "no upper bound" (see §7)
    dripfeed TINYINT(1) NOT NULL DEFAULT 0,
    refill TINYINT(1) NOT NULL DEFAULT 0,
    cancel TINYINT(1) NOT NULL DEFAULT 0,
    custom_description TEXT,                                 -- admin override, shown to end users instead of provider's raw name/desc
    custom_price DECIMAL(10,2) DEFAULT NULL,                  -- admin override: GHS price PER UNIT. NULL = auto-price from rate_per_1000 * dollar_rate
    is_active TINYINT(1) NOT NULL DEFAULT 1,                  -- admin can hide a service from users without deleting it
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_provider (provider_id),
    INDEX idx_category (category),
    INDEX idx_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

- `provider_id` is the provider's own `service` id — **unique**, used as the upsert key on every sync (see §6).
- `custom_price`, `custom_description`, `is_active` are **admin-only local overrides** that must survive a re-sync (§6 explains exactly how).
- Real-world scale in the reference deployment: **~4,965 services** synced from one provider across dozens of categories (Twitter, Instagram, TikTok, Discord, Spotify, Binance/crypto engagement, web traffic, etc). Design pagination and search for catalogs of this size from day one — do not assume a small list.

### 4.3 `smm_orders` — local order ledger

```sql
CREATE TABLE smm_orders (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id INT UNSIGNED NOT NULL,
    service_id INT UNSIGNED NOT NULL,                        -- FK to smm_services.id (local id, not provider_id)
    service_name VARCHAR(255) NOT NULL,                       -- denormalized snapshot at order time (survives service rename/delete)
    provider_order_id INT UNSIGNED DEFAULT NULL,               -- the provider's own order id, returned by `add`
    link VARCHAR(500) NOT NULL DEFAULT '',
    quantity INT NOT NULL DEFAULT 0,
    charge_ghs DECIMAL(10,2) NOT NULL DEFAULT 0.00,            -- what the user was actually charged, in local currency
    charge_usd DECIMAL(10,4) NOT NULL DEFAULT 0.0000,          -- derived USD cost (0 when custom_price override was used — see §5)
    status VARCHAR(30) NOT NULL DEFAULT 'pending',              -- see §8 for the vocabulary mismatch gotcha
    start_count INT DEFAULT NULL,                               -- filled in only after a status poll
    remains INT DEFAULT NULL,                                   -- filled in only after a status poll
    extra_field VARCHAR(255) DEFAULT NULL,                      -- unused in reference impl; reserved for order-type-specific extra param name
    extra_value TEXT,                                           -- unused in reference impl; reserved for order-type-specific extra param value
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_user (user_id),
    INDEX idx_status (status),
    INDEX idx_provider (provider_order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

- `extra_field`/`extra_value` exist in the schema but are **never written to** in the reference implementation — they were clearly reserved for supporting the other order types in §3.4 (comments/keywords/hashtag/etc as a generic key-value pair) but that generalization was never finished. A from-scratch build should either finish this generalization or use dedicated typed columns per order-type — don't just copy the half-done column pair.
- No `smm_refills` table exists — refill requests are fire-and-forget against the provider with no local audit trail (§9, §13).

---

## 5. Currency Conversion & Pricing Logic

Two independent pricing modes per service, chosen by whether `custom_price` is `NULL`:

### 5.1 Auto-priced (default — `custom_price IS NULL`)

```
per_unit_ghs  = (rate_per_1000 / 1000) * dollar_rate
per_1000_ghs  = rate_per_1000 * dollar_rate

# at order time, for a given quantity:
usd_cost = (rate_per_1000 / 1000) * quantity
ghs_cost = round(usd_cost * dollar_rate, 2)
```

This is a **flat markup-free conversion** — the reference implementation does not add any extra profit margin on top of the provider's rate beyond whatever `dollar_rate` itself is set above the real exchange rate. If you want an explicit profit margin, add a `markup_percent` multiplier here (e.g. `ghs_cost = usd_cost * dollar_rate * (1 + markup_percent/100)`) — the reference app does **not** have this, profit is baked entirely into how far above the real FX rate `dollar_rate` is set by the admin.

### 5.2 Custom-priced (`custom_price IS NOT NULL`)

```
per_unit_ghs = custom_price          # admin sets this directly, in GHS, per unit
per_1000_ghs = custom_price * 1000

# at order time:
ghs_cost = round(custom_price * quantity, 2)
usd_cost = 0   # not derived/tracked when using a manual override
```

Use this mode for services where the admin wants to set an arbitrary retail price independent of the live provider rate (e.g., round-number pricing, promotional pricing, or hiding true provider cost entirely).

### 5.3 Order-time cost calculation (full logic, both modes)

```php
$dollarRate  = smm_settings.dollar_rate; // fallback 12.50 if row missing
$ratePer1000 = $service['rate_per_1000'];
$usdCost = ($ratePer1000 / 1000) * $quantity;
$ghsCost = round($usdCost * $dollarRate, 2);

if ($service['custom_price'] !== null) {
    $ghsCost = round((float)$service['custom_price'] * $quantity, 2);
    $usdCost = 0;
}
```

Both `charge_ghs` and `charge_usd` are persisted on the order row — `charge_usd` is purely for admin accounting/reconciliation against the provider's own USD-denominated billing, and is left at `0` whenever a custom price was used (since there's no longer a direct derivation from the provider's rate).

### 5.4 Design notes for a new project

- **Single global currency multiplier** (`dollar_rate`) — no per-service or per-category override of the conversion rate, no multi-currency support. If you need multi-currency, key `dollar_rate` by currency code instead of a single scalar in `smm_settings`.
- No rounding-mode configuration — always rounds to 2 decimal places (standard "round half away from zero" via PHP's `round()`).
- No live FX API integration anywhere — 100% manually maintained by whoever has admin access.

---

## 6. Service Catalog Sync Logic ("Sync Services" admin action)

Triggered manually by an admin button click (`POST` with `action=sync_services`). No automatic/scheduled sync exists in the reference implementation.

### 6.1 Algorithm

1. Call provider `services` action → get flat array of all services across all categories.
2. For each service returned:
   a. **Skip pseudo-service rows** — if `rate` is exactly `9999`, `99999`, or `999999` (strict comparison), it's a category-divider/header injected by the provider for their own dashboard UI, not a real orderable service. Count and report how many were skipped.
   b. Otherwise, **upsert by `provider_id`**:
      ```sql
      INSERT INTO smm_services
        (provider_id, name, category, type, rate_per_1000, min_order, max_order, dripfeed, refill, cancel)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      ON DUPLICATE KEY UPDATE
        name = VALUES(name), category = VALUES(category), type = VALUES(type),
        rate_per_1000 = VALUES(rate_per_1000), min_order = VALUES(min_order), max_order = VALUES(max_order),
        dripfeed = VALUES(dripfeed), refill = VALUES(refill), cancel = VALUES(cancel);
      ```
3. Report back to the admin: `"Synced {count} services ({skipped} dividers skipped)."`

### 6.2 Critical, non-obvious rule: admin overrides survive re-sync

**`custom_price`, `custom_description`, and `is_active` are deliberately excluded from the `ON DUPLICATE KEY UPDATE` clause.** This means:

- An admin can set a custom retail price or write a friendlier description for a service, then re-sync the whole catalog (e.g. nightly, or whenever the provider adds new services) **without losing those overrides** — only the provider-controlled fields (name, category, rate, min/max, capability flags) get refreshed.
- Likewise, if an admin deactivates a service (`is_active = 0`) because it's unreliable, re-syncing does **not** silently reactivate it.
- **New services default to `is_active = 1`** on first insert (via the column's `DEFAULT 1`) — they go live immediately upon sync, with no review step. If you want a moderation queue for newly-synced services in the new project, insert with `is_active = 0` and require explicit admin activation instead.

### 6.3 What's NOT handled

- **No detection/handling of services that disappeared from the provider's catalog** — a service that's been removed upstream stays in your local `smm_services` table forever (orderable, until it fails at order time). Consider marking services absent from the latest sync as inactive automatically in the new project.
- No sync history/audit log — each sync just silently overwrites.

---

## 7. Order Placement Flow (end-to-end)

Endpoint: user submits `{service_id, link, quantity, ...extra}` (JSON body).

1. **Auth check** — reject if no logged-in user.
2. **Validate required fields** — `service_id`, `link`, `quantity` (all must be truthy/non-zero).
3. **Fetch service** — `SELECT * FROM smm_services WHERE id = ? AND is_active = 1`. Reject with "Service not found or inactive" if missing or deactivated.
4. **Validate quantity bounds:**
   ```php
   if ($quantity < $service['min_order'] || ($service['max_order'] > 0 && $quantity > $service['max_order'])) {
       // reject: "Quantity must be between {min} and {max}."
   }
   ```
   Note `max_order > 0` guard — **`max_order = 0` means "no upper bound enforced"**, not "zero allowed". Preserve this convention or make it explicit in the new project (e.g. `NULL` instead of `0` for "unlimited" is clearer).
5. **Compute cost** — per §5.3 (auto-priced or custom-priced).
6. **Lock and check balance:**
   ```sql
   SELECT balance FROM users WHERE id = ? FOR UPDATE
   ```
   Reject with "Insufficient balance..." if `balance < ghs_cost`.

   ⚠️ **Known bug to fix, not replicate:** this `SELECT ... FOR UPDATE` is executed **without an explicit transaction** (`BEGIN`/`COMMIT`) around it in the reference code. Under MySQL's default autocommit mode, a lone `FOR UPDATE` outside a transaction provides essentially no protection — the row lock is not meaningfully held across the subsequent balance deduction. This is a **real race-condition risk**: two concurrent orders from the same user could both pass the balance check before either deducts. **In the new project, wrap steps 6–9 in a real DB transaction.**
7. **Verify provider is configured** — reject with "SMM service not configured" if `smm_settings.api_key` is empty.
8. **Deduct balance immediately** (optimistic/pre-auth style): `UPDATE users SET balance = balance - ghs_cost`. This happens **before** the provider is called.
9. **Call provider `add`** with `service = service.provider_id`, `link`, `quantity`, plus any of `comments`/`username`/`keywords`/`hashtag`/`media`/`answer_number` that were supplied (only these six extras are wired up in the reference impl — see §3.4 for the full set the provider actually supports).
10. **On provider failure** (no response, or response contains an `error` field):
    - **Refund the user immediately**: `UPDATE users SET balance = balance + ghs_cost`.
    - Log the **real** provider error server-side (with user id, service, provider service id) for admin debugging.
    - Return a **generic, non-technical message to the user** — never leak raw provider errors to end users (they're often confusing or reveal upstream vendor details): *"Sorry, service temporarily unavailable. Please try again later due to some small technical issues."*
11. **On success:**
    - Insert into `smm_orders`: `user_id, service_id, service_name (snapshot), provider_order_id, link, quantity, charge_ghs, charge_usd, status='pending'`.
    - Return `{success:true, order_id, provider_order_id, charge, new_balance}` to the client.

### 7.1 Design notes

- The **deduct-then-refund-on-failure** pattern (step 8 → 10) means there's a window where money is held from the user's wallet with no order yet created. If the process crashes between step 8 and step 10/11, the user loses the balance with no order and no refund. **Wrap the whole flow in a transaction and only commit the deduction once the provider call has definitively succeeded or failed** (or use a reserve/capture two-phase pattern) in the new project.
- Only a **subset** of the provider's order-type parameters are forwarded (§3.4 vs step 9 above). A generalized new project should accept a `type` field on the order form and forward the matching parameter set for the service's actual type.

---

## 8. Order Status Distribution / Sync-Back

**There is no push mechanism and no scheduled job.** Status updates only happen when a human explicitly triggers a refresh:

- **User-triggered:** `GET /smm/check-status?id={order_id}` — pulls a single order's status from the provider and persists `status`, `start_count`, `remains` back onto the `smm_orders` row. Triggered by a "refresh" button on each order card in the user's order history UI.
- **Admin-triggered:** same idea, `action=check_status` on the admin orders page, per-order button.

Both call the provider's single-order `status` action (§3.2) — **never the bulk `status` action** (`multiStatus`), even though the reference client implements it. This is a missed optimization: **an admin with hundreds of pending orders has no way to refresh them all at once** without clicking each one individually.

### 8.1 Known bug: status vocabulary mismatch

Two different vocabularies collide in the same `status` column:

1. **Locally-defined statuses** used by admin manual overrides and the UI's badge CSS classing: `pending`, `processing`, `completed`, `cancelled`, `refunded` (lowercase, British "cancelled").
2. **Provider's raw returned strings**, written directly and unmodified whenever a status poll succeeds: `Pending`, `In progress`, `Processing`, `Completed`, `Partial`, `Canceled` (mixed case, American "Canceled" — note the real order sample below).

Real order rows observed in production:
```
status = "Canceled"     -- provider raw string (note: American spelling, capital C)
status = "Completed"
status = "In progress"
```

Because the CSS badge classes are keyed on the lowercase local vocabulary (`.badge.cancelled`, `.badge.completed`...), a provider-polled status like `"Canceled"` or `"In progress"` **does not match any badge class** and renders with no styling (falls through to default/no color). **This is purely cosmetic (no functional breakage — the raw string is still stored and displayed as text), but visibly broken.**

**Recommendation for the new project:** define an explicit status-normalization map at the boundary where you write provider responses into your DB:
```
"Pending"      → "pending"
"In progress"  → "processing"
"Processing"   → "processing"
"Partial"      → "partial"       (add this as a first-class local status)
"Completed"    → "completed"
"Canceled"     → "cancelled"     (normalize spelling too)
```
Keep the provider's raw string in a separate `provider_status_raw` column if you want to preserve it for debugging, and use the normalized value for all UI logic/filtering.

### 8.2 What triggers a `cancelled`/`refunded` local status (independent of provider polling)

- Admin can force-set an order's local `status` directly via a dropdown (`update_status` action) to any of the five local values, independent of what the provider says — useful for manual dispute resolution/refund bookkeeping, but means local status and provider-truth can silently diverge if not polled again afterward.
- The **cancel action** (§9) sets local status to `cancelled` unconditionally after calling the provider — even if the provider's cancel call itself failed/errored (no error handling on that path in the reference implementation). Fix this in the new project: only mark cancelled locally if the provider confirms.

---

## 9. Cancel & Refill Workflows

### 9.1 Cancel (admin-only in reference implementation)

```
1. Admin clicks cancel on a non-completed, non-cancelled order.
2. Confirm dialog.
3. POST action=cancel_order {id}
4. Server: call provider.cancel([provider_order_id])
5. Server: UPDATE smm_orders SET status='cancelled' WHERE id=? -- unconditional, no check of provider's response!
6. No refund logic exists here at all — cancelling an order does NOT return the user's charge_ghs to their balance.
```

**Two gaps to fix in the new project:**
- Only set local status to cancelled if the provider actually confirms the cancellation (don't assume success).
- Decide and implement a refund policy on cancel (full refund? none, since work may have partially completed? partial based on `remains`/`start_count` proportion?). The reference implementation has **no refund on cancel at all**, which is very likely a bug/oversight, not a deliberate policy — do not silently copy this.

### 9.2 Refill

```
1. Admin clicks refill on a *completed* order only (UI only shows the button when status === 'completed').
2. Confirm dialog.
3. POST action=refill_order {id}
4. Server: call provider.refill(provider_order_id)
5. Response (refill id or error) shown to admin as a toast — NOT PERSISTED anywhere.
```

**Gap:** there is no `smm_refills` table, so there is no audit trail of what was refilled, when, or its outcome. A refill request that "succeeds" against the provider produces a `refill_id` that your app immediately forgets — you can never later call `refill_status` to check on it because you didn't save the id. **In the new project, add a `smm_refills` table** (`id, order_id, provider_refill_id, status, requested_at, resolved_at`) and a poll mechanism analogous to order status.

### 9.3 User-facing refill/cancel

Neither cancel nor refill is exposed to end users in the reference implementation — both are **admin-only** actions. Users can only view status and refresh it.

---

## 10. Provider Balance Check

Admin-only, on-demand (auto-fetched on page load + manual "Check Balance" button). Calls provider `balance` action, displays the raw USD figure with a color-coded threshold:

```js
if (balance <= 0)   color = red    // out of funds, orders will start failing
else if (balance < 10) color = amber // low-balance warning
else                 color = green
```

No automated alerting (email/SMS/Slack) when balance runs low in the reference implementation — purely a manual glance at the admin dashboard. **Recommend adding a scheduled low-balance check + notification in the new project**, since running out of provider balance mid-operation causes every subsequent order to fail at step 9 of §7 (with the user's money already deducted-then-refunded, but a bad user experience at scale).

---

## 11. Internal App API Contract

### 11.1 User-facing endpoints

| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | `/smm` | Browse/search service catalog, view wallet balance | User session |
| GET | `/smm/orders` | Paginated order history (20/page) | User session |
| GET | `/smm/check-status?id={order_id}` | Refresh one order's status from provider | User session, order must belong to requesting user |
| POST | `/smm/order` | Place an order — JSON body `{service_id, link, quantity, comments?, username?, keywords?, hashtag?, media?, answer_number?}` | User session |

`/smm/order` and `/smm/check-status` return JSON always; `/smm` and `/smm/orders` return full HTML pages.

### 11.2 Admin endpoints

| Method | Path | Purpose |
|---|---|---|
| GET/POST | `/admin/smm` | Service catalog management page + actions below (as `action=` POST field) |
| — `action=save_settings` | | Update `api_key`, `api_url`, `dollar_rate` |
| — `action=sync_services` | | Trigger §6 sync algorithm |
| — `action=update_service` | | Set `custom_description`, `custom_price`, `is_active` for one service |
| — `action=get_service` | | Fetch one service's full row (for edit modal) |
| — `action=check_balance` | | §10 |
| GET/POST | `/admin/smm-orders` | Order management page + actions below |
| — `action=update_status` | | Force-set local status (bypasses provider) |
| — `action=check_status` | | §8, single order |
| — `action=cancel_order` | | §9.1 |
| — `action=refill_order` | | §9.2 |
| — `action=get_order` | | Fetch one order's full row + joined user name/email (for detail modal) |

Both admin pages support `?page=`, and `/admin/smm` supports `?category=&q=`, `/admin/smm-orders` supports `?status=&search=` as GET query filters (server-side, DB-level pagination — 24/page for services, 25/page for orders).

### 11.3 Pagination strategy mismatch worth noting

- **Admin service list** (`/admin/smm`): server-side SQL pagination (`LIMIT`/`OFFSET`), 24 per page, filters applied in the `WHERE` clause and re-run per page load.
- **User service catalog** (`/smm`): the **entire filtered-active catalog is fetched in one query and rendered into the DOM**, then paginated **client-side in JavaScript** (24 per page) with all filtering (platform quick-buttons, category/service dropdown, free-text search) also done client-side against `data-*` attributes on each card.

At ~5,000 services this client-side approach means every page load ships a large DOM (all cards, `display:none` on hidden ones) rather than truly paginating server-side. It works but is not efficient. **For the new project, prefer server-side pagination + filtering everywhere** (i.e., an actual search/filter API endpoint returning one page of JSON at a time), especially if the catalog will be this large.

---

## 12. Frontend UX Logic Worth Preserving

- **Quick platform filter buttons** (Facebook/Instagram/YouTube/TikTok/X/Spotify/Telegram/Google/LinkedIn/Snapchat/Twitch/Threads/All) — implemented as a simple case-insensitive substring match of the platform keyword against `category + " " + name`. Not a real taxonomy field — just a convenience heuristic. A cleaner new-project design would tag each synced service with a normalized `platform` enum derived from its category name at sync time (§6), rather than re-deriving it via substring match on every filter interaction.
- **Cascading category → service dropdown**: selecting a category populates a second dropdown scoped to just that category's service names, letting a user jump straight to an exact service.
- **Order modal live price preview**: as the user types a quantity, JS recomputes `total = per_unit_ghs * quantity` client-side, mirroring the server formula from §5.3 exactly, so the user sees the price before submitting. **Always re-validate and recompute this server-side on submit** — never trust the client-computed total (the reference implementation does correctly recompute server-side; just don't regress this in a rewrite).
- **Order history cards** are collapsible/expandable, showing summary (service name, date, charge, status badge) collapsed and full detail (link, quantity, start count, remains) expanded.

---

## 13. Known Gaps & Recommendations Summary (read before building v2)

| Gap | Risk | Recommendation |
|---|---|---|
| No DB transaction around balance check + deduct (§7 step 6, 8) | Race condition → double-spend under concurrent requests | Wrap order placement in a real transaction |
| Deduct-then-refund-on-failure with no atomicity guarantee | Crash mid-flow can silently lose user funds | Reserve/capture pattern, or transaction covering deduct+provider-call+insert |
| Cancel sets local status unconditionally, ignoring provider response | Local status lies about reality | Only update local status on confirmed provider success |
| No refund policy on cancel | Users lose money on cancelled orders with no recourse | Define and implement an explicit refund policy |
| No `smm_refills` audit table | Refill requests are untracked/unauditable, `refill_status` action is unusable (no id saved) | Add tracking table |
| Status vocabulary mismatch (local lowercase vs provider raw strings) | Cosmetic badge breakage, inconsistent filtering | Normalize provider statuses at write-time (§8.1) |
| No cron/scheduled bulk status sync | Orders silently sit stale unless a human clicks refresh; provider's `multiStatus` bulk endpoint exists and is unused | Add a scheduled job using the bulk status endpoint |
| No low-balance alerting | Orders fail silently mid-operation once provider balance hits 0 | Scheduled balance check + notification (email/Slack/SMS) |
| API key stored in plaintext in DB | Credential exposure if DB is ever leaked/dumped | Encrypt at rest, or use a secrets manager |
| CSRF token generated/embedded in views but **not actually validated server-side** on any SMM POST endpoint | CSRF vulnerability on order placement and admin actions | Enforce `validate_csrf_token()` (or equivalent) on every state-changing POST |
| TLS certificate verification disabled in the HTTP client to the provider | MITM risk on all provider communication | Enable verification, fix the underlying CA bundle issue instead |
| `smm_orders.user_id` has no foreign key constraint | Orphaned orders possible if a user is hard-deleted | Add FK with appropriate `ON DELETE` behavior |
| Only 6 of ~14 provider order-type parameter sets are wired up (§3.4 vs §7) | Can't sell services requiring drip-feed/web-traffic/subscription/poll/invite-from-groups params | Generalize the order form to accept a `type` and the matching param set |
| `max_order = 0` silently means "unlimited" | Non-obvious, easy to misread as "0 allowed" | Use `NULL` for unlimited instead of `0` |
| New synced services go live (`is_active=1`) immediately, no moderation | Bad service accidentally exposed to users right after sync | Default new services to inactive, require explicit admin activation |
| No detection of services removed upstream | Dead/removed services stay orderable forever until they fail at order time | Deactivate services not present in the latest sync |
| Client-side pagination/filtering of the full catalog (§11.3) | Inefficient at scale (~5k+ services) | Server-side filtered pagination via a proper search endpoint |

None of these are reasons not to build the new project the same *shape* — the core flow (cache provider catalog → mark up in local currency → forward orders → poll status) is sound and battle-tested at ~5,000 services / 100+ orders in production. They're just the specific implementation shortcuts worth **not** copying verbatim.

---

## 14. Minimum Viable Rebuild Checklist

If starting completely fresh, build in this order (mirrors natural dependency order):

1. `smm_settings`, `smm_services`, `smm_orders` tables (§4) — add `smm_refills` and `provider_status_raw` from day one per §13.
2. Provider API client (§3.6), with TLS verification enabled.
3. Admin: settings form (api_key/api_url/dollar_rate) + sync button (§6, with the "preserve local overrides" upsert logic and the pseudo-service filter).
4. Admin: service catalog table with search/filter/pagination + per-service edit (custom price/description/active toggle).
5. Currency/pricing calculation module (§5) — pure function, unit-test it directly (auto-priced vs custom-priced branches, rounding).
6. User: catalog browse page (server-side paginated/filtered this time) + order placement flow (§7, transactional this time).
7. User + admin: order history / order management with status refresh (§8, with normalization).
8. Admin: cancel/refill actions (§9, with proper provider-response handling and a refund policy decision).
9. Scheduled job: bulk status sync via `multiStatus`, and a low-balance check/alert.
10. CSRF enforcement, FK constraints, encrypted API key storage — before going live, not after.
