# Build Brief — Abnabot Admin Panel, complete UI rebuild with working mock data

---

## §0 How to read this brief

This document is an exhaustive inventory of everything the staff-facing
product contains and does: every entity, every field, every enum value, every
action with its preconditions and failures, every sentence the product can
say, every rule that decides what a person is allowed to type, and every
process that takes time. It is a contract about **substance**, not about
**form**.

It deliberately does **not** specify:

- placement of anything (no sidebar, top bar, bottom nav, tab bar, drawer,
  modal, dialog, bottom sheet, card, tile, badge, chip, pill, toast, banner,
  table, column, grid, progress bar, progress ring, donut, line chart or bar
  chart is prescribed anywhere in this document, and none is implied by the
  order in which things are described);
- grouping (which facts sit together, which actions are adjacent, which
  settings share a surface);
- navigation (how staff move between things, what the moves are called,
  whether there are nine areas or ninety, whether anything is a page at all);
- the form of any item (whether a status is a word, a colour or a shape;
  whether a set is a list, a table or something else; whether a switch is a
  switch);
- visual language (palette, type, spacing, iconography, motion);
- which screen anything lives on.

**The section order of this brief is not an information architecture.** It is
the order of enumeration. A rebuild with one surface per section of this
brief, or one surface per area of the old product, is a transcription, not a
design.

**The current implementation is not to be consulted for structure.** The
rebuild inherits the product's substance and its wire contract; it inherits
nothing about how the old interface arranged, named, grouped or styled that
substance. In particular the old product's nine top-level areas and their
sub-areas are not a given.

For every item in this brief, decide and be able to justify:

1. **Who** looks at it and in what mood (checking money at midnight,
   hunting a user who complained, configuring something dangerous, watching a
   bulk job run over 4,000 subscriptions).
2. **What kind of thing** it is: a fact, a status, a quantity, a secret, a
   technical string to copy, an action, a warning, a history, a live process.
3. **How often it changes**, and whether it changes while someone is looking.
4. **What it costs to misread** (decreasing the wrong wallet, disabling all
   selling, gating the last always-available payment method, running a
   removal job over the wrong category).
5. **How reachable it should be**: obvious, one move away, or hard by design.
6. **The empty, one, many, broken, and loading cases** for anything that is a
   set or is fetched.

**Interface language.** The product is Persian and right-to-left. Every
person-facing sentence exists in Persian; a partial English dictionary exists
and there is no language switcher. The rebuild ships Persian as the interface
language. Where this brief quotes English it is quoting the meaning; the
Persian sentence in the current string bundle carries that meaning and must be
preserved. Technical strings (usernames, links, ids, dates, amounts, card
numbers, addresses, API keys) are always left-to-right and Latin-digit.

**Terminology.** "Bankcard" in this brief means a customer-facing bank card
(a 16-digit card number with a holder and a bank) that customers transfer money
to; it is a domain object, never a UI component.

---

## §1 What you are building

Abnabot sells VPN access through a Telegram bot. This is the **staff side**:
the place where the people who run the business configure what is sold, watch
money come in, manage individual customers, operate the infrastructure that
provisions accounts, talk to their audience, and run bulk operations.

One audience uses it: a **bot admin**, identified by Telegram and flagged as
admin in their user record (`IsAdmin`; the record also carries `IsBlocked` and `IsPartner`). There are no sub-roles; every admin can do
everything here. The product also has a **first-run setup** (seven steps) that
the same admin completes once before the bot is usable, and which stays
available afterwards as a status readout.

What an admin cares about, in their own terms:

- *How is the business doing?* Today's received money, counts of users and
  subscriptions, and a revenue analysis over any date range with a
  previous-period comparison, by payment method and by wallet.
- *Find this customer and fix their problem.* Search by id, username or name;
  see balances, totals and subscriptions; adjust a wallet with a recorded
  reason; block or unblock; grant or revoke partner status; buy a plan on
  their behalf; open one subscription and add or remove days and data,
  disable or re-enable it, read its full change history, or delete it
  permanently.
- *Control what is sold.* Plans (price, data, days, discount, trial), the
  categories that group them, their audience (partner / normal), the wallet
  each category charges, the commitment gate, the renewal strategy, the
  paused-selling and paused-renewal messages, the expired-subscription
  auto-deletion window, and two global switches that stop all selling or all
  renewals at once.
- *Take money safely.* Bankcards with per-period transaction and amount
  caps and wallet scope; a rotating bankcard-transfer minimum ("Safe Mode") with a
  mandatory reason for every change and an audit trail; payment methods with
  per-wallet overrides; cashback rules and which method-and-wallet pairs they
  apply to; unlock rules that gate payment methods behind a customer's
  history; Tronado and NOWPayments credentials and settings.
- *Keep the infrastructure connected.* Panels (Marzban or PasarGuard) with
  live health; the template account each category provisions from; which
  plans each category sells; a sync that resolves panel access.
- *Reach customers.* Broadcasts to five audiences with a test-first flow;
  expiry and data-usage warning rules; required-join channels; tutorial
  entries; ad-tracking links with attribution reports; referral rewards.
- *Run and watch bulk work.* A bulk adjustment over a category's
  subscriptions (add or remove days and gigabytes, optionally reset usage,
  optionally notify each user with a templated message), previewed against a
  frozen snapshot before it runs, then paused, resumed, cancelled and
  inspected item by item; and the queue of notification jobs.
- *Operate the system.* Setup completion, the report group, sending a day's
  activity report, running a database backup, and kicking the job runner or
  the notification dispatcher by hand.

The product's temperament: **consequential and mostly synchronous**. Nearly
every write moves money, changes what customers can buy, or touches a remote
panel that provisions real accounts. A few processes are long: bulk jobs,
notification dispatch, panel health checks, backups. Several values are
secrets shown once or never. Mistakes here affect every customer at once, so
the confirmations and warnings in this brief are not optional.

---

## §2 Outcome requirements (hard)

1. Every control works. Nothing is a placeholder, nothing is "coming soon", no
   action is a dead end.
2. Mock data exists everywhere data can exist, and it is realistic, varied,
   and internally consistent (§14, §15).
3. State is real and shared across the whole product: a wallet adjustment
   changes the user's balance and totals; a plan edit changes every offer
   that includes it; a bulk job's progress changes its items, its counters and
   its status; a broadcast creates a notification job; a category's switches
   change what the setup readout reports.
4. Anything that takes time progresses visibly and finishes: bulk jobs walk
   their items; notification jobs send; the Safe Mode countdown counts and
   rotations arrive; panel health resolves per panel.
5. Every state in this brief — every enum value, every failure, every empty
   case, every setup-step state — is reachable without editing code.
6. Persian is the interface language; right-to-left correct throughout;
   technical strings left-to-right.
7. Nothing here is optional. You choose **how**, not **whether**.

---

## §3 Technical shape

Build constraints only. None of these is a design instruction.

- **Stack.** The rebuild replaces the current staff front end in place:
  static HTML, CSS and plain ES-module JavaScript served from the existing
  static mount, no build step required to run, loaded inside the Telegram
  in-app browser. A build step is acceptable if its output is static files in
  the same mount. No new runtime dependency from a CDN: the current product
  draws its own SVG for every visual, and the content-security policy allows
  external scripts only from the product's own origin, `https://telegram.org`
  and `https://cdn.jsdelivr.net`.
- **Content-security constraints.** No inline `<script>` and no inline
  event-handler attributes; inline `style` attributes are allowed. Images
  same-origin or `data:`.
- **One narrow, typed data-access module.** The interface never touches the
  store directly. Every read and write goes through one module whose function
  names, argument shapes, return shapes, error objects and latency semantics
  are exactly what the real HTTP client has. Today its body is the mock world
  (§14); later it is swapped for real requests and nothing that consumes it changes.
  Two families of calls exist (ongoing management, and first-run setup); they
  share the same identity and the same error envelope.
- **Wire fidelity.** Field names are `snake_case` exactly as in §7. Ids are
  integers. Money amounts are numbers in Toman. Timestamps are ISO strings;
  revenue buckets are naive Tehran-local (`YYYY-MM-DDTHH:MM:SS`); everything
  else is UTC with a zone. `null` means absent. Booleans are booleans.
- **Typed errors.** Every failure carries an HTTP-like `status` and a
  `detail` that is either a plain string (the sentence the server chose) or an
  object with a machine `code` and often a `message` and extra fields (§7,
  §12). The interface maps codes to Persian sentences and shows a server
  string as-is only when no translation exists.
- **Forms are schema-validated** with per-input and whole-form messages,
  using the exact rules in §7 (name character sets, ranges, 16-digit bankcards,
  TRON address shape, whole-toman integers, date formats).
- **Accessibility.** Keyboard-complete (including any series-over-time
  representation, which must be navigable point by point with a live text
  readout and have an equivalent textual form), visible focus, every control
  labelled, contrast-safe in both Telegram light and dark themes, motion
  respects reduced-motion preference.
- **Responsive range.** Narrow phone width (~360px) through desktop width,
  with the phone as the primary case: this is used from a phone inside
  Telegram.
- **Reset.** A discoverable way to reset the mock world to its seed.

---

## §4 Design mandate

This is an operations console for a small business handling real money and
real infrastructure from a phone. It must feel **precise, trustworthy and
fast to scan**, never decorative for its own sake and never ambiguous about
what a control will do.

- **Status must be unmistakable.** A job that is Running is not one that is
  Paused; a panel that is offline is not one that has not been checked; a
  payment method that is enabled but not linked to any wallet is effectively
  off; a bankcard whose period quota is exhausted is not taking money; a
  category with no panel cannot sell. A viewer must never have to guess.
- **Danger must feel different from routine.** Decreasing a wallet,
  deleting a subscription, disabling all selling, running a removal job,
  gating the last always-available payment method, changing Safe Mode: none
  of these may look like toggling a plan's visibility.
- **Secrets are secrets.** API keys and IPN secrets are never echoed; a set
  secret is distinguishable from an unset one; entering a value replaces it.
- **Technical strings are exact values people copy.** Bankcard numbers, bot deep
  links, webhook addresses, template usernames, report-group codes,
  placeholder tokens. Never re-cased, translated, wrapped without a way to get
  the whole value, or rendered in Persian digits.
- **Density is a decision.** Some deployments have one panel, two plans and
  forty users; others have four panels, thirty plans and thousands of users.
  Both must work from a phone.
- **Explain the dangerous mechanisms.** Safe Mode and the bulk-job engine
  are subtle; the product carries explanatory texts (§7.14, §7.25) that must
  remain reachable from where the decision is made.
- **Be specific, not templated.** The product has a brand name, a Persian
  brand name and a brand emoji supplied at runtime (§7.1); nothing else about
  the visual identity is given, and "generic admin template" is a failure.

---

## §5 Identity, roles, sessions

There is no sign-in. Identity comes from Telegram: the host supplies signed
initialisation data and every request carries it in the header
`X-Telegram-Init-Data`. The server verifies the signature and then requires
the user to be an admin.

```
// Identity as the product knows it (getMe())
ok         true
is_admin   true
user_id    int          // the acting admin's Telegram id; recorded on every audited write
brand:
  name     str          // substitutes BRAND_NAME
  name_fa  str          // substitutes BRAND_NAME_FA
  emoji    str          // substitutes BRAND_EMOJI
```

| role | meaning |
| --- | --- |
| bot admin (`IsAdmin`) | the only role; every capability in this brief |

Rules:

- **Gate at start.** Before anything else the product confirms identity. No
  init data at all → "This only works inside Telegram." 401 (bad or
  expired signature) → "Invalid Telegram auth; reopen the app." 403
  (`Admin privileges required`) → "Access denied. This area is for bot admins
  only." Any other failure → "Server error". None of these states offers
  anything but the way forward.
- **Every write is audited with the acting admin's id** where the domain
  records it (wallet adjustments, block/partner changes, subscription
  changes, Safe Mode events, job creation, ad links). The acting admin is
  always the authenticated user; a client-supplied actor is never trusted.
- **What an admin must never see:** panel passwords and tokens, full API
  keys and IPN secrets (only "set / not set" and a masked tail: `•` × (len−4)
  + last four characters, or `••••` for very short values).
- **Branding** arrives from the identity call and is substituted into every
  sentence containing `BRAND_NAME`, `BRAND_NAME_FA`, `BRAND_EMOJI`. The
  product title is "`BRAND_NAME_FA` admin".
- **Theme** follows the Telegram host theme and re-applies when it changes.
- **Host integration.** When the person is somewhere nested, the Telegram
  host's own back control is shown and takes them back; at the root it
  is hidden. Confirmations use the host's native confirm when available.
  Success and failure feedback triggers the host's haptic notification.

---

## §6 Account and access capabilities

No registration, password, one-time code, two-factor, password reset or
sign-out exists; all of that is Telegram's. The capabilities that exist:

### 6.1 Start

Inputs: none. The product verifies identity (§5), applies branding and
theme, and presents the overview (§7.2) as the first thing an admin sees
(a statement of what they come for, not of where it lives).

### 6.2 First-run setup

A separate entry the admin uses before the bot is configured; see §7.30.
Same identity gate, same fatal states, with these sentences: no init data →
"This only works inside Telegram."; 401 → "Invalid Telegram auth;
reopen the app."; 403 → "Access to this area is for the bot admin only.";
other → "Server error; try again later."

### 6.3 Reset local state

Nothing is stored locally except the last user search query (§7.5) and the
revenue filter state (§7.4) for the session. A reset-to-seed exists for the
mock build (§3).

---

## §7 The domain: every entity, every field, every value

### 7.1 Money, dates, digits

- One currency: **Toman**. Amounts are numbers; shown with thousands
  separators and the unit word («تومان»). Whole numbers in practice.
- Digits are always Latin (`0-9`). The current product renders some dates
  through a Persian locale and therefore in Persian digits; the rebuild must
  not: dates, too, use Latin digits.
- Dates and times are shown in **Tehran time**. Wire timestamps are UTC ISO
  unless stated (revenue buckets are naive Tehran-local).
- Compact numbers exist for axis-style readouts: thousands `K`, millions
  `M`, billions `B`, one decimal under 10 (`1.2M`, `45K`).
- Percentages with one decimal where they are shares (`37.5%`) and whole
  where they are progress (`62%`).
- Absent is shown as absent (`—`), never as `null`, `undefined`, `0` or a
  blank label with no value.

### 7.2 Overview snapshot (`getDashboard()`)

```
users:            { total: int|null, blocked: int|null }
subscriptions:    { active: int|null, total: int|null }      // active = status 10, not deleted; total = not deleted
catalog:          { categories: int|null, plans: int|null }
panels:           { count: int|null }
today:                                                       // Tehran calendar day
  revenue         float      // sum of today's SUCCESSFUL deposits (money received); never includes purchases/renewals
  payments_count  int
  sales_count     int        // new subscriptions today
  renewals_count  int        // renewals today
  methods:        [{ title: str|null, total: float, count: int }]     // received money by payment method
  top_users:      [{ user_id: int, full_name: str|null, username: str|null,
                     paid: float, count: int, balance: float }]       // top 5 payers today
setup:            { complete: bool, completed_count: int, total_count: int, progress_percentage: int } | null
```

Rules: a `null` count means that counter could not be computed and must read
as unknown, not zero. "Revenue" everywhere in this product means **money
received into wallets** (successful deposit payments). Subscription
purchases and renewals spend wallet balance and are never counted as revenue;
the product must say so wherever revenue is shown ("Revenue counts successful
wallet deposits only; subscription purchases and renewals spend wallet balance
and are not counted again."). When there are no payments today: "No payments
recorded today". The setup readout (`setup`) is the same fact as §7.30's
progress; when complete: "Setup complete 🎉", otherwise "{done} of {total}
steps".

### 7.3 Panel health (`getPanelHealth()`)

```
panels: [{
  id            int
  friendly_name str|null
  url           str|null      // panel address (technical string)
  online        bool          // the panel answered with its system overview
  total_users   int|null
  active_users  int|null
  version       str|null      // e.g. "0.8.4"
}]
```

Rules: this is fetched **on demand only** (one network call per panel; can
take seconds per panel) — never as part of the overview snapshot. An offline
panel reads "Offline"; an online one states its active-user count and
version.

### 7.4 Revenue analysis (`getRevenueMeta()`, `getRevenue(query)`)

Vocabulary (`getRevenueMeta()`):

```
timezone        "Asia/Tehran"
presets         ["today","yesterday","this_week","last_7_days","this_month","last_30_days","last_90_days","this_year","all_time","custom"]
granularities   ["auto","hour","day","week","month"]
metrics         ["revenue","payments","average"]
default_preset  "last_7_days"
max_points      400
methods         [{ id: int, title: str, enabled: bool }]     // payment methods (PaymentType)
wallets         [{ id: int, title: str, enabled: bool }]
```

Query:

```
preset          one of presets (default last_7_days)
start, end      ISO dates, only for custom (both required)
granularity     one of granularities (default auto)
methods         comma-separated method ids (empty = all)
wallets         comma-separated wallet ids (empty = all)
compare         bool — previous period of equal length in days
top_limit       1..50 (default 10)
```

Response:

```
meta:
  timezone, preset, requested_granularity, granularity, granularity_adjusted (bool),
  points (int), max_points, bounded (bool), compare_available (bool), generated_at,
  filters: { methods: [int], wallets: [int] }
range:            { start: date|null, end: date|null, days: int|null }
summary:          { revenue: float, payments: int, average: float, unique_users: int, max_payment: float }
comparison:       null | {
  range, summary (same shape),
  alignment: "relative_index_from_period_start",
  series_points: int, series_truncated: bool,
  delta: { revenue|payments|average|unique_users|max_payment: { abs: number, pct: number|null } }
}
series:           [{ bucket: "YYYY-MM-DDTHH:MM:SS", revenue, payments, average, unique_users }]   // zero-filled, one per bucket
comparison_series: null | same shape, clamped to series length
methods:          [{ id, title, revenue, payments, average, share (0..100) }]
top_users:        [{ user_id, full_name, username, revenue, payments }]
```

Rules (server truth; the interface only chooses parameters and presents):

- Week runs **Saturday to Friday**. "This week" starts on the Saturday on or
  before today. Presets resolve to inclusive Tehran dates.
- `auto` granularity: ≤ 2 days → hour; ≤ 92 days → day; ≤ 400 days → week;
  else month. Any granularity whose series would exceed 400 points is stepped
  coarser and `granularity_adjusted` is true; the product says "Granularity
  was changed to "{g}" to keep the series readable." A range too wide even
  for monthly points fails 400 `{code: "range_too_large", months, max_points,
  max_years, message}` and the product says "This range is too large: {months}
  monthly points exceed the {max}-point limit. Pick a range of at most {max}
  months (about {years} years)." — shown where the person can act on it, not
  as a passing notice.
- `all_time` resolves its edges from the data; with no revenue at all under
  the filters, the response is an empty report (`points: 0`, empty series).
  Comparison is unavailable for `all_time` ("Comparison is unavailable for
  the all-time range").
- **Comparison alignment.** The previous window has the same number of days
  but may straddle one more calendar bucket; bucket n of the previous period
  pairs with bucket n of the current one counting from each period's start,
  extra trailing buckets are dropped from the paired series only and still count
  in the totals. When `series_truncated` the product says so ("The previous
  period spans more calendar buckets than the current one; the paired series is
  aligned from the start of each period and the extra buckets are omitted
  from the paired series only; they are still included in the totals.").
- `delta.pct` is `null` when the previous value was zero: read as "No
  activity in the previous period", never as infinite or 100 %.
- **Interface-side choices that do not refetch**: which metric of the three
  is shown over time; line vs. bar rendering; cumulative (a running sum —
  meaningless for `average`, which stays as-is).
- Failures (all 400 with a string): `custom range requires both start and
  end`; `start must not be after end`; `invalid start: expected an ISO date
  (YYYY-MM-DD)` (likewise `end`); `unsupported preset: …`; `unsupported
  granularity: …`; `top_limit must be between 1 and 50`; `invalid payment
  method filter: …`; `unknown payment method: N` / `unknown wallet: N`;
  `comparison is not available for an unbounded range`; `no preceding period
  exists before this range`.
- A custom preset with only one date chosen shows "Pick a start and end
  date to see the report" and issues no request.
- Facts an admin reads: the resolved range ("{start} → {end}"), the effective
  granularity, the point count ("{n} points"), five totals with their deltas,
  the series over time (with the paired comparison series when requested), the share
  by method, the per-method breakdown (amount, count, average, share), the
  top payers (name and @username, amount, count), and the revenue-definition
  note. Empty range: "No successful payments in this range".
- Filter state (preset, dates, granularity, methods, wallets, compare, metric,
  display, cumulative) persists while the app is open; "Reset filters"
  restores defaults. A slow response for an abandoned filter combination must
  never paint over a newer one.

### 7.5 User (search, detail, subscriptions)

Search (`searchUsers(q)`): matches full name, Telegram username (a leading
`@` is ignored) or the numeric id, case-insensitively, anywhere in the value;
at most 50 results; an empty query returns nothing and the product says
"Search for a user to manage them." No results: "No user found". The last
query is remembered while the app is open.

```
// search result
id          int
full_name   str|null
username    str|null
```

Detail (`getUser(id)`), 404 `User not found`:

```
id                    int
full_name             str|null
username              str|null
is_admin              bool
is_blocked            bool
is_partner            bool
type_title            str|null    // AppUserType: Normal | Subscribed | Banned
created_date          str|null
balance               float       // sum across ALL wallets (completed rows)
total_paid            float       // completed deposits (money received from this user)
total_renewed         float       // completed renewal spend (reason 20)
total_subscriptions   int         // not deleted
active_subscriptions  int         // status 10
```

Subscriptions of a user (`getUserSubscriptions(id)`):

```
subscriptions: [{
  id, name (panel username), status_id, status_title (English lookup title),
  category_name (friendly), plan_name (friendly), enabled (bool), expiration_date
}]
```

Subscription status vocabulary (`status_id`) — the same as the customer
product's:

| id | name | Persian | meaning |
| --- | --- | --- | --- |
| 10 | Active | فعال | usable |
| 20 | Expired | منقضی | time or data ran out |
| 25 | DisabledByAdmin | غیرفعال (مدیر) | staff switched it off; sticky; only the explicit Enable action clears it |
| 30 | CancelledByUser | لغو‌شده | the customer permanently deleted it (terminal) |
| 40 | DeletedByAdmin | حذف‌شده | staff permanently deleted it (terminal) |
| 50 | NotFoundOnPanel | یافت‌نشد روی پنل | the panel no longer knows the account |

### 7.6 Wallet adjustment (`adjustWallet(user_id, body)`)

```
amount      number     // > 0
reason      str        // ≥ 3 characters after trim; recorded and sent to the user
type        "INCREASE" | "DECREASE"
wallet_id   10 | 20    // Normal | Partner (default 10)
// response
ok          true
new_balance float
```

Rules: a decrease larger than the wallet's balance fails 400 `Insufficient
balance. Current: {X} T, Attempted decrease: {Y} T`. Other failures: 400
`Amount must be positive`, `Type must be INCREASE or DECREASE`, `Reason must be at least 3 characters`, `Invalid wallet`, `Invalid adjustment type`; 404
`User not found`. Consequences: the user's balance and (for increases via
deposit reasons) totals change; the user receives a Telegram message; a report
lands in the financial report topic. Wallet names: 10 «کیف پول اصلی», 20
«کیف پول همکاری». Partner-mistake corrections are the reason wallet 20 exists
here.

### 7.7 Block / partner (`toggleBlock(user_id)`, `togglePartner(user_id)`)

Both flip the current value and answer `{ok, blocked}` / `{ok, partner}`;
404-like failure 400 `User not found`. Blocking sends the user a Telegram
message and posts a report; partner changes do not message the user. A
partner sees partner wallets and partner categories in the customer product.

### 7.8 Purchase for a user

`getPurchaseCategories(user_id)` → `categories: [{id, friendly_name,
plan_count}]` — every category with at least one assigned enabled plan
(not audience-filtered). `getPurchasePlans(user_id, category_id)` →
`plans: [{id, friendly_name, price (discounted), original_price, discount,
transfer_limit (GB, 0 = unlimited), duration (days), is_trial}]` — empty
when the category is outside the user's audience ("the admin sees what the
user would see"); trial plans the user already used are omitted.

`purchaseForUser(user_id, {category_id, plan_id})` → `{ok, subscription_id,
new_balance}`. The confirmation states: "Buy "{plan}" for {price} for this
user?" and the user's balance. Money is debited **first** (commitment credit
then the category's wallet), then the panel account is created; on failure
the debit is refunded exactly and any orphan panel account is removed.
Failures: 404 `User not found`; 400 `Invalid plan or category`; 403 `Plan
not eligible for this user`; 402 `COMMITMENT_REQUIRED`; 402
`INSUFFICIENT_BALANCE` → "The user's balance is not enough"; 502
`Subscription creation failed on panel`; 500 `Failed to record subscription`. Success: "Purchase completed ✅"; the user receives their
subscription by Telegram message and a report is posted.

### 7.9 Subscription (staff detail, live panel data, history)

`getSubscription(id)` (404 `Subscription not found` for missing or deleted):

```
id                  int
name                str        // panel username
user_selected_name  str|null   // the customer's chosen display name
user_id             int
category            str|null   // friendly
plan                str|null   // friendly
status_id           int        // §7.5 vocabulary
is_auto_renew       bool
created_date        str|null
expiration_date     str|null
updated_date        str|null
transfer_limit_gb   float      // 0 = unlimited
duration_days       int
panel_type_id       int|null
panel_name          str|null
subscription_link   str|null
panel: null | {                // live read from the panel
  status              str|null      // the panel's own status word
  data_limit_gb       float|null
  used_traffic_gb     float|null
  remaining_traffic_gb float|null
  used_percent        float|null
  expire_timestamp    int|null
}
not_on_panel        bool       // true when the panel does not know the account (or could not be read)
```

Usage tone thresholds (used everywhere a used-percentage is shown): ≥ 90
critical, ≥ 70 warning, else comfortable.

History (`getSubscriptionHistory(id)`) — newest first, plus a synthetic
`Created` entry at the creation date:

```
items: [{
  id               int|null
  change_type      "Renewal" | "ManualAdjust" | "AdminDisable" | "AdminEnable" | "AdminDelete" | "Created" | other text
  date             str
  actor            { kind: "admin" | "system" | "user", name: str|null, id: int|null }
  reason           str|null
  changes: {
    volume?  { old_gb, new_gb, delta_gb }
    expiry?  { old, new, delta_days: int|null }
    plan?    { old: str|null, new: str|null }
  }
  is_auto_renew    bool
  usage_at_time_gb float|null
  amount           null
}]
```

| change_type | Persian | meaning |
| --- | --- | --- |
| Renewal | تمدید | a paid renewal (by the customer, or automatic — `actor.kind` system) |
| ManualAdjust | تغییر دستی (مدیر) | staff added/removed days or GB |
| AdminDisable | غیرفعال‌سازی | staff disabled |
| AdminEnable | فعال‌سازی | staff re-enabled |
| AdminDelete | حذف اشتراک (مدیر) | staff permanently deleted |
| Created | ایجاد اشتراک | the subscription came into existence |

Actor sentences: admin → "by admin «{name}» ({id})"; user → "by user";
system → "system" (an automatic renewal reads "auto-renewal"). Empty history:
"No history recorded."

**Adjust** (`adjustSubscription(id, {days_delta, gb_delta, reason?})`):

- Both deltas are signed integers/decimals; at least one non-zero.
- Adding days: new expiry = max(current expiry, now) + days. Removing days:
  new expiry = current expiry − days; refused if that is not in the future.
- Data: refused when the subscription is unlimited; the result must be > 0
  and not less than the live used amount.
- Never re-enables an inactive subscription (a disabled one stays disabled).
- Failure codes (400 `{code, message}`), with the sentences: `NO_CHANGE`
  "No change entered."; `UNLIMITED_VOLUME` "This subscription has unlimited
  data; GB cannot be added or removed."; `LIMIT_TOO_LOW` "The resulting data
  limit must be greater than zero."; `BELOW_USAGE` "The resulting limit would
  be less than the user's current usage."; `NO_EXPIRY` "This subscription has no
  expiry date."; `EXPIRY_IN_PAST` "Removing this many days would expire the
  subscription right now; use Disable to stop service instead."
- Other failures: 502 `Could not read subscription from panel`; 409
  `Subscription user not found on panel`; 502 `Failed to apply changes on panel`.
- A live preview before applying: projected data limit (with "⚠️ The final
  limit is less than current usage." when so) and projected expiry (with "⚠️ The
  expiry would land in the past." when so); "No change entered." when both
  deltas are zero.
- Consequences: panel and record updated; a `ManualAdjust` history entry with
  the admin id and reason; the user receives a message; the admin receives a
  confirmation message; a report is posted. The refreshed detail is returned.
- Not offered when `not_on_panel` ("⚠️ This subscription was not found on
  the panel; changes cannot be applied.").

**Disable / enable** (`setSubscriptionStatus(id, {action: "disable"|"enable",
reason?})`): disable sets status 25 on the panel and here; enable sets 10.
Confirmations: "Disable this subscription's service? (no change to
data/time)" / "Enable this subscription?". Failures: 400 `action must be
'disable' or 'enable'`; 502 `Failed to disable on panel` / `Failed to enable on panel`. Consequences: history entry (`AdminDisable`/`AdminEnable`), user
message, admin message, report. Which is offered depends on `status_id`
(25 → enable; otherwise disable).

**Permanent delete** (`deleteSubscription(id, {reason?})`): allowed for
**any** status, including 50 (that is exactly the case staff clean up). See
§11. Result `{ok, code: "DELETED", subscription_id}`. Failures: 404
`Subscription not found`; 409 `{code: ALREADY_DELETED}` → "This subscription
has already been deleted."; 409 `{code: SUBSCRIPTION_BUSY}` → "Another
operation is in progress for this subscription. Wait a moment and try
again."; 502 `{code: PANEL_DELETE_FAILED}` → "Removing the user from the panel
failed; the subscription is unchanged. Try again later."; 500 `{code:
DELETE_FAILED}` → "Deletion did not complete; the error was reported to
admins. Check the subscription's state and retry if needed." (this one must
not promise nothing changed). Success: "Subscription deleted ✅" and the
subscription no longer exists anywhere.

### 7.10 Plan (`listPlans()`, `createPlan()`, `updatePlan()`, `togglePlan()`, `deletePlan()`, `discountAllPlans()`)

```
id                   int
name                 str      // technical code, 2+ chars of [A-Za-z0-9_-], unique (case-insensitive); immutable after creation
friendly_name        str
price                float    // Toman, ≥ 0 (list price)
discount_percentage  float    // 0..100
transfer_limit       float    // GB; 0 = unlimited
duration             int      // days, > 0
is_trial             bool
enabled              bool
visible              bool
```

Rules:

- Final price shown = round(price × (1 − discount/100)); when a discount
  exists the list price is still a fact.
- Data input accepts `50`, `1.5GB`, `200MB` (parsed to GB); invalid → 400
  `Invalid transfer limit (e.g. 50, 1.5GB, 200MB)`.
- Create failures (400 strings): `Plan name must be 2+ chars (letters,
  numbers, - or _)`; `Friendly name is required`; `Price cannot be negative`; `Duration must be positive`; `Discount must be 0-100`; 409 `Plan name already exists`. Update: 404 `Plan not found` plus the same value
  rules.
- Toggle (`field`: `enabled` | `visible` | `trial`) flips and returns
  `{ok, value}`; 400 `Invalid toggle field`.
- Delete is **soft**: the plan is marked deleted, disabled and hidden; it
  disappears from every offer but existing subscriptions still resolve it.
  Confirmation: "Disable this plan?"
- Discount-all (`{discount}` 0..100) sets the discount on every enabled plan;
  returns `{ok, updated}`.
- A trial plan is purchasable once per customer.

### 7.11 Category (staff view, `listCategories()`)

```
id                          int
name                        str        // technical code, 2+ chars of [A-Za-z0-9_-], unique
friendly_name               str
panel_id                    int|null
panel_name                  str|null
template_name               str|null   // the panel account used as a template for provisioning
has_inbounds                bool       // panel access has been resolved (last sync date exists)
enabled                     bool
visible                     bool
selling_active              bool
renew_active                bool
for_partner                 bool
for_normal                  bool
selling_message             str|null   // shown to customers when selling is paused here
renew_message               str|null
renew_strategy_id           int|null   // §7.13
renew_strategy_name         str|null
plan_ids                    [int]
plan_count                  int
purchase_wallet_id          int        // 10 | 20 — the wallet purchases in this category charge
commitment_amount           float      // 0 = no commitment gate
commitment_duration_days    int|null
commitment_grace_days       int
commitment_intro_message    str|null   // template; placeholders below
commitment_renewal_message  str|null
expired_auto_delete_enabled bool
expired_delete_after_days   int        // 1..3650, default 30
```

Health facts a category carries: has a panel (name) / no panel; has a
template (name) / no template; has N plans / none; access resolved / not.
A category cannot sell without a panel, a template, resolved access and at
least one plan.

Actions:

- Create `{name, friendly_name}`: 400 `Category name must be 2+ chars
  (letters, numbers, - or _)`, `Friendly name is required`; 409 `Category name already exists`.
- Rename `{name?, friendly_name?}`: 404 `Category not found`; same name rule.
- Toggle (`field`: `visible` | `enabled` | `selling` | `renew` | `for_partner`
  | `for_normal`) → `{ok, value}`; 400 `Invalid toggle field`.
- Selling-paused message `{message}` and renewal-paused message `{message}`
  (free text; empty means the default sentence is used).
- Renewal strategy `{strategy_id}`: 400 `Invalid strategy`.
- Category discount `{discount}` 0..100 → `{ok, updated}` (applies to the
  category's plans).
- Commitment settings `{purchase_wallet_id?, commitment_amount?,
  commitment_duration_days?, commitment_grace_days?,
  commitment_intro_message?, commitment_renewal_message?}` → `{ok,
  warning: str|null}`. Rules: amount > 0 requires duration > 0 (400
  `CommitmentDurationDays must be positive when CommitmentAmount > 0`);
  `Commitment amount cannot be negative`; `Grace days cannot be negative`;
  `Invalid or disabled wallet`. Warning (not a refusal): "Wallet is
  partner-only but the category is visible to normal users; they will not be
  able to pay in it."
- Expired auto-deletion `{enabled?, delete_after_days?}`: 400
  `delete_after_days must be between 1 and 3650` / `must be an integer`.
  Explanation the product carries: "Expired subscriptions in this category
  are removed from both the panel and the bot this many days after their
  expiry date. The timing is controlled by this category setting, not by the
  notification queue."
- Delete: soft (the category is disabled). Confirmation: "Disable this
  category?"

Commitment message placeholders (typed inside the two templates; an unknown
placeholder renders unchanged and does not break the message):

| placeholder | meaning | example |
| --- | --- | --- |
| `{category_name}` | category friendly name | 🇩🇪 آلمان |
| `{wallet_name}` | the category's purchase wallet | کیف‌پول همکاری |
| `{wallet_balance}` | the customer's balance in that wallet (Toman) | 1,250,000 |
| `{commitment_amount}` | this period's commitment price | 500,000 |
| `{commitment_duration_days}` | commitment length in days | 30 |
| `{commitment_grace_days}` | renewal window after the period ends | 3 |
| renewal-only: `{commitment_remaining}` | unused credit | 120,000 |
| `{commitment_used}` | credit consumed | 380,000 |
| `{grace_days_left}` | days left in the renewal window | 2 |
| `{end_date}` | period end | 2026-05-20 14:30 |
| `{grace_end_date}` | renewal window end | 2026-05-23 14:30 |

Each placeholder must be copyable in one action.

### 7.12 Wallet (`listWallets()`)

```
id             int    // 10 Normal, 20 Partner
title          str    // "Normal" | "Partner"
friendly_name  str    // کیف پول اصلی | کیف پول همکاری
enabled        bool
for_partner    bool
for_normal     bool
```

### 7.13 Renewal strategy (`listRenewStrategies()`)

```
id           int
name         str    // Persian, admin-visible
description  str
```

| id | name | meaning |
| --- | --- | --- |
| 1 | افزودن فقط حجم | add bought data only |
| 2 | افزودن فقط زمان | add bought days only |
| 3 | افزودن حجم و زمان | add both |
| 4 | بازنشانی فقط حجم | reset data (usage resets) |
| 5 | بازنشانی فقط زمان | reset time |
| 6 | بازنشانی حجم و زمان | reset both (default) |
| 7 | افزودن حجم و بازنشانی زمان | add data, reset time |
| 8 | بازنشانی حجم و افزودن زمان | reset data, add time |

### 7.14 General selling and renewal switches (`getGeneral()`, `setGeneral()`)

```
selling_enabled   bool     // GENERAL_SELLING_ENABLED
renew_enabled     bool     // GENERAL_RENEW_ENABLED
selling_message   str      // GENERAL_SELLING_DISABLED_MESSAGE (shown to every customer while off)
renew_message     str      // GENERAL_RENEW_DISABLED_MESSAGE
```

Rule the product states: "These switches control ALL selling/renewal of the
bot." Turning either off is consequential (§11).

### 7.15 Panel (`listPanels()`, `listPanelTypes()`, `createPanel()`, `deletePanel()`)

```
// panel
id              int
url             str        // technical, http(s)
username        str
friendly_name   str
panel_type_id   int        // 1 | 2
panel_type      str        // "Marzban" | "PasarGuard"
category_count  int|null   // categories bound to this panel
// panel_types: [{ id, name ("marzban" | "pasarguard"), friendly_name, description }]
```

Create `{url, username, password, friendly_name, confirm_duplicate,
panel_type_id}`:

- 400 `Panel URL must start with http:// or https://`; `Username, password and friendly name are required`.
- 409 `{code: "duplicate_credentials", existing: [names]}` when a panel with
  the same address and username exists → the product asks "A panel with the
  same address and username exists ({names}). Continue?" and resends with
  `confirm_duplicate: true`.
- The credentials are **verified live** against the panel before the panel is
  kept; failure → 502 `Failed to authenticate with the panel; check URL and credentials` and nothing is saved. Success reads "Saved and connection
  tested ✅".
- The password is never returned.

Delete is soft; 404 `Panel not found`. Confirmation: "Delete this panel?" A
deleted panel leaves its categories without a panel.

Health per panel is §7.3 (on demand).

### 7.16 Template (`listTemplates()`, `renameTemplate()`)

```
template_name    str        // a panel account name used as provisioning template
category_names   str|null   // categories using it (joined text)
panel_name       str|null
```

Rename `{old_name, new_name}` updates every category using the old name and
**clears their resolved access**, so a sync (§7.17) is needed afterwards.
Failures: 400 `Old and new template names are required`; 404 `Template not found`. Result `{ok, updated}`. Empty: "No template is in use".

### 7.17 Connections (`getConnections()`, `connectPanel()`, `connectPlans()`, `setTemplate()`, `syncInbounds()`)

```
categories: [{ id, friendly_name, name, panel_id, panel_name, template_name, has_inbounds, plan_ids, enabled }]
panels:     [panel]                       // §7.15
plans:      [{ id, friendly_name, price }] // enabled plans only
```

- Connect panel `{panel_id, template_name}`: binds a category to a panel and
  a template; 400 `Template name is required`; 404 `Category not found` /
  `Panel not found`. The product refuses locally without a chosen panel
  ("Choose a panel").
- Assign plans `{plan_ids}` (≥ 1; local refusal "Choose at least one
  plan"): 400 `At least one plan is required`, `Unknown plan ids: [...]`;
  result `{ok, assigned_count}`.
- Set template `{template_name}` alone.
- Sync access (`syncInbounds()`): for every category that has a template but
  no resolved access, resolves it through the category's panel; result
  `{ok, synced: [category names], failed: [{category, error}]}`. Sentences:
  "{n} categories synced ✅"; "{ok} succeeded, {fail} failed"; "Everything is
  already in sync ✅".
- Per category facts: "Connected: {panel}" or "Not connected"; "Access ✓"
  when resolved.

### 7.18 Bankcard (`listCards()`, `createCard()`, `updateCard()`, `toggleCard()`, `toggleCardWallet()`, `deleteCard()`)

```
id                    int
holder                str
number                str        // "6037 9911 2233 4455" (16 digits, grouped by 4; copy without spaces)
bank                  str|null   // admin-entered; the product also DERIVES the bank from the first 6 digits (below)
order                 int|null   // display order among bankcards
allowed_transactions  int        // slots per period (0 = none)
duration_days         int        // period length, ≥ 1
min_amount            float
max_amount            float|null // null = no cap
enabled               bool
wallets               [int]      // wallet ids this bankcard serves; new bankcards start with [10]
stats: {
  period_used_count   int        // reserved + approved slots in the current period
  period_used_amount  float      // approved amounts (or requested while reserved) in the current period
  total_count         int        // all-time approved
  total_amount        float
  allowed             int
  duration_days       int
  period_start        str|null
  period_end          str|null   // periods are Tehran-midnight-aligned blocks counted from the bankcard's creation
}
```

Rules:

- Creation/update: holder required (`Card holder name is required`, `Card holder name cannot be empty`); number <!-- lint:ignore --> must contain exactly 16 digits after stripping
  non-digits (`Card number must contain exactly 16 digits`); a max ≤ 0 means <!-- lint:ignore -->
  unlimited; duration is at least 1; `No fields to update` when nothing
  changed. 404 `Card not found`. <!-- lint:ignore -->
- Derived facts: slots used of allowed (a percentage); amount used of cap
  when a cap exists, else "No amount cap"; "Resets in {d} days" / "Resets
  today" from `period_end`.
- Bank derivation from the 6-digit prefix (31 Iranian banks and institutions;
  e.g. 603799 → بانک ملی, 589210 → بانک سپه, 610433/991975 → بانک ملت,
  627353/585983 → بانک تجارت, 603769 → صادرات, 502229/639347 → پاسارگاد,
  622106/627884/639194 → پارسیان, 621986 → سامان, 627412 → اقتصاد نوین,
  603770/639217 → کشاورزی, 628023 → مسکن, 639346 → سینا, 627760 → پست بانک,
  627488/502910 → کارآفرین, 589463 → رفاه کارگران, 627381 → انصار, 639370 →
  مهر اقتصاد, 502806/504706 → شهر, 636214 → آینده, 505416/505426 → گردشگری,
  505785 → ایران زمین, 504172 → رسالت, 502938 → دی, 639607 → سرمایه, 606373 →
  مهر ایران, 606256 → ملل, 505801 → کوثر, 507677 → نور, 636949 → حکمت
  ایرانیان, 585947/505809 → خاورمیانه, 502908 → توسعه تعاون); unknown prefix →
  no bank name. Carry the full mapping from the existing code.
- Toggle enabled (`field: "enabled"`) → `{ok, value}`; toggle a wallet link
  → `{ok, linked}` (400 `Invalid wallet`); delete is soft (disabled and
  hidden). Confirmation: "Delete this bankcard?"
- Saving a bankcard's wallet set is a series of per-wallet toggles for the ones
  that changed.

### 7.19 Bankcard-transfer Safe Mode (`getSafeMode()`, `saveSafeMode()`, `getSafeModeHistory()`)

A rotating minimum deposit amount for the bankcard-transfer method (id 10),
because Iranian banks may flag accounts receiving many transfers of an
identical amount.

```
// status
enabled             bool
strategy            "random" | "ascending_cycle" | "alternating_extremes"
lower_bound         int       // Toman
upper_bound         int
step                int
interval_minutes    int       // 1 .. 129600 (90 days)
current_amount      int|null  // the generated minimum; while disabled it is only "the last generated amount" and is NOT enforced
sequence_position   int
candidate_count     int|null  // (upper − lower) / step + 1
last_run_at         str|null
next_run_at         str|null
updated_at          str|null
updated_by          int|null
manual_base_min     float     // the bankcard-transfer method's manual minimum
binding_maximum     int|null  // lowest real bankcard-transfer maximum in force (null = unlimited)
server_time         str       // for a clock-skew-proof countdown
wallets: [{
  wallet_id               int
  effective_min           float     // what customers are actually charged against in this wallet
  max_amount              float
  has_eligible_card       bool
  differs_from_safe_mode  bool      // a bankcard's own minimum raised the effective minimum above the Safe Mode amount
  manual_min              float|null
}]
```

| strategy | Persian | sequence (always starts at the lower bound) |
| --- | --- | --- |
| random | تصادفی | a uniformly random candidate each rotation; never the same value twice in a row when more than one exists |
| ascending_cycle | افزایشی چرخه‌ای | lower, lower+step, …, upper, lower, … |
| alternating_extremes | یکی از پایین، یکی از بالا | lower, upper, lower+step, upper−step, …; an odd count emits the middle value once per cycle |

Save `{enabled, lower_bound, upper_bound, step, interval_minutes, strategy,
reason}` → `{ok, changed, event: "enabled"|"disabled"|"config_updated"|null,
status}`:

- `reason` is **mandatory** for every save ("Writing a reason for the change
  is mandatory"; server: `reason is required for every Safe Mode change`).
- Whole-toman integers only: `{field} must be a whole toman integer`,
  `cannot be negative`, `is unreasonably large` (> 10¹³); `step must be greater than zero`; `upper_bound must be greater than lower_bound`;
  `upper_bound must be reachable from lower_bound in whole steps ((upper -
  lower) % step == 0)`; `interval_minutes must be a whole number of minutes`,
  `interval_minutes must be between 1 and 129600`; `strategy must be one of: random,
  ascending_cycle, alternating_extremes`; `enabled must be true or false`.
- When enabling, the upper bound must not exceed the binding bankcard-transfer
  maximum: 409 `upper_bound ({upper}) exceeds the effective card-to-card maximum ({max}); raise that maximum or lower the upper bound`. <!-- lint:ignore -->
- 404 `Card Safe Mode is not provisioned (run the database migration first)`. <!-- lint:ignore -->
- Lifecycle facts the product explains: enabling applies the lower bound
  immediately and schedules now + interval; changing bounds/step/strategy/
  interval while enabled restarts the cycle atomically from the lower bound;
  changing while disabled only stores; disabling stops rotations and makes
  the manual minimums effective again at once; a restart does not restart the
  cycle; a missed schedule performs exactly one rotation.
- While enabled: "⚠️ While Safe Mode is enabled, the manual bankcard-transfer
  minimums (base and per wallet) are not applied and become effective again
  immediately after disabling."
- Countdown to `next_run_at` measured on the **server clock** (offset =
  `server_time` − local now), refreshed every second: "{m} minutes and {s}
  seconds left"; overdue → "Waiting to run…"; disabled or none → "Not
  scheduled". Server truth (status and history head) is re-read every 20
  seconds while the person is looking, and stops when they leave.
- Per-wallet readout: effective minimum; "No eligible bankcard" (danger);
  "Higher because of a bankcard minimum" (warning) when `differs_from_safe_mode`;
  "Equal to the Safe Mode amount" when enabled and equal. While disabled: "Safe
  Mode is disabled; the values shown are the current effective minimums
  (manual + bankcard constraints)."
- The current amount reads "Current Safe Mode minimum (Toman)" while
  enabled and "Last generated Safe Mode amount (not applied now)" while
  disabled; "No amount generated yet" when null.

History (`getSafeModeHistory(limit ≤ 50, before_id?)` → `{events, has_more}`):

```
id                int
created_at        str
event_type        "enabled" | "disabled" | "config_updated" | "rotated"
actor_type        "admin" | "system"
admin_user_id     int|null
actor_display     str|null    // "Name (@username)" snapshot
reason            str|null    // admin's reason, or the generated Persian reason for rotations
previous_amount   int|null
new_amount        int|null
old_config        {lower_bound, upper_bound, step, interval_minutes, strategy} | null
new_config        same | null
strategy          str|null
sequence_position int|null
```

Event labels: enabled «فعال‌سازی», disabled «غیرفعال‌سازی», config_updated
«تغییر تنظیمات», rotated «چرخش خودکار»; actor system «سیستم». A config
change lists only the fields that differ (old → new). Newest first; older
batches load by `before_id`; new rotations merge in without losing older loaded
entries. Empty: "No event recorded yet". Generated rotation reason shape:
«چرخش خودکار حالت ایمن — راهبرد «{label}»؛ گام {n} از {count} در چرخه» (or
«انتخاب تصادفی از {count} مقدار مجاز»).

Explanatory texts (eight, Persian, in the current bundle: what Safe Mode is;
bounds; step; interval; strategies; lifecycle; Safe Mode minimum vs effective
minimum; why a reason is mandatory; what history shows) are substance: keep
them reachable from the corresponding decision.

### 7.20 Payment method (`listPaymentMethods()`, `updatePaymentMethod()`, `setMethodWallet()`, `getMethodWalletCashback()`, `toggleMethodWalletCashback()`)

```
id                 int        // 10 CardToCard, 20 Tronado, 30 Crypto, 40 NOWPayments
title              str        // friendly title (editable)
raw_title          str
description        str|null
enabled            bool       // global kill switch
min_amount         float      // base
max_amount         float      // base; 0 = unlimited
lifetime           int        // minutes a payment stays open
wallets: [{                   // per-wallet link; a method is usable for a wallet ONLY when a link exists, is enabled, and the method is enabled
  wallet_id              int
  enabled                bool
  min_amount             float|null   // override; null = base
  max_amount             float|null
  description            str|null
  lifetime               int|null
  effective_min          float        // resolved
  effective_max          float
  effective_description  str|null
  effective_lifetime     int
}]
is_card            bool
is_nowpayments     bool
safe_mode_enabled  bool       // bankcard only
safe_mode_current  int|null   // bankcard only
```

Rules:

- Update `{enabled?, min_amount?, max_amount?, lifetime?, friendly_title?}`:
  400 `min_amount cannot exceed max_amount` (only when max is a real cap),
  `lifetime must be positive`, `{field} cannot be negative`; 404 `Payment method not found`. For the bankcard method, lowering the maximum under an
  enabled Safe Mode's upper bound is refused 409
  `max_amount ({cap}) is below the enabled Card Safe Mode upper bound ({upper}); disable Safe Mode or lower its upper bound first`. <!-- lint:ignore -->
- While Safe Mode is on, the bankcard method's manual minimum is shown as
  overridden: "Safe Mode: {safe} (manual: {manual})" and the warning of
  §7.19 accompanies any edit of it.
- Per-wallet settings `{enabled?, min_amount?, max_amount?, description?,
  lifetime?}`: a **null** clears the override back to base, an omitted field
  keeps the current override; the candidate effective range may never end up
  min > max; the same Safe Mode maximum rule applies when the link is (or
  stays) enabled; 400 `No fields to update`, `Invalid wallet`. Result `{ok,
  wallet}`. Links can be configured while disabled.
- Per method-and-wallet cashback: `conditions: [{id, minimum_amount,
  percentage, enabled, linked}]`; toggling a link creates the wallet link
  (disabled) if it did not exist; result `{ok, linked}`; 404 `Condition not
  found`.
- Facts per method: base min (or the overridden reading), base max or
  "Unlimited", lifetime in minutes, per-wallet availability (linked and
  enabled / not).

### 7.21 Cashback condition (`listCashback()`, `createCashback()`, `updateCashback()`, `toggleCashback()`, `deleteCashback()`)

```
id              int
minimum_amount  float    // deposit must be ≥ this
percentage      float    // 0..100
enabled         bool
```

Rules: 400 `Minimum amount is required` / `cannot be negative`;
`Percentage must be between 0 and 100`; 404 `Condition not found`; toggle
(`field: "enabled"`) → `{ok, value}`; delete soft ("Delete this rule?"). A
condition takes effect only through the method-and-wallet links of §7.20.

### 7.22 Tronado settings (`getPaymentSettings()`, `setTronado()`)

```
tronado_api_key_masked   str|null   // "••••••••1234" or null when unset/placeholder
tronado_wallet_address   str|null   // TRON address, or null when unset/placeholder
```

Set `{api_key?, wallet_address?}`: only non-empty values replace; the
address must be 34 characters starting with `T` (400 `Wallet address must be a TRON address (34 characters starting with 'T')`). The key is never
returned.

### 7.23 NOWPayments settings (`getNowpayments()`, `updateNowpayments()`)

```
fields: [{ key: str, label: str (Persian), kind: "secret" | "bool" | "text" | "decimal" | "int",
           value?: any, has_value?: bool, display?: str|null }]
callbacks: null | { nowpayments: url, tronado: url, health: url }    // resolved from WEBHOOK_PUBLIC_BASE_URL; copyable
```

The thirty keys and kinds, with the current product's grouping as a hint of
meaning (not of placement): credentials `NOWPAYMENTS_API_KEY`,
`NOWPAYMENTS_IPN_SECRET`, `NOWPAYMENTS_SANDBOX_API_KEY`,
`NOWPAYMENTS_SANDBOX_IPN_SECRET` (secret); sandbox `NOWPAYMENTS_SANDBOX_MODE`
(bool), `NOWPAYMENTS_TEST_USER_IDS`, `NOWPAYMENTS_API_BASE_URL`,
`NOWPAYMENTS_SANDBOX_API_BASE_URL` (text); currency and rate
`NOWPAYMENTS_PRICE_CURRENCY`, `NOWPAYMENTS_DEFAULT_PAY_CURRENCY`,
`NOWPAYMENTS_OUTCOME_CURRENCY` (text), `NOWPAYMENTS_FEE_PAID_BY_USER` (bool),
`NOWPAYMENTS_USD_TOMAN_MANUAL_RATE`, `NOWPAYMENTS_USD_TOMAN_RATE_SOURCE`,
`NOWPAYMENTS_USD_TOMAN_RATE_URL` (text), `NOWPAYMENTS_RATE_MARKUP_PERCENT`
(decimal), `NOWPAYMENTS_RATE_CACHE_TTL_SECONDS`,
`NOWPAYMENTS_MIN_AMOUNT_CACHE_TTL_SECONDS`,
`NOWPAYMENTS_HTTP_TIMEOUT_SECONDS` (int); addresses `WEBHOOK_PUBLIC_BASE_URL`,
`NOWPAYMENTS_IPN_CALLBACK_URL`, `NOWPAYMENTS_SUCCESS_URL`,
`NOWPAYMENTS_CANCEL_URL` (text); polling and review
`NOWPAYMENTS_POLLING_ENABLED` (bool), `NOWPAYMENTS_POLLING_INTERVAL_SECONDS`,
`NOWPAYMENTS_POLLING_MAX_AGE_HOURS` (int),
`NOWPAYMENTS_ADMIN_NOTIFICATIONS_ENABLED`,
`NOWPAYMENTS_OVERPAYMENT_ADMIN_REVIEW_ENABLED`,
`NOWPAYMENTS_PARTIAL_PAYMENT_ADMIN_REVIEW_ENABLED` (bool). Labels are the
Persian ones in the existing field definitions.

Rules: a secret shows "Set" (with masked tail) or "Not set" and is replaced
only by entering a non-empty value; bools save immediately; text and numbers
save together on demand as `{updates: {key: value}}` → `{ok, applied:
[keys]}`; unknown keys are ignored. "Saved ✅" when nothing was pending is
still a success.

### 7.24 Payment-method unlock rules (`listPaymentConditions()`, `getPaymentConditionsMeta()`, `getPaymentCondition(id)`, `createPaymentCondition()`, `updatePaymentCondition()`, `togglePaymentCondition()`, `deletePaymentCondition()`, `testPaymentCondition()`)

A rule group gates payment methods behind a customer's history: when the
group's logic is true for a customer, the listed methods are unlocked for
them.

```
// list entry
id               int
name             str
description      str|null
priority         int        // lower is evaluated first
is_active        bool
leaf_count       int
unlocked_methods [{ id, title }]
// meta
methods          [{ id, title, enabled }]
base_method_ids  [int]      // methods currently available to everyone (not gated by any active group)
checks           ["PAYMENT_COUNT","PAYMENT_AMOUNT","USER_AGE","SUBSCRIPTION_COUNT"]
operators        [">=","<=","=",">","<","!="]
// one group
id, name, description, priority, is_active, tree, unlocked_method_ids
// tree
group:     { type: "group", connector: "ALL" | "ANY", children: [node] }
condition: { type: "condition", check: CHECK, payment_type_id: int|null, op: OP, value: number }
```

| check | Persian | meaning | needs a method |
| --- | --- | --- | --- |
| PAYMENT_COUNT | تعداد پرداخت‌های یک روش | number of successful payments through one method | yes |
| PAYMENT_AMOUNT | مجموع مبلغ یک روش | total amount paid through one method | yes |
| USER_AGE | سن حساب (روز) | account age in days | no |
| SUBSCRIPTION_COUNT | تعداد اشتراک‌ها | number of subscriptions | no |

Operators read as words: ≥ "equal or more than", ≤ "equal or less than",
= "equals", > "more than", < "less than", ≠ "not equal to". Connectors:
ALL "all of the following (AND)", ANY "at least one of the following (OR)".
A textual summary of the tree exists ("(check [method] op value و …)").

Rules:

- Save requires a name ("Group name is required"), at least one condition
  ("At least one condition is required"), and at least one method to unlock
  ("Select at least one payment method to unlock"); unknown methods fail
  "Unknown payment method: N"; structural errors come back joined by "; ".
  Nested groups are allowed; single-child groups collapse.
- **Warnings before saving** (not refusals): for each chosen method that is
  currently in `base_method_ids`: "Warning: this method is currently
  available to everyone; this rule will gate it. ({method})". If the choice
  would leave no always-available method: "Serious warning: after this change
  NO always-available method remains; new users may see none."
- Toggle `{is_active}` → `{ok, value}`; delete is hard ("Delete this
  rule?"); 404 `Condition group not found`.
- **Tester** `{user_id, tree, unlocked_method_ids}` evaluates a draft against
  a real user with the production engine: `result: {overall: bool, leaves:
  [{name, check, op, required, actual, passed: bool|null}], would_unlock:
  [ids], user_stats: {payment_counts, payment_amounts, account_age_days,
  subscription_count}}`. Each leaf reads "✓/✗ {check} {op} {required}
  (={actual})"; overall "Unlocked ✅" / "Not unlocked ⛔".

### 7.25 Broadcast (`broadcastTest()`, `broadcast()`)

```
audience         10 | 11 | 12 | 13 | 14
text             str        // Telegram HTML
disable_preview  bool       // suppress link previews
// broadcast result
ok, job_id, recipients (int)
```

| audience | meaning |
| --- | --- |
| 10 | users with an active subscription |
| 11 | users with subscriptions but none active |
| 12 | users who never had a subscription |
| 13 | users with any subscription (any status), excluding those who blocked the bot |
| 14 | all users, excluding those who blocked the bot |

Rules: the text is required ("Message text is required"). **Test first**:
the test sends the exact message to the acting admin immediately ("Test
message sent to you ✅ check it"), surfacing Telegram's HTML rejection as 400
`Test send failed: {reason}`; the product then asks "Saw the test? Send it to
the selected audience now?". A direct send asks "Send this message to the
selected audience?". The send **queues** a notification job (§7.29) and
reports "{n} messages queued ✅"; zero matching recipients marks the job
failed with `No recipients matched audience`. 400 `Invalid audience`. Media
and forwarded broadcasts are bot-only and not part of this product.

### 7.26 Notification rule (`listNotificationRules()`, `createNotificationRule()`, `updateNotificationRule()`, `toggleNotificationRule()`, `deleteNotificationRule()`)

Warnings the monitor sends to customers about their subscriptions.

```
id                     int
name                   str
type                   "TIME_BASED" | "DATA_GB" | "DATA_PERCENT"
messages_text_key      str      // template: subscription_warning_days | subscription_expired | subscription_deleted | subscription_data_gb_warning | subscription_data_percent_warning
enabled                bool
order_id               int
days_before_expiry     int|null   // TIME_BASED: negative = before expiry, 0 = at expiry, positive = after; −365..365
data_threshold_gb      float|null // DATA_GB: > 0 (fires when less than this remains)
data_threshold_percent float|null // DATA_PERCENT: 0..100
renew_deadline_days    int        // default 3
max_sends_per_period   int        // default 1
```

Template labels: warning_days «هشدار نزدیک انقضا», expired «اطلاع انقضا»,
deleted «اطلاع حذف اشتراک», data_gb «هشدار حجم (گیگ)», data_pct «هشدار حجم
(درصد)». Trigger sentence derived from the rule: "{n} days before expiry" /
"at expiry" / "{n} days after expiry" / "less than {n} GB remaining" / "less
than {n}% remaining". The message's call to action: the `subscription_deleted`
template offers "buy a new subscription"; every other one offers "renew this
subscription".

Rules: create needs `rule_name`, `rule_type`, `messages_text_key` and the
threshold for its type (400 `Rule name is required`, `Message key is required`, `Invalid rule_type`, `TIME_BASED rule needs days_before_expiry`,
`days_before_expiry must be an integer`, `days_before_expiry must be between -365 and 365`, `DATA_GB rule needs a positive data_threshold_gb`,
`DATA_PERCENT rule needs data_threshold_percent between 0 and 100`); update
re-validates the effective type; 404 `Rule not found`; toggle → `{ok,
enabled}`; delete is soft ("Delete this rule?"). The product carries this
note: "Automatic deletion of expired subscriptions is controlled per
category, not by these rules. These only send warnings and are checked for
active subscriptions (before/at expiry); the deletion notice is sent when a
category actually deletes."

### 7.27 Join channel, tutorial, ad link, referral

**Channel** (`listChannels()` …): `{id, chat_id (str, numeric Telegram chat
id like -1001234567890), name, public_id (@handle | null), order_number,
active}`. Create needs numeric `chat_id` and `name` (400 `chat_id and name are required`, `chat_id must be a numeric Telegram chat id`; 409 `Channel already exists`); update `{name?, public_id?, order_number?, is_active?}`;
toggle → `{ok, active}`; delete soft ("Delete this channel?"); 404
`Channel not found`.

**Tutorial** (`listTutorials()` …): `{id, button_name, caption, has_media,
media_type, order_id, visible}`. Create `{button_name (required), caption?,
order_id?}` (400 `Button name is required`); update; toggle → `{ok,
visible}`; delete is **hard** ("Delete this tutorial?"); 404 `Tutorial not found`. Media can only be attached from the bot: "No media (add it from the
bot)" vs "Has media".

**Ad link** (`listAds()`, `createAd()`, `getAdReport()`, `deleteAd()`):
`{id, title, uuid (32 hex), url: "https://t.me/{bot}?start=Ads_{uuid}" |
null}`; create `{title}` (400 `Title is required`; 500 `Failed to create link`); report `{user_count, subscription_count, total_deposits (completed
deposits by attributed users), total_subscription_spend (their purchases and
renewals)}`; delete soft ("Delete this link?"). The link is copyable in one
action.

**Referral** (`getReferral()`, `setReferral()`): `{bonus_enabled,
commission_enabled, display_enabled, bonus_amount (Toman), commission_percentage
(0..100)}` backed by `REFERRAL_BONUS_ENABLED`, `REFERRAL_COMMISSION_ENABLED`,
`REFERRAL_DISPLAY_ENABLED`, `INVITE_BONUS_AMOUNT`,
`INVITE_COMMISSION_PERCENTAGE`; 400 `Bonus cannot be negative`, `Commission must be 0-100`. Meaning: bonus = fixed gift to the inviter per invite;
commission = share of the invitee's spending; display = whether customers see
the invite feature.

### 7.28 Bulk adjustment job (`listJobs()` → `{background_jobs: [...], notification_jobs: [...]}`, `getJob()`, `getJobItems()`, `previewJob()`, `runJob()`, `pauseJob()`, `resumeJob()`, `cancelJob()`, `updateJobSettings()`, `deleteJob()`)

```
kind                          "bulk_adjust"
id                            int
title                         str|null
status_id                     10..70
category_id                   int|null
panel_id                      int|null
days_delta                    int
gb_delta                      float
is_dry_run                    bool
reset_usage                   bool
strategy                      "bulk_adjust" | "classic" | null   // how the panel applies it (capability of the panel)
worker_count                  int|null   // inert; parallelism is not implemented
apply_rate_limit_per_sec      int|null
notify_users                  bool
notification_type_id          int|null   // default 30 (System Alert)
notification_message_template str|null   // Telegram HTML with placeholders
notification_job_id           int|null
filter_snapshot               object     // the targeting used (frozen)
total, applied, failed, skipped   int
progress                      float      // (applied+failed+skipped)/total × 100, one decimal
created_at, started_at, completed_at   str|null
last_error                    str|null
```

| status_id | name | Persian | meaning |
| --- | --- | --- | --- |
| 10 | Draft | پیش‌نویس | preview snapshot built; nothing applied |
| 20 | Queued | در صف | promoted from a frozen snapshot; awaiting the runner |
| 30 | Running | در حال اجرا | applying items (in flight) |
| 40 | Paused | متوقف | stopped claiming new items; in-flight ones finish |
| 50 | Completed | کامل شد | every item reached a terminal state |
| 60 | Failed | ناموفق | unrecoverable error; resumable after the cause is fixed |
| 70 | Cancelled | لغو شد | admin stopped it; applied items kept, remaining cancelled |

Items (`getJobItems(id, limit=100, offset=0)`):

```
id, subscription_id, subscription_name, user_id,
item_status_id       10 Pending | 20 Applied | 30 Failed | 40 Skipped | 50 Cancelled
old_expiration, new_expiration, skip_reason (e.g. "no-expiration"), error, applied_at
```

| item status | Persian |
| --- | --- |
| 10 Pending | در صف |
| 20 Applied | اعمال شد |
| 30 Failed | ناموفق |
| 40 Skipped | رد شد |
| 50 Cancelled | لغو شد |

**Which actions are legal in which state**: Run — Draft only. Pause — Queued
or Running. Resume — Paused. Cancel — Queued, Running or Paused. Edit
settings (rate limit, notify, template) — Draft, Queued or Paused. Delete —
Draft, Completed, Failed or Cancelled. Confirmations: run "Run this job over
the matched subscriptions?"; cancel "Cancel this job? Remaining items will
not be applied."; delete "Delete this job?".

**Preview / create** (`previewJob(body)` creates a **Draft** with a frozen
snapshot and returns what it would do):

```
title?                 str
category_id            int
days_delta             int        // signed
gb_delta               float      // signed
reset_usage            bool
status_ids             [10] + [20 if include expired] + [25 if include admin-disabled]
include_disabled       bool
require_expiration     bool       // forced true whenever days_delta ≠ 0
active_window_start, active_window_end   str|null   // accepted by the API; the current interface never sets them
expire_after, expire_before   "YYYY-MM-DDT00:00:00" / "T23:59:59" | null   // compared in UTC
subscription_ids, user_ids    [int] | null    // narrow scope (comma-separated free text, digits only)
apply_rate_limit_per_sec      int|null
notify_users                  bool
notification_message_template str|null
// response
job_id, panel_id, strategy, matched_count, is_dry_run,
sample: [{ subscription_id, subscription_name, user_id, old_expiration, new_expiration, old_limit_gb, new_limit_gb, skip_reason }]  // first 5
unknown_placeholders: [str]
```

Rules:

- At least one of days, GB or reset-usage must be set ("Nothing to apply";
  400 `{code: "no_change"}`); a category must be chosen ("Select a
  category"); 404 `category not found`; 409 `category has no panel`; 400
  `invalid gb_delta`. A days change forces the targeting to subscriptions
  that have an expiry.
- Known placeholders for the notification template: `{subscription_name}`,
  `{user_id}`, `{full_name}`, `{category_name}`, `{brand_name}`,
  `{old_expire_date}`, `{new_expire_date}`, `{days_added}`, `{days_removed}`,
  `{days_delta}`, `{gb_added}`, `{gb_removed}`, `{gb_delta}`,
  `{old_data_limit_gb}`, `{new_data_limit_gb}`. Unknown ones are reported
  while typing ("Unknown variable: {x}") and block running (400 `{code:
  "unknown_placeholders", placeholders}`).
- A preview that is not run should be **discarded** (deleted) when the person
  leaves or replaces it; running consumes it.
- Run: 409 `{code: not_draft}`; 409 `{code: active_job_exists, message:
  "another job is already active"}` (only one Queued/Running/Paused job at a
  time); 409 `{code: transition_failed}`. Pause/resume/cancel: 409 `{code: "pause_failed" | "resume_failed" | "cancel_failed", message: "job is not in a state that allows {action}"}`.
  Settings: 400 `{code: no_fields}`; 409 `{code: not_editable, message: "job
  settings are locked in its current state"}`. Delete: 409 `{code:
  not_deletable, message: "active jobs cannot be deleted; cancel first"}`.
  404 `Job not found`.
- The runner is a separate process (or the manual trigger of §7.31); a
  Queued job waits until one picks it up. Dry-run jobs are never executed by
  the runner; the preview is the inspection tool.
- Sample line shape: "{name}: {old expiry} → {new expiry}; {old GB} → {new
  GB} GB · {skip reason}".

**Notification job** (`kind: "notification"`, read-only):

```
id, title (JobType text, e.g. "broadcast_send", "bulk_adjust_12"),
status_text ("pending" | "failed" | other), total, sent, progress, created_at, completed_at, fail_reason
```

Not drillable; no per-item view.

### 7.29 System status, reports, manual triggers, backup

`getSystemStatus()` → `{setup: {complete, completed_count, total_count,
progress_percentage} | null, report_group_ready: bool|null}` ("Ready" /
"Not set up").

`runDailyReport({period: "today"|"yesterday"} | {date: "YYYY-MM-DD"})` →
`{ok, sent: bool, date}`: sends one day's activity report to the report
group ("Report {date} sent ✅" / "Report {date} had no data"); 400 `Date must be YYYY-MM-DD`, `Provide a date or period`, `Date cannot be in the future`; 500 `Failed to generate report`.

`runJobRunnerNow()` → `{started: true, job_id}` ("Job run started ✅ — watch
progress in the jobs area") or `{started: false, message: "no_runnable_job"}`
("No queued job to run"); 409 `{code: already_running}`. Drains the runnable
queue in the background, independent of the bot's scheduler.

`runNotificationDispatcherNow()` → `{started: true, pending: n}` ("Dispatch
started for {n} notifications ✅") or `{started: false, message:
"no_pending_notifications"}` ("No queued notification to send"); 409
`{code: already_running}`.

`runBackup()` → `{ok}` after completion (may take a while; the product warns
"It creates a backup and uploads it to the report group. This may take a
moment." and confirms "Run the backup now?"); 500 `Backup failed`.

### 7.30 First-run setup

`getSetupStatus()`:

```
steps: [{ step: 1..7, name: str (Persian), completed: bool }]
completed_count, total_count (7), progress_percentage, setup_complete
```

| step | name | complete when |
| --- | --- | --- |
| 1 | تنظیم گروه گزارش تلگرام | a report group is finished (all nine topics created) |
| 2 | ساخت کتگوری | at least one enabled category |
| 3 | تنظیم پنل مرزبان | at least one non-deleted panel |
| 4 | تنظیم نام قالب | at least one enabled category with a template name |
| 5 | تنظیمات روش پرداخت | the admin marked payment setup complete (`PAYMENT_METHODS_SETUP_COMPLETE`) |
| 6 | تنظیم پلن اشتراک | at least one enabled plan |
| 7 | تنظیمات اتصالات | at least one enabled category with a panel, a template and an enabled plan |

Rules: progress reads "{done} of {total} steps completed"; when complete:
"🎉 Setup complete! All steps are done and the bot is ready to use." The
first incomplete step is where a person wants to start.

**Report group** (`getReportGroupStatus()` → `{finished, group_name, code}`;
`getReportGroupCode({regenerate})` → `{code}`): instructions the product
gives: create a supergroup with Topics; add the bot as a full admin with
topic management; send the code inside the group; after the topics are
created, check status. Facts: the code (copyable), "New code" regenerates
it; 409 `Report group already set up`. When finished: "Report group set up
✅: {group_name}". The nine topics the bot creates: new subscriptions,
renewals, financial, one-time subscriptions, nightly report, cron report,
database backup, other, error report.

**Categories** (setup shape: `{id, name, friendly_name, template_name,
panel_id, panel_friendly_name, has_inbounds, plan_ids}`), create and delete as
in §7.11 (setup sentences: "Category created ✅", "Category deleted";
"No category created yet").

**Panels** as in §7.15 ("Panel saved and connection tested ✅"; "No panel
registered yet"; "Enter panel details; after saving, the connection and login
are tested automatically").

**Templates**: per category `{template_name}` ("Template name saved ✅";
prerequisite "Create categories in step 2 first"). Explanation: "For each
category enter the template user's name on the panel; new users are created
with the same access."

**Payments** (`getSetupPayments()`):

```
card_number, card_holder_name, card_configured (bool)     // the legacy single bankcard setting used by the setup flow
tronado_api_key_masked, tronado_wallet_address, tronado_configured (bool)
setup_complete (bool)
methods: [{ id, title, enabled, min_amount, max_amount, lifetime }]
```

Actions: `setSetupCard({card_number?, card_holder_name?})` (16 digits; `Bankcard
holder name cannot be empty`; local refusal "Enter a bankcard number or holder
name"); `setSetupTronado({api_key?, wallet_address?})` (`API key cannot be empty`; TRON shape); `setSetupMethod(id, {is_enabled, min_amount, max_amount,
lifetime})` (same rules as §7.20 including the Safe Mode maximum guard);
`completeSetupPayments()` → 400 `Enable at least one payment method before completing this step`; sentences "Bankcard details saved ✅", "Tronado settings
saved ✅", "Payment method updated ✅", "Payment step completed ✅", "✅ This
step is complete". "Configured" / "Incomplete" per provider.

**Plans**: as §7.10 (only enabled plans listed; "Plan created ✅"; "No plan
created yet"; trial: "is a trial (free) plan").

**Connections**: as §7.17 with the same three actions and the sync ("Panel
connected to category ✅", "Plans saved ✅"). Prerequisite "Create categories
in step 2 first".

### 7.31 Settings keys this product edits

`GENERAL_SELLING_ENABLED`, `GENERAL_RENEW_ENABLED`,
`GENERAL_SELLING_DISABLED_MESSAGE`, `GENERAL_RENEW_DISABLED_MESSAGE` (§7.14);
`REFERRAL_*` and `INVITE_*` (§7.27); the thirty NOWPayments keys and
`WEBHOOK_PUBLIC_BASE_URL` (§7.23); Tronado key and address (§7.22);
`PAYMENT_METHODS_SETUP_COMPLETE` (§7.30).

---

## §8 What a bot admin must be able to do

Each item points into §7 for the facts and rules.

1. **See at a glance how the business is doing**: users total and blocked,
   subscriptions active and total, plans and categories count, today's
   received money with its by-method split and top five payers, today's
   sales and renewals counts, and the setup completion state (§7.2). Reach
   the full revenue analysis and every management area from there.
2. **Check panel health on demand** and read, per panel, online/offline,
   active users and version (§7.3).
3. **Analyse revenue** over any preset or custom range, at any granularity,
   narrowed by payment method and wallet, with a previous-period comparison
   and its deltas; switch the metric, the rendering and cumulative mode
   without refetching; read the by-method share and breakdown and the top
   payers; understand every refusal (range too large, comparison unavailable)
   where they can act on it (§7.4).
4. **Find a user** by id, username or name; read their flags, balances and
   totals; **adjust a wallet** up or down with a reason and a wallet choice
   and see the new balance; **block/unblock**; **grant/revoke partner**;
   **buy a plan for them** seeing what they would see; and **reach any of
   their subscriptions** (§7.5–§7.8).
5. **Manage one subscription**: read its record and its live panel state
   (used, remaining, percentage, panel status), or that the panel does not
   know it; **add/remove days and GB** with a live projection and every
   guard-rail sentence; **disable/enable**; **read the full change history**
   with actor, changes and reasons; **delete permanently** after the four-line
   warning (§7.9, §11).
6. **Manage plans**: create, edit, toggle enabled/visible/trial, soft-delete,
   and apply one discount to every enabled plan (§7.10).
7. **Manage categories**: create, rename, toggle enabled/visible/selling/
   renewal/partner/normal, set the renewal strategy, the paused messages, a
   category-wide discount, the purchase wallet and commitment terms (with the
   placeholder reference at hand), the expired auto-deletion window; read each
   category's health (panel, template, plans, access); soft-delete (§7.11).
8. **Stop or resume all selling or all renewals** with one switch each and
   edit the sentences customers read meanwhile (§7.14, §11).
9. **Manage panels**: add one of a selectable type with live credential
   verification and duplicate detection; delete; see how many categories use
   each (§7.15). **Rename a template** across categories knowing access must
   be re-synced (§7.16). **Connect** each category to a panel and template,
   choose its plans, and **sync access** (§7.17).
10. **Manage bank bankcards**: create/edit with holder, number, bank, per-period
    slot and amount caps, wallet scope; see the derived bank, slots used,
    amount used, period reset; toggle; soft-delete (§7.18).
11. **Operate Safe Mode**: read whether it is on, the current generated
    minimum, the bounds, step, interval, strategy, candidate count, next
    rotation with a live countdown, and per-wallet effective minimums with
    why they differ; change any of it with a mandatory reason; read the audit
    history including automatic rotations, and load older entries; reach the
    explanations (§7.19, §11).
12. **Configure payment methods**: enable/disable each, edit title, base
    min/max/lifetime, and per-wallet availability with overrides that fall
    back to base; link cashback rules per method-and-wallet; understand when
    Safe Mode overrides the bankcard minimum (§7.20, §7.21).
13. **Configure Tronado and NOWPayments** without ever seeing a stored
    secret, and copy the resolved webhook addresses (§7.22, §7.23).
14. **Author unlock rules** as nested AND/OR logic over four checks, choose
    which methods they unlock, be warned before gating an always-available
    method, **test a draft against a real user** and read every leaf's
    actual vs required, set priority, toggle, delete (§7.24).
15. **Broadcast** to one of five audiences with HTML, test on themselves
    first, and see how many were queued (§7.25).
16. **Manage warning rules** by type with the right threshold input for the
    type, order, template, deadline and send cap, and know what deletion is
    and is not controlled by (§7.26).
17. **Manage join channels, tutorials, ad links (with attribution reports
    and a copyable deep link), and referral rewards** (§7.27).
18. **Create a bulk job**: choose a category, signed day and GB deltas,
    reset usage, targeting (statuses, expiry window, explicit ids), rate
    limit, optional user notification with a placeholder-checked template;
    **preview** the matched count and a before/after sample; **run**,
    **pause**, **resume**, **cancel**, **edit settings**, **delete**; watch
    counters and progress advance; inspect items with their statuses, expiry
    changes, skip reasons and errors; discard an unused preview (§7.28).
19. **See the notification job queue** with sent/total and failure reasons
    (§7.28).
20. **Operate the system**: send a day's report, run a backup, kick the job
    runner or the dispatcher, read setup and report-group readiness (§7.29).
21. **Complete first-run setup** step by step with each step's prerequisites,
    instructions, actions and completion state, and come back to it later
    (§7.30).
22. **Be told, not stranded,** whenever identity is invalid, the server is
    unreachable, a panel is unreachable, or a fetch fails (§16).

---

## §9 What the first-run setup must let the same admin do

The setup is the same admin and the same facts, framed as a guided
completion of seven states (§7.30). It must:

1. Show which of the seven steps are complete and the overall progress, and
   make the first incomplete one the natural starting point.
2. For the report group: give the four instructions, hand over a copyable
   code, regenerate it, and re-check completion.
3. For categories, panels, templates, plans and connections: the same
   create/delete/connect actions as §8 with the setup sentences, plus the
   prerequisite sentences when an earlier step is empty.
4. For payments: configure the legacy single bankcard and Tronado, edit each
   method's enabled/min/max/lifetime, and mark the step complete only once at
   least one method is enabled.
5. Announce completion once all seven are done.

---

## §10 Long-running work: how progress must be conveyed

Every process listed here has a fixed truth to convey: where it is now, whether it
is alive or terminal, what it is waiting for, why it failed, what has
happened so far, and what can be done now. Progress must advance without a
manual refresh while the person is looking, and refreshing must stop at a
terminal state.

- **Bulk job** (§7.28). Where it is: status 10–70 and counters
  applied/failed/skipped of total, progress percentage. Alive states: Queued
  (waiting for a runner — waiting, not working), Running (working), Paused
  (waiting for the admin). Terminal: Completed, Failed (with `last_error`),
  Cancelled. What has happened so far: the item set with per-item status,
  old → new expiry, skip reason or error, applied time. What can be done now:
  exactly the legal actions for the state (§7.28), and "cancel first" when
  deletion is refused. The old interface required a manual refresh; the
  rebuild refreshes a Queued/Running/Paused job and its items automatically
  (every few seconds) and stops once terminal. Any failed item makes the
  progress read as impaired, not merely incomplete.
- **Notification job**. sent of total, status text, failure reason; refresh
  while pending; stop when completed/failed.
- **Safe Mode rotation** (§7.19). A countdown to the next rotation that
  counts every second on the server clock; the current amount and the history
  head re-read every 20 seconds; a rotation arriving is visible without a
  reload. Stops when the person leaves.
- **Panel health** (§7.3). On demand; per-panel resolution can take seconds;
  the person must see that it is checking, then each panel's answer.
- **Manual runner / dispatcher** (§7.29). Return immediately with "started"
  and a pointer to where progress is visible (the jobs area / the report
  topic); a second trigger while one runs is refused `already_running`.
- **Backup** (§7.29). Awaits completion; must say it may take a while and
  must not be re-triggerable meanwhile.
- **Writes that call a panel** (create panel, adjust, disable/enable,
  delete, purchase, sync): the triggering control is un-retriggerable until
  the answer; the working state is visible; the outcome replaces it (success
  sentence and refreshed facts, or the failure sentence and the control
  restored).
- **Revenue queries** (§7.4). A newer request always wins; a slow response
  for an abandoned filter never paints.

No attempt counts, cancel windows or event streams exist beyond what is
listed. Never show a working state that cannot end.

---

## §11 Destructive and irreversible actions

Each of these must be impossible to trigger in a single unconfirmed action,
must state its consequence in specific words first, and must offer a way out
before its point of no return.

1. **Permanently delete a subscription** (§7.9). Consequence, verbatim
   meaning: "Permanently delete "{name}"? • This deletion is permanent and
   cannot be undone. • The user of this subscription is removed from the panel
   and from the bot's database. • After deletion this subscription cannot be
   recovered or renewed. • No money is refunded and the user's wallet is not
   credited. The plan and category remain available for future purchases."
   Persian original in the bundle. Point of no return: the confirmed request.
   A refused deletion (busy, already deleted, panel failure) leaves everything
   as it was and says so; the generic failure must not promise that. After:
   an `AdminDelete` history entry, a user message, a report; the subscription
   ceases to exist here.
2. **Decrease a wallet** (§7.6). Money leaves a customer's balance; requires
   a written reason (≥ 3 characters); refused when the balance is short. The
   after-the-fact record is the user's message and the financial report.
3. **Block a user** (§7.7). The customer is told. Reversible by unblocking,
   but consequential.
4. **Turn off all selling / all renewals** (§7.14). Every customer is
   affected at once; the message they read is editable here.
5. **Gate an always-available payment method** (§7.24). Warned, not
   refused; the "no always-available method remains" case is a serious
   warning.
6. **Change Safe Mode** (§7.19). Requires a written reason every time;
   enabling changes what every customer is charged against immediately;
   recorded in the audit history with before/after.
7. **Run a bulk job** (§7.28). Changes many panel accounts; must be preceded
   by a preview; only one active job at a time; cancellation keeps what was
   applied and abandons the rest; deletion of an active job is refused.
8. **Broadcast** (§7.25). Reaches an entire audience; the test-first flow is
   the guard.
9. **Delete a panel** (§7.15). Its categories lose their panel and stop
   selling.
10. **Rename a template** (§7.16). Clears resolved access for every category
    using it until re-synced.
11. **Soft deletions** (plan, category, bankcard, cashback rule, warning rule,
    channel, ad link, job) and **hard deletions** (tutorial, unlock rule
    group): each has a confirmation naming the thing; the soft ones read as
    "disable" where the product says so.
12. **Discount-all and category discount** (§7.10, §7.11). Rewrite the
    discount of many plans at once.

---

## §12 The message vocabulary

Every sentence a person can read. Wording may be improved; meaning must
survive; the Persian in the current bundle is the source. Enum labels are in
§7 and not repeated.

**Shell / access**
- "Loading…", "Back", "Save", "Cancel", "Delete", "Confirm", "Edit", "Add",
  "Close", "Retry", "Refresh", "Saved ✅", "Deleted", "Server error",
  "Enabled", "Disabled", "Yes", "No", "—", "Unlimited", "Toman", "Nothing
  here yet"
- "Invalid Telegram auth; reopen the app.", "Access denied", "This area is
  for bot admins only.", "This only works inside Telegram."
- "Copied ✅", "Could not copy", "Copying is not supported here"

**Overview**
- "Overview", "Total users", "Blocked", "Active subs", "Total subs",
  "Categories", "Plans", "Panels", "Today's performance", "Today's revenue",
  "Payments", "Sales", "Renewals", "By payment method", "Top users today",
  "Balance", "No payments recorded today", "Full revenue analytics", "Setup
  progress", "Setup complete 🎉", "{done} of {total} steps", "Quick access",
  "Panel health", "Online", "Offline", "active users", "Check panel health"
- Area names and one-line meanings the product uses: Revenue "Revenue
  analytics, trends and top payers"; Users "Search, wallet, block,
  buy-for-user"; Payments "Bankcard-transfer, crypto, methods, cashback"; Catalog
  "Plans, categories, selling, discounts"; Panels "Panels, templates,
  connections"; Marketing "Broadcast, notifications, channels, referral";
  Activity "Bulk-adjust jobs and notification dispatch status"; System
  "Reports, backups, setup status"

**Revenue**
- "Revenue analytics", "Range & filters", "Date range", "Reset filters",
  presets "Today / Yesterday / This week / Last 7 days / This month / Last 30
  days / Last 90 days / This year / All time / Custom", "From", "To", "Pick a
  start and end date to see the report", the range-too-large sentence,
  "Granularity" with "Auto / Hourly / Daily / Weekly / Monthly", the
  granularity-adjusted sentence, "Metric" with "Revenue / Payments / Average
  payment", "Display" with "Line / Bar", "Cumulative", "Compare to previous
  period", "Comparison is unavailable for the all-time range", "Previous
  period", the comparison-truncated sentence, "Payment method", "Wallet",
  "All", "Total revenue", "Successful payments", "Average payment", "Unique
  payers", "Largest payment", "No activity in the previous period", "Revenue
  trend", "Payment method share", "Payment method breakdown", "Top payers",
  "Method", "Amount", "Count", "Average", "Share", "User", "Time", "No
  successful payments in this range", "{start} → {end}", "{n} points", the
  revenue-definition note

**Users**
- "ID, username or name…", "Search", "Search for a user to manage them.",
  "No user found", "Back to search", "ID", "Balance", "Total paid", "Total
  renewed", "Subscriptions", "active", "Admin", "Blocked", "Partner", "Adjust
  balance", "Wallet", "Normal wallet", "Partner wallet", "Increase",
  "Decrease", "Amount (Toman)", "Reason", "Block", "Unblock", "Make partner",
  "Remove partner", "View subscriptions", "Buy for user", "Choose category",
  "Choose plan", "Confirm purchase", "Buy "{plan}" for {price} for this
  user?", "Purchase completed ✅", "The user's balance is not enough", "{n}
  plans", "days"; the server sentences of §7.6–§7.8

**Subscription**
- "Manage subscription", "User", "Category", "Plan", "Status", "Auto
  renew", "Created", "Expires", "Total data", "Used", "Remaining", "Panel
  status", the not-on-panel sentence, "Add / remove GB & days", "Disable",
  "Enable", "History", "Days (time)", "GB (data)", "Add", "Remove", "Reason
  (optional)", "Apply change", "Preview", "No change entered.", "Final data",
  "Final expiry", the two ⚠️ projection warnings, the disable/enable
  confirmations, "Delete subscription", the §11 deletion text, "Subscription
  deleted ✅", the five deletion failure sentences, "Done ✅", "No history
  recorded.", "by admin", "auto-renewal", "by user", "system", "Data",
  "Expiry", "Plan", "Usage at that time", "Reason", "GB", "days"; the six
  adjustment refusal sentences

**Catalog**
- Plans: "Add plan", "New plan", "Edit plan", "Technical name", "Display
  name", "Price (Toman)", "Data (e.g. 50 or 1.5GB)", "Duration (days)",
  "Discount (%)", "Trial plan", "Enabled", "Visible", "Trial", "GB", "days",
  "off", "Bulk discount", "Apply to all enabled plans", "Disable this plan?",
  "Apply"
- Categories: "Add category", "New category", "Manage", "Panel", "Template",
  "{n} plans", "Access", "No panel", "No template", "Selling", "Renewal",
  "Renewal strategy", "Selling-paused message", "Renewal-paused message",
  "Discount for this category", "Disable this category?", "Partner", "Normal",
  "Save message", "Category wallet & commitment", "Category purchase
  wallet", "Commitment amount (Toman — 0 = no commitment)", "Commitment
  duration (days)", "Renewal grace (days)", "Commitment intro message (empty
  = default)", "Commitment renewal message (empty = default)", "See available
  variables", "Intro message variables", "Renewal message variables", "You can
  write these variables inside the message; they are replaced with real
  values when shown to the user. Tap a variable to copy it.", "Shared
  variables (both messages)", "Renewal message only (renewal-window time)",
  "Example", "If a variable is wrong or unknown, the message is not broken;
  that variable is shown unchanged.", "Expired subscription auto-deletion",
  "Auto-delete enabled", "Delete after (days from expiry)", the auto-deletion
  explanation, the partner-wallet warning
- General: "General selling", "General renewal", "These switches control
  all selling/renewal of the bot."

**Panels / templates / connections**
- "Add panel", "Panel type", "Panel address", "Username", "Password",
  "Display name", "Save & test connection", "Delete this panel?", "{n}
  categories", the duplicate question, "Online", "Offline", "active users",
  "No template is in use", "Template", "Used in", "Rename", "New template
  name", "Connected", "Not connected", "Access ✓", "Choose a panel…",
  "Template name", "Connect panel", "Save plans", "Sync access", the three
  sync outcome sentences, "Choose a panel", "Choose at least one plan",
  "Panel", "Plans"; the server sentences of §7.15–§7.17

**Payments**
- Bankcards: "Add bankcard", "Bankcard holder", "Bankcard number", "Bank name (optional)",
  "Allowed per period", "Period (days)", "Minimum amount", "Maximum amount (0
  = no cap)", "Display order", "Wallets", "No amount cap", "Transactions this
  period", "Amount this period", "Total approved", "Resets in {d} days",
  "Resets today", "Edit bankcard", "New bankcard", "Delete this bankcard?", "Bankcard number
  must be 16 digits"
- Methods: "Payment methods", "Minimum amount", "Maximum amount", "Payment
  window (minutes)", "Display title", "Enabled for this wallet", "Minimum
  amount (empty = base)", "Maximum amount (empty = base)", "Description (empty
  = base)", "Payment window minutes (empty = base)", "Cashback rules",
  "Linked", "Not linked"
- Cashback: "Add rule", "Minimum amount", "Cashback percent", "Delete this
  rule?"
- Crypto: "Tronado (TRON)", "NOWPayments", "API key", "Enter to change",
  "TRON wallet address", "Save settings", "Webhook addresses", "Copy", "Set",
  "Not set", groups "Keys / Sandbox / Currency & rate / Addresses / Polling &
  notifications / Other"
- Safe Mode: "Bankcard-transfer Safe Mode", "Safe Mode settings", "Current Safe
  Mode minimum (Toman)", "Last generated Safe Mode amount (not applied now)",
  the disabled-wallets note, "Lower bound", "Upper bound", "Step", "Interval
  (minutes)", "Strategy", "Next rotation", "Last rotation", "Last change",
  "Rotation interval (minutes)", "Lower bound (Toman)", "Upper bound
  (Toman)", "Step / gap (Toman)", "Amount range", "{n} allowed values",
  "Reason for the change (required)", "e.g. reducing the risk of same-amount
  deposits", "Writing a reason for the change is mandatory", "Save Safe
  Mode", "Safe Mode enabled", "{m} minutes and {s} seconds left", "Waiting to
  run…", "Not scheduled", "No amount generated yet", the three strategy
  names, "Effective minimum per wallet", "Effective minimum", "No eligible
  bankcard", "Higher because of a bankcard minimum", "Equal to the Safe Mode amount",
  the manual-overridden warning, "Safe Mode: {safe} (manual: {manual})",
  "Change history", "No event recorded yet", "More", the four event labels,
  "System", "Help", and the eight explanation titles with their texts; the
  server sentences of §7.19
- Unlock rules: "Payment-method unlock rules", "New rule", "Rule name",
  "Description (optional)", "Active", "Methods this unlocks", "ALL of (AND)",
  "ANY of (OR)", "Add condition", "Add sub-group", "Check", "Method",
  "Operator", "Value", "Summary", "Test against a user", "Telegram user ID",
  "Run", "Unlocked ✅", "Not unlocked ⛔", "Save", "Delete", "Edit", "Delete
  this rule?", "Saved", "Deleted", "Priority", "Lower number is evaluated
  first", "Advanced", "Conditions", "Select at least one method to unlock",
  the two base-method warnings, the four check labels, the six operator
  words

**Marketing**
- Broadcast: "Audience" with the five audience labels ("Has an active
  subscription / Has an inactive subscription / No purchase ever / Has any
  subscription / All users"), "Message text", "Send broadcast", "Test (send to
  me)", "Test message sent to you ✅ check it", "Saw the test? Send it to the
  selected audience now?", "Hide link preview", "Send this message to the
  selected audience?", "{n} messages queued ✅"
- Rules: "Enabled", "Order", "Days to expiry", "Data threshold (GB)",
  "Percent threshold", "Renewal deadline (days)", "Max sends", "Delete this
  rule?", "Add notification", "New notification", "Edit notification",
  "Notification name", "Type" with "Time-based (by expiry) / Data (GB) / Data
  (percent)", "When" with "Before expiry / At expiry / After expiry",
  "Value", "Number of days", "Threshold (GB)", "Threshold (percent)", "Message
  template", "Trigger", "Message action", "Renew this subscription", "Buy a
  new subscription", the trigger sentences, the five template labels, the
  deletion note
- Channels: "Add channel", "Numeric chat ID", "Name", "Public ID (@…)",
  "Active", "Delete this channel?"
- Tutorials: "Add tutorial", "Button name", "Caption", "Visible", "Has
  media", "No media (add it from the bot)", "Delete this tutorial?"
- Ads: "Create ad link", "Title", "Report", "Users", "Subscriptions",
  "Deposits", "Spent", "Delete this link?", "Copy"
- Referral: "Invite gift system", "Commission system", "Show invite
  button", "Gift amount (Toman)", "Commission percent", "Save"

**Jobs**
- "Bulk jobs", "Notification jobs", "No bulk jobs recorded.", "No
  notification jobs recorded.", "Back", "Refresh", "Job detail", "ID",
  "Kind", "Status", "Category", "Panel", "Days delta", "GB delta", "Dry run",
  "Total", "Applied", "Failed", "Skipped", "Progress", "Created", "Started",
  "Completed", "Last error", "Sent", "Fail reason", "Items", "Subscription",
  "User", "Old expiry", "New expiry", "Skip reason", "Error", "Applied at",
  "No items found.", "Bulk adjust", "Notification", "Untitled", "New job",
  "Run", "Pause", "Resume", "Cancel", "Delete", "Edit settings", the three
  confirmations, "Done", "Edit job settings", "Workers", "Apply rate limit
  (per sec)", "Notify users", "Message (HTML)", "New bulk job", "Category",
  "Title (optional)", "Data delta (GB)", "Add", "Remove", "days", "GB", "Reset
  usage", "Targeting", "Include expired", "Include admin-disabled",
  "Notification", "Available variables", "Unknown variable", "Preview", "Run
  job", "Discard", "Matched", "Strategy", "Sample (before → after)", "Nothing
  to apply", "Select a category", "Failed to load categories", "Subscription
  expiry range (optional)", "Expires from", "Expires until", "Only these
  subscription IDs", "Only these user IDs", "Comma-separated — handy to limit
  scope for a test", "Apply rate limit (per sec, optional)", "Advanced"; the
  server codes of §7.28

**System**
- "Daily report", "Sends one day's activity report to the report group.",
  "Today", "Yesterday", "Custom date (YYYY-MM-DD)", "Run report", "Report
  {date} sent ✅", "Report {date} had no data", "Database backup", the backup
  hint, "Run backup", "Run the backup now?", "Backup completed ✅", "Setup
  status", "Setup is complete", "Report group", "Ready", "Not set up",
  "Background jobs", the runner hint, "Run job queue now", "Job run started ✅
  — see the jobs area", "No queued job to run", "Notifications", the
  dispatcher hint, "Send notification queue now", "Dispatch started for {n}
  notifications ✅", "No queued notification to send"

**Setup**
- "Bot setup", "{done} of {total} steps completed", "🎉 Setup complete! All
  steps are done and the bot is ready to use.", "Step {n}: {name}", "Error
  loading", the four report-group instructions, "Copy code", "New code", "Get
  code", "Check status", "New code created", "Report group set up ✅",
  "Each category is a subscription group (e.g. Germany server). The technical
  name uses only letters, numbers, - and _.", "No category created yet",
  "Create new category", "Create category", "Category created ✅", "Category
  deleted", "Disable this category?", the panel hint, "No panel registered
  yet", "Add panel", "Save & test connection", "Panel saved and connection
  tested ✅", "Panel deleted", the template hint, "Create categories in step 2
  first", "Template name (template username)", "Template name saved ✅",
  "Configure and enable at least one payment method, then finish payment
  setup.", "Bankcard transfer", "Configured", "Incomplete", "Bankcard number (16
  digits)", "Bankcard holder name", "Save bankcard", "Tronado (crypto)", "API key
  (current: {masked})", "Enter to change", "TRON wallet address", "Save
  Tronado", "Payment methods", "Minimum amount (Toman)", "Maximum amount
  (Toman)", "Payment window (minutes)", "Save", "Finish payment setup", "✅
  This step is complete", "Enter a bankcard number or holder name", "Enter an API
  key or wallet address", "Bankcard details saved ✅", "Tronado settings saved
  ✅", "Payment method updated ✅", "Payment step completed ✅", "Define
  subscription plans (price, data, duration). Data is in gigabytes (e.g. 50
  or 1.5GB or 200MB).", "No plan created yet", "Create new plan", "is a trial
  (free) plan", "Create plan", "Plan created ✅", "Plan deleted", "Disable
  this plan?", the connections hint, "Connected to {panel}", "Not connected",
  "Access ✓", "Choose…", "Connect panel", "Panel connected to category ✅",
  "Save plans", "Plans saved ✅", "Sync access", "Enable at least one payment
  method", "Access only for the admin", and the translated server sentences
  ("This category name is already registered", "This plan name is already
  registered", "The report group is already set up", "Connecting to the
  panel failed; check the address and credentials", "Bankcard number must be
  exactly 16 digits", "Wallet address must start with T and be 34
  characters", "Invalid data (e.g. 50 or 1.5GB or 200MB)", "Panel address
  must start with http:// or https://")

**Confirmations / acceptance**
- Every success and failure sentence is shown for about three seconds with
  a haptic, then goes away; failures that the person must act on (revenue
  range, deletion outcomes, tester results, unknown placeholders) stay put
  until dismissed or superseded.

---

## §13 Data presentation rules (facts, not layout)

- **Numerals** Latin everywhere, tabular where amounts or dates are compared.
- **Technical strings** exact and left-to-right: panel addresses, usernames,
  template names, bankcard numbers (copy without spaces), deep links, webhook
  addresses, report-group codes, placeholder tokens, chat ids, TRON
  addresses. Anything a person will paste elsewhere is copyable in one action
  with confirmation.
- **Money** never without its unit; list price and final price are both facts
  when a discount exists; deltas carry their sign.
- **Timestamps** in Tehran time with Latin digits; revenue buckets labelled
  by their granularity (hour `HH:00`, day `MM/DD`, month `YYYY/MM`, full
  label `YYYY-MM-DD HH:00` / `YYYY-MM-DD` / `YYYY-MM`).
- **Data amounts** in GB with up to two decimals; `∞` for unlimited limits.
- **Percentages** one decimal for shares, whole for progress; usage tone
  thresholds are 90/70 for subscriptions and 90/70 for bankcard quotas (invert
  where a high value is good).
- **Secrets** masked as described; never in a value attribute a person could
  copy.
- **Absent** shown as absent (`—`).
- **Admin-authored text** (messages, templates, descriptions) rendered as
  text, never as markup; broadcast and notification templates are Telegram
  HTML and are shown as source.
- **Internal ids** (job id, subscription id, category id, panel id, history
  id) are staff substance here and may be shown.

---

## §14 The mock data layer

Binding.

**A world, not fixtures.** One typed in-memory store holding: admins,
users (with flags, wallets, totals), subscriptions (with live-panel state and
history entries), plans, categories (all 27 fields), wallets, renewal
strategies, general switches, panels (with health), templates, connections,
bankcards (with per-period transactions), the Safe Mode row and its events,
payment methods with wallet links and cashback links, cashback conditions,
Tronado and NOWPayments settings, unlock rule groups, broadcasts and
notification jobs, warning rules, channels, tutorials, ad links with
attributed users, referral settings, background jobs with items, system
status, and the setup state. Seeded deterministically at first load;
persisted in browser storage; resettable to seed.

**The access module is the wire contract.** Function names, argument shapes,
return shapes (exactly §7), error objects (`{status, detail}` where `detail`
is a string or `{code, message?, …}`) and latency (150–600 ms per call; panel
health 1.5–3 s per panel; backup 4 s; one in twenty calls 2–3 s) are what the
real client will have.

**Time passes.**
- A seeded Queued bulk job of 240 items starts Running within 10 s of first
  load and applies about 4 items per second, emitting Applied, one Skipped
  (`no-expiration`) and, at item 180, three Failed with a believable panel
  error, reaching Completed with `failed = 3`; a second seeded job is Paused
  mid-way and resumes when told; a third is Draft with a preview sample.
- A seeded pending notification job sends about 10 per second to Completed;
  another fails with `No recipients matched audience`.
- Safe Mode is enabled with a 3-minute interval; the countdown runs and a
  `rotated` event appears in the history with the next amount per the
  strategy.
- Bankcard period counters advance when the seeded pending payments approve
  (one every 30 s for the first two minutes).
- Panel health answers per panel with a delay; one panel is offline.

**Failures on demand** (all reachable from the seed):
- a user whose wallet cannot be decreased (`Insufficient balance…`); a
  purchase that fails `INSUFFICIENT_BALANCE`, one `COMMITMENT_REQUIRED`, one
  `Plan not eligible for this user`, one `Subscription creation failed on
  panel`;
- a subscription with `not_on_panel`; one unlimited (→ `UNLIMITED_VOLUME`);
  one whose usage exceeds a proposed reduction (`BELOW_USAGE`); one with no
  expiry (`NO_EXPIRY`); one where removing days lands in the past
  (`EXPIRY_IN_PAST`); one that answers `SUBSCRIPTION_BUSY` for 20 s after any
  action; one whose delete fails `PANEL_DELETE_FAILED` once then succeeds;
- a duplicate-credential panel (409 with `existing`); a panel whose
  credentials fail verification (502); a template rename that clears access
  and a sync with one failure;
- a plan and a category name collision (409); an invalid data string; a
  discount out of range;
- a commitment save that warns about a partner-only wallet; one refused for
  missing duration;
- a Safe Mode save with each validation failure (non-integer, step 0, upper ≤
  lower, unreachable upper, interval out of range) and the 409 binding-maximum
  conflict; a bankcard-method maximum edit refused for the same reason;
- an unlock-rule save that triggers both base-method warnings; a tester run
  that returns mixed leaves and "Not unlocked";
- a broadcast test that fails `Test send failed: can't parse entities`; a
  broadcast to an audience with zero recipients;
- a warning-rule save with each type-threshold failure;
- a channel with a non-numeric chat id and a duplicate one;
- a job preview with unknown placeholders; a run refused `active_job_exists`;
  a delete refused `not_deletable`; a pause refused `pause_failed`;
- a daily report with no data; a report date in the future; the runner and
  dispatcher answering `already_running` and "no queued …";
- a revenue custom range too large (400 `range_too_large`) and a compare on
  all_time;
- a switch that makes every call answer 401, and one that answers 403.

**Accounts.** One admin (`admin`, discoverable) and, for the fatal states,
`nonadmin` (403) and `expired` (401). Seeded customers include the ones the
failures listed need (named in §15).

**Cross-cutting consistency chains** (implement all):
1. **Adjusting a wallet** changes the user's `balance`, adds to `total_paid`
   for increases, appears in the user's history as seen from the customer
   product's wallet, and (for the top payers of today) changes the overview's
   top-users list.
2. **Buying for a user** debits the balance, creates a subscription that
   appears in the user's subscriptions and in the overview counts, and adds a
   `Created` history entry.
3. **Adjusting, disabling, enabling or deleting a subscription** changes its
   detail, appends the matching history entry with the admin actor and reason,
   and (for delete) removes it from the user's list and decrements the counts.
4. **Toggling a plan or category, or a general switch,** changes the setup
   readout where relevant (an enabled category with template, panel and plan
   completes step 7), changes what "buy for user" offers, and changes the
   category health facts.
5. **Saving Safe Mode** appends the right event (`enabled` / `disabled` /
   `config_updated`), applies the lower bound as `current_amount` on enable,
   schedules `next_run_at`, and changes every payment method's
   `safe_mode_enabled` and the per-wallet effective minimums; an automatic
   rotation appends `rotated` and updates the amount.
6. **Running a bulk job** walks its items, advances counters and progress,
   ends terminal, and (when notify is on) creates a notification job that then
   sends; only one job may be active at a time and the runner trigger reports
   it.
7. **Creating a broadcast** creates a notification job with `total =
   recipients` that then sends.
8. **Connecting a category** to a panel and template, assigning plans and
   syncing sets `has_inbounds`, changes the panel's `category_count`, the
   template listing, and setup steps 4 and 7.

---

## §15 Seed dataset requirements

Every value of every enum vocabulary appears at least once. Every non-happy
state is reachable. Every filter option matches something and some filter
combination matches nothing. Dates spread over months; some in the future.

- **Admins**: 1 acting admin with a full name and @username (appears as
  `actor_display` in Safe Mode events and as the history actor).
- **Users**: ≥ 60 searchable users with varied Persian and Latin names, some
  without a username, some without a full name; ≥ 5 blocked, ≥ 6 partners,
  1 other admin, types Normal/Subscribed/Banned all present; balances
  non-round across both wallets; totals paid/renewed non-zero for ≥ 30;
  5 with ≥ 10 subscriptions.
- **Subscriptions**: ≥ 200 across the seeded users covering statuses 10, 20,
  25, 50 (30 and 40 appear only inside history as terminal events), with
  live panel data spanning 0–100 % usage, ≥ 3 with `not_on_panel`, 2
  unlimited, 1 without expiry; ≥ 8 with rich history (≥ 6 entries mixing all
  six change types, all three actor kinds, reasons present and absent).
- **Plans**: ≥ 14 covering trial/non-trial, enabled/disabled, visible/hidden,
  discounts 0/10/25/50 %, data 0.5–200 GB and unlimited, durations 7–365,
  prices 90,000–2,400,000.
- **Categories**: 8 — one healthy per panel type, one without a panel, one
  without a template, one with unsynced access, one paused for selling with
  its own message, one with renewal paused, one partner-only with wallet 20
  and a commitment (amount 2,000,000, 30 days, 5 grace, both messages using
  placeholders), one with auto-deletion off; strategies spanning 1, 3, 4, 6,
  8.
- **Wallets**: 10 and 20 with the seed names. **Strategies**: all 8.
- **General**: selling on, renewal on, both messages populated.
- **Panels**: 4 (three Marzban, one PasarGuard), one offline, versions
  varied, category counts 0–3. **Templates**: 3 in use, one shared by two
  categories.
- **Bankcards**: 6 — different banks by prefix (including one unknown prefix),
  one with no amount cap, one at 100 % of slots, one at 90 % of amount, one
  disabled, one serving both wallets, periods of 1, 7 and 30 days.
- **Safe Mode**: enabled, `random`, 100,000–160,000 step 1,000 (61
  candidates), interval 3 minutes, a current amount, ≥ 25 events covering all
  four event types and both actor types, with config diffs on the
  `config_updated` ones; wallets showing one `differs_from_safe_mode` and one
  without an eligible bankcard.
- **Payment methods**: all four (10, 20, 30, 40), one disabled, wallet links
  in every combination (linked-enabled, linked-disabled, unlinked), overrides
  on some fields only.
- **Cashback conditions**: 4, one disabled, linked to different
  method-and-wallet pairs.
- **Tronado**: key set (masked), address set. **NOWPayments**: all 30 keys,
  secrets half set, sandbox on, callbacks resolved from a base URL.
- **Unlock rules**: 3 groups — one simple, one nested with ANY inside ALL,
  one inactive; base methods such that gating one triggers the first
  warning and gating all triggers the second.
- **Warning rules**: 7 (the product's defaults: 3-day, 1-day, at-expiry,
  3-days-after deletion notice, 50 %, 80 %, 100 %), mixed types and enabled
  states.
- **Channels**: 3 (one inactive, one without public id). **Tutorials**: 4
  (two with media, one hidden). **Ad links**: 3 with reports (one with zero
  users). **Referral**: bonus 50,000, commission 10 %, all three switches
  varied.
- **Jobs**: ≥ 12 bulk jobs across all seven statuses (with titles and
  untitled ones, dry-run and real, notify on and off, strategies both), one
  with 240 items covering all five item statuses; ≥ 6 notification jobs
  across pending/completed/failed with fail reasons.
- **Revenue**: ≥ 900 successful deposits over 14 months across all methods
  and both wallets, dense enough that every preset and granularity draws a
  non-empty series, some days with zero, one day with a very large payment
  (the "largest payment" fact), and a previous-period comparison that
  triggers `series_truncated` for "this month".
- **System**: report group ready; setup complete; a second seed variant
  (`setup-fresh`) with nothing configured so all seven steps are incomplete
  and every prerequisite sentence appears.

---

## §16 Non-happy states

Each must be distinguishable and must offer the way forward.

- **First load vs. background refresh.** A first fetch shows nothing yet; a
  refresh (job polling, Safe Mode polling, post-action reload) keeps what is
  known and updates it — nothing blinks empty.
- **Empty because nothing exists**: "Nothing here yet" for any set; the
  specific sentences for users ("No user found"), templates, jobs, history,
  Safe Mode events, plans in setup; each with the way to create the thing
  where creation exists.
- **Empty because a filter matched nothing**: revenue "No successful payments
  in this range" with the filters still visible to change; a user search with
  no hits.
- **Failed to load**, with retry: every fetched set and detail; setup steps
  ("Error loading"); revenue ("Server error" with retry, or the structured
  range sentence).
- **Partially available with the reason**: a subscription whose panel could
  not be read (`not_on_panel`) while its record is fully known; an overview
  with a `null` counter; a category missing one of panel/template/plans/
  access; a payment method enabled but unlinked to any wallet; Safe Mode
  disabled (last generated amount vs effective minimums).
- **Not permitted with the reason**: 403 fatal state; adjust not offered when
  not on panel; job actions outside the legal states (and the server's
  sentence when raced); delete of an active job ("cancel first"); the
  Safe Mode maximum conflicts; the payment step "complete" refused.
- **Nonexistent**: 404 sentences (`User not found`, `Subscription not found`,
  `Job not found`, …) → a way back to the set.
- **In flight, re-trigger refused**: every write while pending; the runner
  and dispatcher `already_running`; `SUBSCRIPTION_BUSY`; `active_job_exists`.
- **Stale vs. old**: a Safe Mode countdown that has passed reads "Waiting to
  run…" (stale, the server will rotate) rather than a negative time; a
  Completed job's data is old, not stale; revenue results describe the filter
  state that produced them and never a newer one.
- **Secrets**: "Not set" is a state, not an error.
- **Setup prerequisites**: "Create categories in step 2 first" where a later
  step needs them.
- **Clipboard unavailable**: "Copying is not supported here" with the value
  still visible to select.

---

## §17 Acceptance checklist

A reviewer performs each without editing code, in Persian at phone width
unless stated.

1. Open as `nonadmin` and read the access-denied state; open as `expired`
   and read the invalid-auth state; open as `admin` and see the overview with
   today's money, counts, setup state; trigger the panel health check and
   watch four panels resolve with one offline.
2. Analyse revenue: last 7 days at auto; switch to hourly on "today"; pick a
   custom 5-year range and read the range-too-large sentence where you can
   fix it; enable compare on "this month" and read the truncation note;
   switch metric, display and cumulative without a refetch; narrow to one
   method and one wallet; reset.
3. Search a user by name, by @username and by id; open one; increase wallet
   20 by 150,000 with a reason and see the new balance; try a decrease larger
   than the balance and read the refusal; block and unblock; make partner and
   revoke; buy a plan for them (see the confirmation with their balance) and
   find the new subscription in their list and in the overview counts.
4. Open a subscription: read live usage; preview adding 10 days and removing
   5 GB with the projection; apply; read the new `ManualAdjust` history entry
   with your name; try each guard-rail refusal on the seeded subscriptions
   (unlimited, under usage, no expiry, past expiry); disable, enable; delete
   after reading the four-line warning; confirm it is gone.
5. On the `not_on_panel` subscription confirm adjust is not offered but
   delete is; delete it.
6. Create a plan with `1.5GB`; try a duplicate name; toggle visible; apply a
   20 % discount to all; soft-delete one and confirm it vanishes from offers
   but a subscription using it still names it.
7. Create a category; connect it to a panel with a template; assign plans;
   sync and read the outcome; set strategy 8; write a selling-paused message;
   set commitment terms including both templates with placeholders (copy one
   placeholder); get the partner-wallet warning; set auto-deletion to 45 days;
   toggle every switch; soft-delete.
8. Turn general selling off and read the sentence customers will see; turn it
   back on; same for renewal.
9. Add a panel with wrong credentials (502, nothing saved); add one that
   duplicates an existing address+username and confirm through the duplicate
   question; delete a panel and see its categories lose their panel; rename a
   template and confirm access is cleared then re-sync.
10. Add a bankcard with a 15-digit number (refused) then 16; see the derived
    bank; set a 500,000 cap; link both wallets; watch the seeded approvals
    advance its period counters; toggle; soft-delete.
11. Safe Mode: read the countdown counting down; wait for a rotation to
    appear in the history; change the interval without a reason (refused);
    with a reason (accepted, `config_updated` with a diff); set an
    unreachable upper bound (refused); set an upper bound higher than the binding
    maximum (409); disable (history `disabled`, wallets read manual minimums);
    re-enable; load older history; open each explanation.
12. Edit the bankcard method's maximum under the Safe Mode upper bound (refused);
    set a per-wallet override then clear it with an empty value (falls back
    to base); link a cashback rule to a method-and-wallet pair; create and
    delete a cashback condition.
13. Change the Tronado address to an invalid one (refused) then a valid one;
    in NOWPayments set a secret (shows Set, never echoed), flip a bool, edit
    a text and save, copy a webhook address.
14. Build an unlock rule with a nested ANY inside ALL over PAYMENT_AMOUNT for
    Tronado and USER_AGE; choose a base method and read the warning; choose
    every base method and read the serious warning; test against a seeded
    user and read the leaves; save; toggle; delete.
15. Broadcast: send a test (receive the confirmation), then confirm the send
    and read "{n} messages queued"; find the notification job and watch it
    send; broadcast to the empty audience and see the failed job with its
    reason; send HTML that fails the test.
16. Create a TIME_BASED rule "3 days before" and a DATA_PERCENT 20 % rule;
    edit one into DATA_GB and read the threshold refusal when left at 0;
    toggle; delete.
17. Add a channel with a non-numeric id (refused) and a valid one; add a
    tutorial and read "No media"; create an ad link, copy it, open its report;
    change referral to 15 % commission.
18. Create a bulk job: pick the category, +30 days, −5 GB, include expired,
    an expiry window, two explicit subscription ids, notify on with a template
    containing an unknown placeholder (flagged); preview; fix the template;
    preview again and read matched count and the sample; run; watch it move
    Queued → Running with counters advancing; pause; resume; cancel; read the
    items with each status; delete (refused while active, accepted after);
    discard a preview by leaving.
19. Try to run a second job while one is active (`active_job_exists`); trigger
    the runner twice (`already_running` on the second); trigger the
    dispatcher.
20. Run today's report and yesterday's; enter a future date (refused); run a
    backup and read the completion.
21. Load the `setup-fresh` seed: walk all seven steps in order, reading each
    prerequisite sentence when skipping ahead, get and regenerate the report
    group code, complete the payment step only after enabling a method, and
    read the completion announcement; return to the overview and see setup
    complete.
22. Narrow viewport (360 px) pass of items 3, 4, 11, 14 and 18: no technical
    string is truncated without a way to get the full value; every
    confirmation is readable before its confirming control; the revenue
    series remains readable and navigable.
23. Keyboard-only pass of everything: every control reachable, focus
    visible, every confirmation dismissible, the revenue series navigable
    point by point with a spoken readout, every copy action operable.
24. Both Telegram themes: every status, tone and warning remains
    unmistakable.
