# Build Brief — Abnabot, complete UI rebuild with working mock data

---

## §0 How to read this brief

This document is an exhaustive inventory of everything the 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, header, footer, tab bar, bottom sheet,
  modal, dialog, drawer, card, badge, chip, toast, table, column, grid, list
  view, or progress bar 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);
- navigation (how a person moves between things, what the moves are called,
  whether there are one or many screens, whether anything is a page at all);
- the form of any item (whether a status is a word, a colour, a shape, a
  sentence; whether a set is a list, a table, or something else);
- 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 that has one surface per section of this
brief 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, or styled that substance.

For every item in this brief, decide and be able to justify:

1. **Who** looks at it, and in what mood (checking, buying, panicking, idly
   browsing).
2. **What kind of thing** it is: a fact, a status, a quantity, a technical
   string to carry elsewhere, an action, a warning, a history.
3. **How often it changes**, and whether it changes while someone is looking.
4. **What it costs to misread** (a copied wrong link, an accidental permanent
   deletion, a purchase of ten instead of one).
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-first and right-to-left. Every
person-facing sentence exists in Persian and in English; Persian is the default,
English is selectable, and the selection persists on the device. Both languages
ship in the rebuild. Technical strings (usernames, links, ids, dates) are always
left-to-right and Latin-digit regardless of language. The Persian sentences are
the ones the current product says; where this brief quotes English, the Persian
counterpart in the existing string bundles carries the same meaning and must be
preserved.

---

## §1 What you are building

Abnabot sells VPN access. A person buys a **subscription** inside a
**category**; the category decides which panel provisions the account, which
plans exist, which wallet is charged, what a renewal does to the existing
account, and whether a **commitment** must be paid before anything in that
category can be bought. Each subscription is a panel account with a username,
a data allowance, an expiry date, a configuration link that VPN clients import,
and a set of raw connection configurations.

Money lives in **wallets**. A wallet balance is derived from a ledger of
transactions; nothing here lets a person add money — deposits happen through
the Telegram bot and appear here as history. What a person does with money here
is spend it: on subscriptions, on renewals, on category commitments.

One audience uses the product: the **account holder**, identified by Telegram.
Two variants of that audience exist and see slightly different worlds:

- a **normal user** sees the categories and wallets marked for normal users;
- a **partner** (a reseller flagged as such by staff) additionally sees the
  partner wallet and partner-only categories, and may have several wallets
  with separate balances and separate histories.

A third mode exists: a **staff member acting as a specific user**. Everything
is scoped to that user, exactly as the user would see it, with one difference:
permanent deletion is refused in that mode (staff delete elsewhere). The
rebuild must handle that mode without inventing a staff surface.

What the account holder cares about, in their own terms:

- *Is my VPN still working, and for how long?* Status, days remaining, data
  remaining, for each subscription, at a glance across many.
- *Get me the link into my app.* Copy the link, scan it, or hand it straight to
  Streisand, v2rayNG or v2Box, and know whether that worked.
- *Renew before it dies, without surprises.* Which plan, what it costs, what
  renewing does to my current time and data (the category's renewal strategy
  may add or reset either), and the balance it will draw on.
- *Buy more, sometimes many at once.* Choose a category and a plan; buy one
  with a chosen name, or twenty with a naming pattern; know the names before
  paying; know when a category first demands a commitment.
- *Where did my money go?* A per-wallet history that explains each movement:
  purchase, renewal, deposit, cashback, commitment, correction.
- *Let it renew itself.* Auto-renewal per subscription, with a chosen plan and
  a clear warning that enabling it can charge immediately.

The product's temperament: **synchronous and financial**. Every action returns
its result within one request; there are no queues to watch. But almost every
action moves money or destroys something (a link, an expired account), so the
stakes of a misclick are real and the confirmations in this brief are not
optional. Usage figures come from a remote panel and can be slow or absent;
everything else is immediate.

---

## §2 Outcome requirements (hard)

1. Every control works. Nothing is a placeholder, nothing is a dead end, no
   action is "coming soon".
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 purchase changes the
   subscription set, the wallet balance, the wallet history and the usage set;
   a renewal changes the expiry, the allowance, the balance and the history; a
   deletion removes the subscription everywhere.
4. Anything that takes time progresses visibly and finishes (usage loading,
   language switching, remote-app handoff).
5. Both audience variants and the acting-as-user mode are demonstrable in one
   build with discoverable credentials (§14).
6. Every state in this brief — every enum value, every failure, every empty
   case — is reachable without editing code.
7. Persian is the default interface language; English is selectable; the
   choice persists.
8. 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 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.
- **Content-security constraints.** External scripts may load only from the
  product's own origin, `https://telegram.org` and `https://cdn.jsdelivr.net`.
  No inline `<script>` and no inline event-handler attributes; inline `style`
  attributes are allowed. Images may be same-origin or `data:` URIs.
- **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.
- **Wire fidelity.** Field names are `snake_case` exactly as in §7. Ids are
  integers, except wallet-history ids which are strings (numeric, or `c` +
  digits). Money amounts are numbers in Toman with two decimals; plan prices
  arrive pre-formatted as strings with thousands separators (`"1,250,000"`).
  Timestamps are UTC, either ISO with a zone or naive (`YYYY-MM-DDTHH:MM:SS`,
  `YYYY-MM-DD HH:MM:SS`) — a naive value **is UTC**. `null` means absent.
- **Typed errors.** Every failure from the access module carries an HTTP-like
  `status` and a machine `code` (or the raw `detail` string when the API gives
  no code), matching §7 and §12. The interface maps codes to sentences; it
  never shows a raw code, a raw key, or an internal name.
- **Forms are schema-validated** with per-input and whole-form messages, using
  the exact rules in §7 (quantity range, name character sets, custom-name
  length).
- **Accessibility.** Keyboard-complete, visible focus, every control labelled,
  contrast-safe in both Telegram light and dark themes, motion respects
  reduced-motion preference, right-to-left correct in Persian and left-to-right
  in English with technical strings always left-to-right.
- **Responsive range.** Narrow phone width (~360px) through desktop width, with
  the phone as the primary case.
- **Reset.** A discoverable way to reset the mock world to its seed.

---

## §4 Design mandate

This is a place people check when their VPN stops working and a place where
they spend money. It must feel **reliable, exact and calm**, never playful
about money and never ambiguous about status.

- **Status must be unmistakable.** Active, expired, disabled-by-admin,
  cancelled, deleted-by-admin, not-found-on-panel, and the usage tone
  (comfortable / getting close / almost gone) are different things with
  different consequences. A person must never have to guess which is which,
  including in the English/Persian switch and in both themes.
- **Danger must feel different from routine.** Changing a link, enabling
  auto-renewal, and permanently deleting are each consequential; deletion is
  irreversible and unrefunded. These cannot look like copy-link.
- **Technical strings are exact values people carry elsewhere.** Usernames,
  links, transaction ids, configuration strings. They are never re-cased,
  translated, wrapped in a way that hides part of them, or rendered in Persian
  digits.
- **Density is a decision.** Some people have one subscription; partners can
  have a hundred and forty. Both must work.
- **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 app supplies signed
initialisation data, and every request carries it. The rebuild reads the
current user's Telegram id from that data for local scoping (cache keys) and
forwards the data unchanged on every request.

```
// Identity as the product knows it (from Telegram init data)
user.id            int      // Telegram user id; the account key
user.username      str|null // Telegram @username; used to auto-generate subscription names
// Server-side facts about the account (never sent as a payload; they shape what is returned)
IsPartner          bool     // partner audience: extra wallet(s), partner categories
IsAdmin            bool     // may act as another user (see below)
```

```
// Request headers the access module sends on every call
X-Telegram-Init-Data   <raw init data string>      // always
X-MiniApp-As-User-Id   <positive integer>          // only in acting-as-user mode
```

| role | meaning |
| --- | --- |
| account holder, normal | sees wallets with `IsForNormalUser`, categories with `IsForNormalUser` |
| account holder, partner | sees wallets with `IsForPartner` (Normal and Partner), categories with `IsForPartner` |
| staff acting as a user | identical to the target user's world; permanent delete refused with `OWNER_ACTION_REQUIRED` |

Rules:

- **Acting-as-user mode** is entered by a launch parameter `as_user_id`
  (positive integer). The interface forwards it as the header on every call
  and scopes all local storage to that user id. If the actor is not an admin
  the API answers 403 `Impersonation requires admin privileges`; a malformed
  value answers 400 `Invalid as_user_id`; an unknown target answers 404
  `Target user not found`. In this mode the permanent-delete capability is not
  offered at all, and if triggered anyway the API answers 403
  `{code: "OWNER_ACTION_REQUIRED"}`.
- **What the account holder must never discover:** anything belonging to
  another user. Every per-subscription call answers 403 `Forbidden` both when
  the subscription belongs to someone else and when it does not exist — the
  two are indistinguishable by design. Panel addresses and credentials are
  never substance; if the wire carries any, the interface ignores them.
- **Session failures that can occur on any call** (all 401 unless noted):
  `Missing init data`, `Missing hash`, `Invalid signature`, `Invalid auth_date`,
  `Init data expired` (init data older than the server's TTL, default one day),
  `Replay detected` (only when strict replay rejection is enabled),
  `Missing user data`, `Invalid user data`; 400 `Invalid init data format`;
  403 `Forbidden` when the server requires a Telegram user agent and gets
  neither that nor init data. The person-facing meaning of all of these is
  "this session is not valid; reopen the app from Telegram".
- **Language preference** is per device: `preferred_language` = `fa` | `en`.
  Detection order at start: launch parameter `lang` (`fa`/`persian` or
  `en`/`english`) → browser language starting with `fa` or `ar` → stored
  preference → default Persian. Changing language stores the preference,
  announces "Switching to Persian…" / "Switching to English…", and reloads.
- **Theme** follows the Telegram host theme (its colour parameters are
  available) and re-applies when the host theme changes.
- **Branding** arrives from the configuration call (§7.1) and is substituted
  into every sentence containing the tokens `BRAND_NAME`, `BRAND_NAME_FA`,
  `BRAND_EMOJI`.
- **Hidden hard reset.** The current product lets a person hold the language
  control for five seconds to wipe all local storage and reload. Keep a
  reset-to-seed capability (§3); whether it stays a long-press is yours.

---

## §6 Account and access capabilities

There is no registration, password, one-time code, two-factor, password reset
or sign-out in this product; all of that is Telegram's. The capabilities that
exist:

### 6.1 Start

Inputs: none from the person. The product loads configuration (§7.1), loads the
language bundle, applies branding and theme, and presents the person's
subscriptions as the first thing they see (this is a statement of what people
come for; where it lives is yours).

Failures: any §5 session failure → "this session is not valid" with the way
forward (reopen from Telegram). Configuration load failure is non-fatal: brand
tokens fall back to the literal token text and the cache TTL to 180 seconds.

### 6.2 Switch language

Inputs: target language `fa` or `en`. Rule: persists `preferred_language`;
shows the switching sentence; reloads. Both bundles have identical key sets.

### 6.3 Act as a user (staff)

Inputs: `as_user_id` at launch. Rules and failures in §5.

### 6.4 Reset local state

Clears every locally cached value and preference and reloads. Never touches
server state.

---

## §7 The domain: every entity, every field, every value

### 7.1 Configuration

```
cache_ttl_seconds       int     // how long locally cached lists stay fresh; default 180
cache_ttl_ms            int     // same in milliseconds
ttl_check_interval_ms   int     // how often expired local entries are swept; 10000
brand_name              str     // e.g. "Abnabot"; substitutes BRAND_NAME
brand_name_fa           str     // Persian brand name; substitutes BRAND_NAME_FA
brand_emoji             str     // one emoji; substitutes BRAND_EMOJI
```

Rules: the app title is the brand name — `BRAND_NAME` in English and
`BRAND_NAME_FA` in Persian (the current English title appends a generic product
word; wording may be improved, the brand substitution must survive).

### 7.2 Money and currency

- One currency: **Toman** (Iranian). Amounts are numbers with up to two
  decimals; in practice whole numbers. Never shown without the unit; the
  Persian unit word is «تومن» / «تومان».
- Digits are always Latin (`0-9`), never Persian or Arabic-Indic, in every
  language. Any digits a person types are normalised to Latin before use.
- Thousands separators are always used (`1,250,000`).
- A wallet **balance** is derived: the sum of `amount × type_multiplier` over
  the wallet's transactions with status 10 (Completed). Debits read as debits.

### 7.3 Wallet

A person has one or more logical wallets. Which ones depends on audience.

```
// element of wallets[] in the wallet overview
id        int     // 10 = Normal, 20 = Partner
name      str     // WalletFriendlyName, admin-editable, Persian in seed
balance   float   // derived balance of this wallet
```

| id | title | seed name | who has it | order |
| --- | --- | --- | --- | --- |
| 10 | Normal | کیف پول اصلی | everyone | 1 |
| 20 | Partner | کیف پول همکاری | partners only | 2 |

Wallet overview (`getWallet(limit?, offset?, wallet_id?)`):

```
balance          float      // the Normal wallet (10) balance — kept for compatibility
wallets          Wallet[]   // every wallet this person may use, in order
transactions     Tx[]       // one batch of history (see 7.4); scoped by wallet_id if given
has_more         bool       // offset + limit < total_count
total_count      int        // history rows in scope
current_offset   int
current_limit    int        // 1..100, default 20
wallet_id        int|null   // echo of the requested scope; null = all wallets
```

Rules:

- A person with **more than one wallet** has a separate balance and a
  separate history per wallet; histories are never merged. A person with one
  wallet has one history.
- Requesting a `wallet_id` that is not in `wallets` fails 400 `Invalid wallet`.
- Nothing here adds money. The only way money enters is through the bot; the
  product must make that clear rather than pretend a top-up exists. The one
  money action reachable from the wallet is spending it (buying).
- The balance is the first thing most people confirm after paying through the
  bot; it must be fetched fresh whenever the wallet is shown, never from cache.

### 7.4 Wallet transaction (history row)

Two sources are merged into one history: real ledger rows, and
commitment-credit-only uses (a purchase paid entirely from commitment credit
creates no ledger row, only a movement, and must still appear).

```
id                              str       // ledger: digits ("81352"); credit-only use: "c" + digits ("c917")
amount                          number    // positive magnitude; direction is type_multiplier
created_at                      str       // UTC timestamp (may be naive — treat as UTC)
type_id                         int|null  // 1 Pay, 10 Deposit; null for credit-only uses
type_multiplier                 int       // -1 debit, +1 credit; credit-only uses are -1
status_id                       int       // 1,10,20,30,40; credit-only uses always 10
reason_id                       int|null  // 10..130; null for credit-only uses
related_table_id                int|null  // 10..70
wallet_id                       int
wallet_name                     str|null
payment_method_id               int|null  // the real payment method of a deposit (related_table_id 20)
related_payment_transaction_id  int|null  // the ledger row this one is linked to (cashback -> its payment; correction -> its deposit)
related_payment_amount          number|null
commitment_period_id            int|null  // the commitment this bought or drew on
commitment_used_amount          number|null // partial purchase: commitment credit that covered the rest of the price
wallet_paid_amount              number    // only when commitment_used_amount: this row's amount
total_purchase_amount           number    // only when commitment_used_amount: wallet_paid_amount + commitment_used_amount
is_commitment_use               bool      // true for the "c<n>" rows
movement_subscription_id        int|null  // internal link; not substance
subscription_id                 int|null
subscription_name               str|null  // the subscription this paid for, when exactly known
subscription_lookup_method      str       // internal diagnostic; not substance
category_id                     int|null
category_name                   str|null
category_friendly_name          str|null  // the category, when exactly known
```

**Transaction type** (`type_id`; vocabulary `WalletTransactionTypeID`):

| id | name | multiplier | meaning | Persian |
| --- | --- | --- | --- | --- |
| 1 | Pay | −1 | money left the wallet | پرداخت |
| 10 | Deposit | +1 | money entered the wallet | واریز |

**Transaction status** (`status_id`; vocabulary `WalletTransactionStatusID`):

| id | name | meaning | Persian |
| --- | --- | --- | --- |
| 1 | Pending | waiting to be processed; does not count toward balance | در انتظار |
| 10 | Completed | counted in the balance | تکمیل شده |
| 20 | Failed | did not go through | ناموفق |
| 30 | CancelledByUser | the person cancelled it | لغو شده توسط کاربر |
| 40 | Rejected | the system or staff rejected it | رد شده |

**Transaction reason** (`reason_id`; vocabulary `WalletTransactionReasonID`):

| id | name | meaning | Persian |
| --- | --- | --- | --- |
| 10 | SubscriptionPurchase | a subscription was bought | خرید اشتراک |
| 20 | SubscriptionRenewal | a subscription was renewed | تمدید اشتراک |
| 30 | ManualDeposit | staff added money by hand | واریز دستی |
| 40 | PaymentVerification | a payment through the bot was verified | تأیید پرداخت |
| 50 | BonusCredit | promotional credit | اعتبار جایزه |
| 60 | Refund | money returned | بازپرداخت |
| 70 | CashBack | cashback on a deposit (always linked to the deposit) | کش‌بک |
| 80 | AdminIncrease | staff increased the balance | افزایش توسط ادمین |
| 90 | AdminDecrease | staff decreased the balance | کاهش توسط ادمین |
| 100 | InviteBonus | reward for inviting someone | پاداش دعوت |
| 110 | ReferralCommission | commission from a referral's spending | پورسانت معرفی |
| 120 | Payment Method Mistake | a correction of a deposit recorded with the wrong method (linked to that deposit) | اصلاح پرداخت |
| 130 | CategoryCommitmentPurchase | a category commitment was paid | پرداخت تعهد دسته‌بندی |

**Related record kind** (`related_table_id`; vocabulary `WalletTransactionRelatedTableID`):

| id | name | meaning | Persian |
| --- | --- | --- | --- |
| 10 | Subscription | purchase or renewal | اشتراک |
| 20 | Payment | a payment record (deposits) | پرداخت |
| 30 | AdminWalletAdjustment | staff adjustment | تنظیم کیف پول توسط ادمین |
| 40 | Refund | refund | بازپرداخت |
| 50 | Bonus | bonus/promotion | جایزه |
| 60 | PaymentMethodCorrection | the correction audit record | اصلاح پرداخت |
| 70 | CategoryCommitmentPeriod | a commitment purchase | (no label needed; the reason says it) |

**Payment method** (`payment_method_id`, only on deposits; vocabulary `PaymentTypeID`):

| id | name | Persian |
| --- | --- | --- |
| 10 | CardToCard | کارت به کارت |
| 20 | Tronado | ترونادو |
| 30 | Crypto | ارز دیجیتال |
| 40 | NOWPayments | ارز دیجیتال (NOWPayments) |

A **payment status** vocabulary (`PaymentStatusID`: 1 WaitingForUser, 10
WaitingForAdmin, 20 Approved, 30 Rejected, 40 Expired, 50 CanceledByUser;
Persian labels exist in the bundle) describes the bot-side payment that
produced a deposit. No field here carries it; keep the labels so a later
field can use them without a string change.

Rules that live on this entity:

- **Ordering** is by the numeric part of `id`, descending; `created_at` is not
  the ordering key. `c`-rows sort by their digits among the others; ties break
  on the raw id string.
- **Kind** (for narrowing the history) is derived, in this order: reason 120 →
  `payment` if `type_id` is 1 or the multiplier is negative, else `deposit`;
  reason 70 or a non-null `related_payment_transaction_id` → `cashback`;
  `type_id` 10, or a positive multiplier with no link → `deposit`; `type_id` 1
  or a negative multiplier → `payment`; otherwise `other`. The four kinds a
  person can narrow to are all / payments / deposits / cashback.
- **Pairing.** A cashback (reason 70) and the deposit it is linked to are one
  story: whenever both are present in the loaded set they belong together, and
  the pair reads "Payment & Cashback — {deposit amount} → {cashback amount}".
  A reason-120 correction is never paired even though it is linked.
- **Headline meaning** of a row, from its reason: 130 "Commitment payment";
  10 "Subscription purchase"; 20 "Subscription renewal"; 40 "Deposit";
  70 "Cashback"; a `c`-row "Commitment credit use". When the category is
  known it is its own fact ("Category: …"); only if the category cannot be
  shown as its own fact does the headline become "Subscription purchase from
  {category}" / "Subscription renewal from {category}" / "Category commitment
  payment ({category})" / "Subscription purchase using {category} commitment
  credit". An unknown reason reads as "-", never as an internal name.
- **Facts a person can read on a row:** id, time (in the viewer's own
  timezone, `YYYY-MM-DD HH:mm:ss`, Latin digits), signed amount, commitment
  credit used and purchase total when a partial-commitment purchase, type,
  status, reason, subscription name, category, payment method. Facts that are
  null are absent, not blank labels.
- **Search** matches, case-insensitively, any of: id, subscription name,
  reason text, status text, type text, payment-method text — over the rows
  loaded so far, not the server. **Narrowing** by kind is likewise local.
  While a search or a non-"all" kind is active, loading further batches is
  suspended.
- **Batches.** History arrives 20 at a time; more is fetched when the person
  reaches the end of what is loaded, until `has_more` is false. Loaded ids are
  de-duplicated. A fetch failure offers retry.
- **Export.** A person can export the entire history of the wallet in scope
  (all batches, fetched 100 at a time) as CSV named `wallet_transactions.csv`,
  UTF-8 with BOM, CRLF line ends, quoted values, with these headers in this
  order: `id, created_at, type, status, reason, amount, commitment_used,
  subscription, category, wallet, payment_method`. Labels in the current
  language; amount signed; time in the viewer's zone. Success: "Transactions
  exported"; failure: "Export failed".

Audience notes: everyone sees the same fields; partners simply have a second
wallet with its own history.

### 7.5 Subscription

```
id                       int
user_id                  int
plan_id                  int|null
name                     str        // panel username; the technical identity; unique per panel
display_name             str|null   // UserSelectedName: a name the person chose; null = none
plan_name                str|null   // internal plan code
plan_friendly_name       str|null   // the plan as people know it
category_id              int|null
category_name            str|null   // internal category code
category_friendly_name   str|null   // the category as people know it
status_id                int        // 10,20,25,30,40,50
transfer_limit           float      // data allowance in GB; 0 = unlimited
link                     str|null   // the subscription link VPN apps import; null after a revoke that returned none
duration                 int|null   // days the plan gave
created_date             str        // UTC
expiration_date          str|null   // UTC
updated_date             str|null
deleted_date             str|null
auto_renew               bool
auto_renew_plan_id       int|null   // the plan auto-renewal will use
auto_renew_plan_name     str|null   // its friendly name; null when none or unknown
deletable                bool       // status 20 and not deleted — see 7.5 rules
transaction_id           int|null   // the ledger row that bought it
expiration_date_display  str|null   // "YYYY-MM-DD HH:MM" in Tehran time, precomputed
created_date_display     str|null
usage_gb                 float|null // null until usage has been fetched
usage_display            str        // "Loading..." until fetched; then human text, or "Error"/"N/A"
usage_loaded             bool
```

**Subscription status** (`status_id`; vocabulary `SubscriptionStatusID`):

| id | name | meaning | Persian | note |
| --- | --- | --- | --- | --- |
| 10 | Active | usable; time and data remain | فعال | healthy |
| 20 | Expired | time ran out **or** data ran out (the monitor sets this for both) | منقضی شده | renew or delete |
| 25 | DisabledByAdmin | staff switched it off; only staff can switch it on; renewal and top-ups never re-enable it | غیرفعال شده توسط ادمین | no person-facing label existed; add one |
| 30 | CancelledByUser | the person permanently deleted it | لغو شده توسط کاربر | terminal; never listed (deleted rows are excluded) |
| 40 | DeletedByAdmin | staff permanently deleted it | حذف شده توسط ادمین | terminal; never listed |
| 50 | NotFoundOnPanel | the panel no longer knows the account | در پنل یافت نشد | alarming; nothing can be done here except contact support |

Derived values the product shows:

- **Displayed name** = `display_name` if set, else `name`. When a display name
  is set and differs from `name`, the original `name` is still a fact a person
  can see (it is the technical identity they may need to quote to support).
- **Remaining time** from `expiration_date` in the viewer's clock, rounded up
  to whole days: > 0 → "{N} days"; 0 → "Expires today"; < 0 → "Expired {N}
  days ago"; no date → nothing.
- **Usage**: `usage_gb / transfer_limit × 100`, capped at 100. Tone thresholds:
  ≥ 80 % is critical, ≥ 60 % is warning, otherwise comfortable. Remaining data
  = max(0, `transfer_limit − usage_gb`). When `transfer_limit ≤ 0` the plan is
  unlimited: total and remaining read as "∞"/"Unlimited" and no percentage
  exists. While `usage_gb` is null the usage is "Loading" with unknown used and
  remaining. If the fetch failed, `usage_display` is `"Error"`; if the panel
  had nothing, `"N/A"`.
- **Data amounts** are humanised: under 1 GB as whole megabytes (never 0 MB;
  at least 1), otherwise gigabytes with up to four decimals trimmed of
  trailing zeros (remaining uses two fixed decimals). Unit words are localised
  («مگابایت» / «گیگابایت», "MB" / "GB").

Rules that live on this entity:

- The set a person sees is every subscription of theirs that is not deleted,
  ordered by `name`. `total_count` is the size of that set.
- **Usage is fetched separately** from the set, per subscription, after the set
  is known (§10). Usage values are cached locally for `cache_ttl_seconds`.
- **Search** (local, over the whole set) matches `name`, `display_name`,
  `plan_friendly_name`, `category_name`, and the status label, case-insensitive.
  The result count is stated ("Found {count} subscription(s) matching
  "{term}""); no match reads "No subscriptions found" / "No subscriptions
  match "{term}"".
- **Sort** (local) by: name (locale order); usage (percentage used, highest
  first; unlimited ones first of all; requires usage for every subscription,
  which is fetched first — "Loading all subscriptions for accurate sorting…");
  time (days remaining ascending — soonest to expire first; no date last).
  Sort failure reads "Error loading data".
- **Paging in the interface**: the set is revealed 10 at a time with "Load
  More ({count} remaining)" / "Loading…" / "Error - Click to retry".
- **`deletable`** is true only for status 20 and not deleted. Status 10, 25,
  30, 40, 50 are never deletable by the person. A subscription whose time or
  data is exhausted but which the monitor has not yet moved to 20 is not yet
  deletable — the product must not compute its own expiry for this.
- **Which actions are legal in which state** (server truth; the old interface
  offered most actions regardless, the rebuild should not):
  - **Copy configurations, copy link, QR, add to app** — need a `link`
    (link actions) or panel links (configurations); sensible for 10, possible
    for 20/25 (the link exists but will not connect), meaningless for 50.
  - **Send to Telegram** — any listed status.
  - **Rename (display name)** — any listed status; 403 if deleted.
  - **Renew** — any listed, non-deleted status, subject to the category's
    renew gate and audience; the bot offers it for 10 and 20.
  - **Auto-renew** enable/disable — any listed, non-deleted status, subject to
    the same gates; enabling always requires a plan.
  - **Change link (revoke)** — any listed status; only meaningful for 10.
  - **Permanent delete** — status 20 only, owner only.
- **Concurrency**: renew and delete (and the background expiry) take the same
  per-subscription lock. If one is in flight the other answers 409
  `SUBSCRIPTION_BUSY` having done nothing; the sentence is "Another operation
  is in progress for this subscription. Please wait a moment and try again."

Failures that belong to this entity: 403 `Forbidden` (not yours / not found)
on every per-subscription call.

### 7.6 Usage

```
// per subscription
usage_gb       float|null
usage_display  str         // humanised used amount, or "Error", or "N/A"
loaded         bool        // always true in a response
// batch
usage_data     { [subscription_id: str]: Usage }   // ids not owned are silently omitted
```

Rules: batch requests carry a list of subscription ids; the server resolves
them five at a time against the panel, so a batch of forty takes noticeably
longer than a batch of five. Batch failure is 500
`Failed to get batch usage data`; the fallback is one request per subscription with ~100 ms spacing.

### 7.7 Configuration links

```
links   str[]   // raw connection configurations (vless://…, vmess://…, trojan://…); may be empty
```

Rules: fetched by `name` (username). "Copy configurations" copies all of them
joined by newlines and reports "{count} configuration(s) copied to clipboard!";
empty → "No configuration available"; fetch failure → "Failed to get
configuration"; clipboard failure → "Failed to copy to clipboard".

### 7.8 Plan (as offered)

```
id         int
name       str     // friendly name
price      str     // discounted price, thousands-separated, Toman, e.g. "450,000"
data       str     // humanised allowance, e.g. "50 گیگابایت", "500 مگابایت", or "∞" (renewal offers) / "نامحدود" (purchase offers)
duration   str     // days, as digits, e.g. "30"
is_trial   bool
```

Rules:

- Offered plans are those assigned to the category, enabled, visible, not
  deleted, ordered by the category's display order then price; **trial plans
  the person has already used are omitted**.
- Price is `SubscriptionPrice × (1 − discount%/100)`, rounded to a whole
  number for display.
- A **trial** plan can be bought once per person, ever, and only one at a time
  (quantity is forced to 1; with quantity > 1 the trial plans become
  unselectable with the reason "Trial plans can only be purchased one at a
  time"). The confirmation states "This is a trial plan that can only be
  purchased once. You cannot buy this plan again in the future." Buying it a
  second time fails `TRIAL_ALREADY_USED`: "You have already used this trial
  plan before. Trial plans can only be purchased once."
- Descriptive facts the old product derived and you may keep: a plan with
  unlimited data; a plan of 90 days or more (long-term).

### 7.9 Renewal strategy

Every category has one (`RenewStrategyID`). It decides what a renewal (manual or automatic) does to
the existing account. It is returned with every renew-plan and auto-renew-plan
offer as `renew_strategy: {id, name, description} | null`, and **must be
stated before a person confirms a renewal or enables auto-renewal**, because
a "reset" strategy discards what they still have.

| id | name (Persian, as stored) | meaning |
| --- | --- | --- |
| 1 | افزودن فقط حجم | add the bought data; time unchanged |
| 2 | افزودن فقط زمان | add the bought days; data unchanged |
| 3 | افزودن حجم و زمان | add both |
| 4 | بازنشانی فقط حجم | data becomes the plan's allowance and usage resets; time unchanged |
| 5 | بازنشانی فقط زمان | expiry becomes now + plan days; data unchanged |
| 6 | بازنشانی حجم و زمان | both reset (default for new categories) |
| 7 | افزودن حجم و بازنشانی زمان | add data, reset time |
| 8 | بازنشانی حجم و افزودن زمان | reset data (usage resets), add time |

The `description` is an admin-editable sentence; render it as text.

### 7.10 Renewal result

```
ok       bool
result:
  success            bool
  subscription_id    int
  strategy_applied   str        // strategy name
  old_transfer_limit float
  new_transfer_limit float
  old_expiry         str        // UTC
  new_expiry         str        // UTC
  panel_updated      bool
  user_activated     bool       // always true
  usage_reset        bool
  wallet_transaction_id int
```

Failures of renewal (in the order the server checks): 403 `Forbidden`; 403
`{code: RENEW_DISABLED, message}` (general or category renew switch off — the
message is the admin's sentence); 400 `Invalid plan`; 403
`{code: PLAN_NOT_ELIGIBLE, message}` (category not for this audience); 400
`COMMITMENT_REQUIRED`; 400 `INSUFFICIENT_BALANCE` (spendable funds = active
commitment credit + category wallet balance < price); 409
`{code: SUBSCRIPTION_BUSY}`; 500 `Renewal failed: …` / `Renew failed`. Nothing
is ever charged on a failure. On success the person also receives a Telegram
message from the bot and staff receive a report; neither is the interface's job.

### 7.11 Auto-renewal

```
// enable / disable
enabled   bool
plan_id   int|null   // required when enabled
// response
ok        bool
enabled   bool
```

Rules:

- Enabling requires choosing a plan from the auto-renew offer (same shape as
  §7.8 with `renew_strategy`). The chosen plan becomes
  `auto_renew_plan_id`/`auto_renew_plan_name` and is visible afterwards; it can
  be changed later by choosing another.
- Enabling is consequential: "Confirming enables automatic renewal of this
  subscription with this plan; it will renew automatically when renewal time
  arrives. If the subscription is expired or about to expire, confirming may
  renew it right now and charge your balance according to the existing rules.
  Depending on settings, the renewal strategy may add or reset time and/or
  volume." This must be read before the confirmation.
- Disabling needs no plan and no confirmation; success reads "Auto-renew
  disabled successfully". Enabling success reads "Auto-renew plan set to
  {plan}".
- Cancelling the enable flow at any point restores the previous state.
- Failures on enable, in server order: 403 `Forbidden`; 403
  `{code: PLAN_NOT_ELIGIBLE, message}`; 400 `{code: PLAN_REQUIRED, message:
  "Plan selection is required to enable auto-renew"}`; 403
  `{code: RENEW_DISABLED, message}`; 400 `Plan not available for this user`;
  500 `Failed`. Generic person-facing failure: "Failed to update auto-renew
  status". An invalid local id reads "Error: Invalid subscription ID".
- Offer failure with no plans reads "No plans available".

### 7.12 Display name

```
name   str   // 1..100 characters after trimming
```

Rules: trimmed; empty → "Custom name cannot be empty" (`CUSTOM_NAME_TOO_SHORT`);
longer than 100 → "Custom name is too long (max 100 characters)"
(`CUSTOM_NAME_TOO_LONG`). A live count "{n}/100" exists while typing. Clearing
is only possible when a display name is set and differs from `name`. Success:
"Custom name saved successfully" / "Custom name cleared"; other failure:
"Failed to update custom name" (`Failed to set custom name` /
`Failed to clear custom name` on the wire).

### 7.13 Link change (revoke)

```
ok        bool
message   str
new_url   str|null   // the new link, or null when the panel returned none (link is cleared)
```

Rules: irreversible; the old link stops working the moment it succeeds; every
device using the old link must be updated. Requires confirmation: "Are you
sure you want to change the subscription link? The current link will be
expired." with the consequence "The current link will be expired and a new
link will be generated for the subscription." Success: "Subscription link
changed successfully" and the new link replaces the old everywhere; when
`new_url` is null the subscription has **no link** until later (the bot says
"the new link is not available yet; come back shortly"). Failures: 500
`Failed to revoke subscription`; 400
`Panel not connected for this subscription`; person-facing "Failed to change subscription link". The person
also receives the new link by Telegram message.

### 7.14 Send to Telegram

Sends the full subscription detail (name, category, plan, status, created,
expiry, days remaining, usage with allowance/remaining/percentage, auto-renewal
state and plan, and the link if active) as a message from the bot, with the
bot's own action controls. `{ok, message}`; failure 500 `Failed to send to
Telegram`. Person-facing: "Subscription details sent to Telegram!" / "Failed to
send to Telegram". While in flight the action must not be re-triggerable.

### 7.15 Import into a VPN app

Three targets. Each is a deep link built from the subscription `link` (or, if
no `link`, the first configuration from §7.7):

| app | platform | deep link | if not installed |
| --- | --- | --- | --- |
| Streisand | iOS | `streisand://import/{link}` | after ~1.5 s with the app still visible: offer App Store `https://apps.apple.com/us/app/streisand/id6450534064` |
| v2rayNG | Android | first `intent://install-config?url={link}#Intent;scheme=v2rayng;package=com.v2ray.ang;end`, then 300 ms later `v2rayng://install-config?url={link}` | after 7 s: offer the latest universal APK (resolved from GitHub releases of `2dust/v2rayNG`; fall back to the releases listing) — "v2rayNG is not installed on your device. You can download the latest version here." |
| v2Box | iOS | `v2box://install-sub?url={link}&name={username or "Sub"}` | after ~1.5 s: offer App Store `https://apps.apple.com/us/app/v2box-v2ray-client/id6446814690` |

Rules: on choosing an app the product says "Opening {app}…"; if the app
becomes hidden (the app took over) the not-installed fallback is cancelled. No
link and no configurations → "No subscription configuration available";
fetch failure → "Failed to get subscription configuration". External links
open through the Telegram host's external opener when available. A **launch
mode** exists: parameters `external=1&app=<streisand|v2rayng>&subscriptionLink=<link>`
perform the handoff immediately and then close.

### 7.16 QR

The subscription `link` rendered as a QR image (error-correction level M),
titled with the subscription's name, with "Scan this QR code with your VPN
app", and the link copyable from the same place ("QR code link copied!").
Generation failure: "Error generating QR code". No link: "No subscription link
available". A "Share" capability string exists ("Share" / "Failed to share")
and was never wired; wire it to the platform share sheet where available.

### 7.17 Category (as offered for purchase)

```
id                 int
name               str    // internal code
friendly_name      str    // the name people see
is_selling_active  bool   // category switch (the offer already excludes false ones by failing at plan time)
```

Rules:

- The offered set is: enabled, visible, matching the person's audience
  (`IsForNormalUser` / `IsForPartner`), with at least one assigned enabled plan.
- If the **general selling switch** (`GENERAL_SELLING_ENABLED`) is off, the
  whole offer fails 403 `{code: SELLING_DISABLED, message}` where `message` is
  the admin's `GENERAL_SELLING_DISABLED_MESSAGE`. This is a sentence to show,
  not an error to hide.
- If a category's own switch (`IsSellingActive`) is off, its plan offer fails
  403 `SELLING_DISABLED` with the category's `SellingDisabledMessage` or the
  default "⛔️ Buying in this category is temporarily paused. Please use other
  categories or come back later." (Persian original in the bundle).
- When **exactly one** category is offered, the product must not make the
  person choose it; go straight to its plans, and "back" from there is home.
- A category outside the person's audience answers an empty plan set
  silently, even by direct id.

### 7.18 Category plan offer

```
plans        Plan[]              // §7.8; empty when gated
commitment   Commitment|null     // §7.19; null when the category has no commitment
```

### 7.19 Category commitment

Some categories require a paid **commitment** before their plans are visible.
The commitment is paid from the category's wallet, becomes **credit** that
purchases and renewals in that category consume first, lasts a fixed number of
days, then enters a **grace** window during which plans are hidden and only
renewing the commitment is offered, then expires.

```
state             "none" | "active" | "grace" | "expired"
amount            float    // commitment price, Toman
duration_days     int|null
grace_days        int
wallet_id         int
wallet_name       str
wallet_balance    float    // balance of that wallet right now
remaining         float    // unused credit; only meaningful in active/grace
days_left         int      // until the period ends; 0 otherwise
grace_days_left   int      // until the grace window ends; 0 otherwise
intro_message     str|null // admin text explaining the commitment (state none/expired)
renewal_message   str|null // admin text for the grace window
```

| state | meaning | what is offered |
| --- | --- | --- |
| none | never paid | the intro message, the terms, "Pay & activate" |
| active | paid, credit available | the plans, plus "Commitment active — {days} days left \| credit: {remaining} Toman" |
| grace | period ended, still renewable | the renewal message, the terms including "Renewal window: {grace_days_left} days", "Renew commitment" |
| expired | grace passed or replaced | same as none |

Stored period statuses behind the derived state (`CategoryCommitmentStatusID`):
10 Active, 20 AwaitingRenewal, 30 Expired, 40 Cancelled. Credit movements
(`CategoryCommitmentMovementTypeID`):
10 Credit (full amount on purchase), 20 SubscriptionUse, 30 ExpireUnused
(unused credit is burned once when the period ends), 40 UseReversal (a use
whose purchase failed is reversed).

Terms a person reads before paying: "Commitment amount (Toman)", "Validity
period" ({duration_days} days), "Wallet" ({wallet_name}), "Balance (Toman)"
({wallet_balance}), and in grace "Renewal window".

**Pay / renew commitment** (`payCommitment(category_id)`): atomic debit of
`amount` from the category wallet, a new period, full credit. Renewing during
grace retires the old period (its unused credit is already burned). Result
`{success: true, commitment}` (also when already active). Failures: 402
`{code: INSUFFICIENT_BALANCE, required, balance}` → "Wallet balance is not
enough for the commitment."; 403 `{code: NOT_AVAILABLE}` (category disabled,
wrong audience, or the wallet unusable); 400 `{code: NOT_CONFIGURED}` (amount
or duration missing); 403 `SELLING_DISABLED`. Success sentence: "Category
commitment activated." After success the plans are re-fetched and, if the
state is now active, offered.

**Spendable funds** in a commitment category = active credit remaining +
wallet balance; in a required-but-inactive state nothing is spendable and any
purchase/renewal fails `COMMITMENT_REQUIRED` rather than "insufficient".

### 7.20 Purchase

Inputs:

```
category_id    int
plan_id        int
quantity       int        // 1..100; forced to 1 for trial plans
custom_name    str|null   // only when quantity == 1
name_pattern   str|null   // only when quantity > 1
```

Naming rules (exact):

- Quantity 1 with a custom name: the name is reduced to `[A-Za-z0-9_-]`,
  every other character becomes `_`. A name shaped like `user_` + 10+ digits
  is treated as no name.
- Quantity > 1 with a pattern: reduced to `[A-Za-z0-9_{}-]` (others → `_`);
  if it lacks `{n}`, `_{n}` is appended. `{n}` is the 1-based unit number;
  `{user}` is the Telegram username lower-cased, or the user id if none.
  Placeholder hint: `{user}_{n}`; help text "Use {n} for numbering".
- Otherwise names are **auto-generated**: prefix = the Telegram username
  reduced to `[a-z0-9_]` (dots and dashes become `_`) if the person has
  subscriptions with that prefix or none at all; else the user id if they have
  subscriptions with that prefix; else the username; if the username reduces
  to nothing, the user id. Number = (last number used with that prefix, or 999)
  + 1, plus the unit index; a last number greater than 10000 is treated as 999. So a
  first-ever purchase for `@Ali.Reza` yields `ali_reza_1000`.
- The names are **previewed before paying** (`previewUsernames(qty,
  name_pattern?, custom_name?)` → `{usernames: str[]}`, qty 1..50); the first
  three are stated with "…" if more. Fallback when preview fails: the custom
  name, or the pattern with `{n}` substituted, or "Auto-generated".

Confirmation facts: plan name, data and duration ("{data} | {duration}"),
quantity, total price (unit price × quantity, thousands-separated), the names
("Usernames will be: …"), and when quantity > 1 the warning "Bulk
subscriptions will be activated immediately and count days from now"; when a
trial, the trial warning (§7.8).

Result:

```
ok                    bool                       // true only when errors is empty and created is non-empty
created               {subscription_id, username}[]
errors                {index, error}[]           // error: "INSUFFICIENT_BALANCE" | "COMMITMENT_REQUIRED" | panel/database text | "Internal server error"
message               str
new_subscription_ids  int[]
refresh_required      bool
cache_keys            str[]                      // internal; ignore
```

Rules:

- Pre-checks in server order: 403 `SELLING_DISABLED {message}`; 400
  `Invalid category/plan`; 403 `PLAN_NOT_ELIGIBLE {message}`; 400 `TRIAL_ALREADY_USED`;
  400 `COMMITMENT_REQUIRED`; 400 `INSUFFICIENT_BALANCE` (total price vs.
  spendable funds).
- Units are bought **one at a time, debit first**: each unit reserves its
  price (commitment credit first, then wallet), then creates the panel account
  and the record; a failed unit is refunded exactly and stops the loop.
  **Partial success is real**: buying 10 may yield 7 created and one error.
  The result must state exactly which names were created and why it stopped.
  Person-facing: "Purchase completed! Created: {names}"; "Purchase failed or
  no subscriptions created"; "Purchase failed: {error}"; insufficient →
  "Insufficient balance."; trial reuse → the trial sentence.
- After any created unit: the subscription set, the wallet balance and history,
  and the usage set are all stale and must be refreshed; the person ends up
  where their subscriptions are, with the new ones present.
- Each created unit also triggers a bot message and a staff report (not the
  interface's job).

### 7.21 Permanent deletion

Owner-only; status 20 only; never in acting-as-user mode. See §11 for the
consequence and confirmation. Result `{ok: true, code: "DELETED"}`. Failures:
403 `{code: OWNER_ACTION_REQUIRED}` → "Only the subscription owner can delete
it. Staff delete on someone's behalf from their own tools."; 403 `Forbidden`; 409
`{code: ALREADY_DELETED}` → "This subscription has already been deleted."; 409
`{code: NOT_ELIGIBLE}` → "Only subscriptions whose time or traffic has run out
can be deleted."; 409 `{code: SUBSCRIPTION_BUSY}` → the busy sentence; 502
`{code: PANEL_DELETE_FAILED}` (nothing deleted; retry is safe) and 500
`{code: DELETE_FAILED}` → "Failed to delete the subscription. Please try again
later." Success → "Subscription permanently deleted" and the subscription is
gone from every set.

### 7.22 Settings keys that shape what a person sees

These are staff-editable; the interface never edits them, only meets their
effects.

| key | where | effect here |
| --- | --- | --- |
| `GENERAL_SELLING_ENABLED` | bot settings | off → whole purchase offer replaced by `GENERAL_SELLING_DISABLED_MESSAGE` |
| `GENERAL_RENEW_ENABLED` | bot settings | off → every renew/auto-renew offer replaced by `GENERAL_RENEW_DISABLED_MESSAGE` |
| `IsSellingActive`, `SellingDisabledMessage` | per category | off → that category's plans replaced by its message or the default |
| `IsRenewActive`, `RenewDisabledMessage` | per category | off → renew/auto-renew in that category replaced by its message or the default "⛔️ Renewal in this category is temporarily paused…"; with no category message the general default "⛔️ Renewal is temporarily paused…" |
| `IsForNormalUser`, `IsForPartner` | per category | audience visibility |
| `PurchaseWalletID` | per category | which wallet is charged (10 or 20) |
| `CommitmentAmount`, `CommitmentDurationDays`, `CommitmentGraceDays`, `CommitmentIntroMessage`, `CommitmentRenewalMessage` | per category | the commitment gate (§7.19); amount 0 = no gate |
| `isTrial` | per plan | trial rules (§7.8) |
| `IsPartner` | per user | audience |
| `MINIAPP_CACHE_TTL_SECONDS` | environment | local cache freshness (default 180) |
| `BRAND_NAME`, `BRAND_NAME_FA`, `BRAND_EMOJI` | environment | branding tokens |

---

## §8 What the account holder must be able to do

Each item points into §7 for facts and rules.

1. **See at a glance, across all their subscriptions,** which are healthy,
   which are close to running out of time or data, and which are dead — with
   the displayed name, plan, category, status, expiry, days remaining, and
   usage (used / remaining / percentage / unlimited) for each (§7.5). With
   one subscription this is trivial; with a hundred and forty it is the whole
   job. Usage arrives after the set; the two moments must both make sense.
2. **Find one subscription** among many by name, chosen name, plan, category
   or status, and **order** them by name, by how full they are, or by how soon
   they expire (§7.5).
3. **Reveal more** when the set is long, and know how many remain (§7.5).
4. **Copy the link** in one action with confirmation; **copy all raw
   configurations** likewise, knowing how many were copied (§7.7).
5. **Show the link as a QR** and copy it from there (§7.16).
6. **Hand off the link into Streisand, v2rayNG or v2Box** and, if the app is not
   installed, be offered where to get it (§7.15).
7. **Send the full detail to their Telegram chat** (§7.14).
8. **Give a subscription a name of their own**, see the original technical
   name alongside it, and clear the chosen name (§7.12).
9. **Renew**: see the offered plans with price, data and duration; read what
   the category's renewal strategy will do to their current time and data;
   confirm; see the outcome (new expiry, new allowance, whether usage reset)
   and the refreshed subscription; understand every refusal (renewal paused
   with the admin's sentence, not eligible, commitment required, insufficient
   balance, busy) (§7.9, §7.10).
10. **Turn auto-renewal on with a chosen plan**, having read the strategy and
    the "may charge right now" warning; **see which plan** auto-renewal will
    use; **change that plan**; **turn it off** (§7.11).
11. **Change the link** after understanding the old one dies (§7.13), and
    cope with the case where no new link comes back yet.
12. **Permanently delete an expired subscription** after an unmistakable
    warning, and never be offered this for anything not expired or while
    staff are acting as them (§7.21, §11).
13. **See their balance** — the Normal wallet, and for partners every wallet —
    fresh every time, and understand that money is added through the bot, not
    here (§7.3).
14. **Read the history** of each wallet: what each movement was, when, how
    much, in which direction, for which subscription and category, by which
    payment method, and see a deposit and its cashback as one story; narrow
    to payments / deposits / cashback; search; load further batches; export
    everything as CSV (§7.4).
15. **Buy**: choose a category (or skip the choice when there is only one),
    read a category's paused message when it is paused, see its plans, choose
    quantity 1–100 (trial: exactly 1), name one subscription or pattern many,
    preview the resulting names, see the total, read the bulk and trial
    warnings, confirm, and see exactly which names were created and why it
    stopped if it did (§7.17–§7.20).
16. **Meet a commitment gate** in a category: read the admin's explanation,
    the amount, the validity, the wallet and its balance; pay it (or be told
    the balance is short); afterwards see the plans, the days left and the
    credit remaining; in the grace window renew it (§7.19).
17. **Switch language** between Persian and English and have it stick (§6.2).
18. **Recover** from a stale or broken local state (§6.4).
19. **Be told, not stranded,** whenever the session is invalid, the server is
    unreachable, or a fetch fails — with the way forward (§16).

---

## §9 What staff acting as a user must be able to do

Everything in §8 items 1–19, scoped to the target user, with these
differences:

1. **Permanent deletion is not offered** and, if reached, is refused with
   "Only the subscription owner can delete it. Staff delete on someone's
   behalf from their own tools." (§7.21).
2. **Local state is scoped** to the target user's id, so switching targets
   never shows the previous target's cached data (§5).
3. Nothing else changes, and no staff-only fact appears. The interface must
   not invent a staff surface.

---

## §10 Long-running work: how progress must be conveyed

Nothing in this product is queued; every write returns its outcome in the same
request. What takes time is fetching usage and links from the panel, and three
interface-side processes. For each, the truth to convey:

- **Usage loading** (§7.6). The subscription set is known first; usage per
  subscription arrives later, in batches the server resolves five at a time.
  Each subscription is in exactly one of: usage unknown (loading), usage
  known, usage failed (`"Error"`), usage not available (`"N/A"`). Loading is
  waiting, not working; the person can do everything else meanwhile. Sorting
  by usage requires every subscription's usage and must say so while it
  fetches. Usage is cached for `cache_ttl_seconds`; every 10 s expired entries
  are dropped and, if the person is looking at their subscriptions, refetched.
  There is no polling beyond that sweep.
- **A write in flight** (renew, auto-renew enable/disable, revoke, delete,
  send to Telegram, rename, buy, commitment payment). The triggering control
  must be un-retriggerable until the response, must say it is working
  ("Renewing subscription…", "Enabling…", "Changing…", "Sending…",
  "Processing…", "Loading…"), and the outcome must replace it: success text
  and refreshed facts, or the failure sentence and the control restored.
  Renewal success is shown for a moment ("✅ Renewed Successfully!") before
  the refreshed subscription replaces it.
- **Post-purchase refresh.** After a purchase, the subscription set, wallet
  and usage are all re-fetched before the person is shown their subscriptions;
  the success sentence names the created usernames.
- **App handoff** (§7.15). A deep link is fired; the product waits (1.5 s or
  7 s) to see whether the app was hidden; if not, offers the download. Waiting
  is visible ("Opening {app}…"); the fallback appears only if the app did not
  take over.
- **Language switch.** A visible transition of ~1.5 s then a reload.
- **History batches.** Fetching the next 20 is visible ("Loading more
  transactions…"); failure offers "Retry"; reaching `has_more = false` ends it
  and nothing suggests more.

There are no attempt counts, no cancel windows, no event streams. Never show
a loading state that cannot end.

---

## §11 Destructive and irreversible actions

Three actions cannot be undone. Each must be impossible to trigger in a
single unconfirmed action, must state its consequence in specific words
first, and must offer a way out at every moment before the point of no return.

1. **Permanently delete a subscription** (§7.21).
   Consequence, verbatim meaning: "This deletion is permanent and cannot be
   undone. No refund is issued, and the subscription can no longer be renewed,
   recovered or managed." Persian: «این حذف دائمی است و قابل بازگشت نیست. هیچ
   مبلغی بازگردانده نمی‌شود و پس از حذف، این اشتراک دیگر قابل تمدید، بازیابی یا
   مدیریت نخواهد بود.» Question: "Are you sure you want to permanently delete
   this expired subscription?" Confirming control text: "Confirm permanent
   deletion". Point of no return: the confirmed request; the server then
   removes the panel account and marks the record. A refused deletion (busy,
   not eligible, already deleted, panel failure) leaves everything as it was
   and says so. After-the-fact record: the subscription disappears from every
   set; the wallet history does not change (no refund row).
   Preconditions: status 20, owner, not acting-as-user. Never offered otherwise.
2. **Change the subscription link** (§7.13).
   Consequence: the current link stops working for every device using it; a
   new one is issued (or, rarely, none yet). Question: "Are you sure you want
   to change the subscription link? The current link will be expired."
   Confirming control text: "Change Link". After-the-fact: the new link is the
   one to copy/scan/hand off; the old one must not linger anywhere in the
   interface.
3. **Enable auto-renewal** (§7.11). Not destructive of data, but it can
   **charge money immediately**. The warning in §7.11 must be read before the
   confirming control ("✅ Confirm Auto-Renewal"); the chosen plan, its price,
   data, duration and the renewal strategy are restated there. Cancelling
   restores the previous state.

Consequential but reversible, still requiring an explicit confirmation with
the facts restated: **renewal** (money leaves; the strategy may reset what
remains) and **purchase** (money leaves; names are fixed once created; bulk
units start their days immediately) and **commitment payment** (money leaves;
credit is bound to one category and burns unused at period end).

---

## §12 The message vocabulary

Every sentence a person can read. Wording may be improved; meaning must
survive; both languages must exist. Enum labels are in §7 and not repeated.

**Session / access**
- "Access denied." (any 403 `Forbidden`)
- "Something went wrong." (any unexplained failure)
- The §5 session failures → "This session is not valid. Reopen the app from Telegram." (new sentence; the current product has none)
- "Loading…"
- "Switching to Persian…", "Switching to English…"
- "Switch to English", "Switch to Persian" (the language control's description); "EN", "فا"

**Wallet and history**
- "Balance", "Wallet Transactions", "No transactions found.", "Search transactions…", "No matching transactions"
- "All", "Payments", "Deposits", "Cashback"
- "Export CSV", "Transactions exported", "Export failed"
- Row facts: "Amount", "Type", "Status", "Reason", "Subscription", "Payment Method", "Category", "Commitment credit used", "Purchase total", "Cashback from"
- Row kinds: "Payment", "Cashback"; pair title "Payment & Cashback"
- Headlines: "Subscription purchase", "Subscription renewal", "Commitment payment", "Deposit", "Cashback", "Commitment credit use", "Category commitment payment ({category})", "Subscription purchase from {category}", "Subscription renewal from {category}", "Subscription purchase using {category} commitment credit"
- "Loading more transactions…", "Error loading more transactions", "Retry"

**Subscriptions**
- "My Subscriptions", "You have no subscriptions.", "Subscription Details"
- Facts: "Name", "Plan", "Expiry", "Remaining", "Usage", "Auto renew", "Data Usage", "Unlimited", "Loading", "Total", "Remaining", "Used", "MB", "GB", "days", "Expires today", "Expired {days} days ago"
- Search/sort: "Search subscriptions…", "No subscriptions found", "No subscriptions match "{term}"", "Found {count} subscription(s) matching "{term}"", "Name", "Usage", "Time", "Loading all subscriptions for accurate sorting…", "Error loading data"
- More: "Load More ({count} remaining)", "Loading…", "Error - Click to retry"
- Actions: "Renew", "Get Config", "Copy Link", "QR Code", "Add to App", "Send to Telegram", "Change Link", "Delete Subscription", "Toggle Auto Renew"; in flight: "Changing…", "Sending…"
- Config/link: "{count} configuration(s) copied to clipboard!", "Failed to copy to clipboard", "No configuration available", "Failed to get configuration", "Subscription link copied to clipboard!", "No subscription link available", "Subscription data not found"
- Telegram: "Subscription details sent to Telegram!", "Failed to send to Telegram"
- Apps: "Streisand", "v2rayNG", "v2Box", "Opening {app}…", "No subscription configuration available", "Failed to get subscription configuration"; "v2rayNG not found" / "v2rayNG is not installed on your device. You can download the latest version here." / "Download v2rayNG" / "Fetching link…"; "Streisand not found" / "Streisand is not installed on your device. You can download it from the App Store." / "Download Streisand"; "v2Box not found" / "v2Box is not installed on your device. You can download it from the App Store." / "Download v2Box"; default import name "Sub"
- QR: "Subscription QR Code", "Subscription Link", "Scan this QR code with your VPN app", "Copy Link", "Share", "Close", "Generating QR code…", "Error generating QR code", "QR code link copied!", "Failed to share"
- Rename: "Custom Display Name", "Enter a custom name for this subscription", "Save", "Clear", "Custom name saved successfully", "Custom name cleared", "Custom name is too long (max 100 characters)", "Custom name cannot be empty", "Failed to update custom name"

**Renewal**
- "Renew Subscription", "Loading…", "Renewing subscription…", "Renewed Successfully!", "Renewal Failed", "Renewal completed.", "Failed to load renewal plans", "Standard" (plan name fallback), "{data} | {duration}", "Plan: {name}", "🔄 Renewal strategy", "Confirm & Renew", "Back", "Unknown error"
- Refusals: "Insufficient balance."; the `RENEW_DISABLED` message as sent; the `PLAN_NOT_ELIGIBLE` message as sent ("❌ This plan is not available for your account. It is not usable for your account type; please choose another plan."); "Commitment required — activate the category commitment first" (new sentence for `COMMITMENT_REQUIRED`; the current product shows the raw code); the busy sentence
- "New Subscription Link"

**Auto-renewal**
- "Auto-renew enabled successfully", "Auto-renew disabled successfully", "Failed to update auto-renew status", "Error: Invalid subscription ID", "Select Auto-Renew Plan", "Auto-renew plan set to {plan}", "Current plan", "Change Plan", "Confirm Auto-Renewal", the §7.11 warning, "✅ Confirm Auto-Renewal", "Enabling…", "Trial" (plan marker), "No plans available", "Plan selection is required to enable auto-renew"

**Link change**
- "Change Subscription Link", the §7.13 question and consequence, "Subscription link changed successfully", "Failed to change subscription link"

**Permanent deletion**
- "Delete Subscription Permanently", the §11 question and consequence, "Confirm permanent deletion", "Subscription permanently deleted", "Failed to delete the subscription. Please try again later.", "Only subscriptions whose time or traffic has run out can be deleted.", "This subscription has already been deleted.", "Only the subscription owner can delete it. Staff delete on someone's behalf from their own tools.", "Another operation is in progress for this subscription. Please wait a moment and try again."

**Purchase**
- "Buy Subscription", "Select a Category", "Select a Plan", "Bulk Purchase", "Name Pattern", "Use {n} for numbering", "Quantity", "Decrease", "Increase", "1–100", "Preview", "Confirm", "Price", "Data", "Duration", "Custom Name", "Enter custom name", "No plans available"
- Confirmation: "Confirm purchase?", "Plan: {data} | {duration}", "Quantity", "Total Price", "Usernames will be", "Auto-generated", "Warning: Bulk subscriptions will be activated immediately and count days from now", the trial warning, "Processing…"
- Outcomes: "Purchase completed! Created: {created}", "Purchase failed or no subscriptions created", "Purchase failed: {error}", "Insufficient balance.", the trial-reuse sentence, "Trial plans can only be purchased one at a time"
- Paused: the `SELLING_DISABLED` message as sent (general or category); default category sentence "⛔️ Buying in this category is temporarily paused. Please use other categories or come back later."

**Commitment**
- "Activate category commitment", "Renew category commitment", "Commitment amount (Toman)", "Validity period", "Renewal window", "Wallet", "Balance (Toman)", "Pay & activate", "Renew commitment", "Category commitment activated.", "Wallet balance is not enough for the commitment.", "Commitment active — {days} days left | credit: {remaining} Toman"; the admin's intro and renewal messages as sent; "This commitment is not available for your account." (new sentence for `NOT_AVAILABLE` / `NOT_CONFIGURED`; the current product shows the generic failure)

**Validation**
- Quantity: integer 1..100 (input normalised to Latin digits; less than 1 becomes 1; more than 100 becomes 100)
- Custom name: 1..100 characters after trim (rename); purchase names reduced to the allowed character sets (§7.20) silently
- Pattern: `_{n}` appended when missing

**Confirmations / acceptance**
- Every success sentence in this section is shown for about three seconds and then goes away on its own; failures likewise. The person must be able to read it in Persian at phone width.

---

## §13 Data presentation rules (facts, not layout)

- **Numerals**: Latin digits everywhere, in both languages; tabular figures
  wherever amounts or dates are compared.
- **Technical strings** (`name`, `link`, configuration strings, transaction
  `id`, usernames in previews) are exact: never re-cased, translated,
  abbreviated without a way to obtain the full value, or rendered right-to-left.
  Anything a person will paste elsewhere is copyable in one action with
  confirmation.
- **Money** never without its unit; debits read as debits (sign or direction),
  credits as credits; thousands separated; two decimals only when non-zero.
- **Timestamps**: wallet history in the viewer's own timezone as
  `YYYY-MM-DD HH:mm:ss`; subscription created/expiry as supplied
  (`YYYY-MM-DD HH:MM`, Tehran time). A naive wire timestamp is UTC.
- **Data amounts** humanised per §7.5; "∞" / "Unlimited" for unlimited; never
  "0 MB" for a positive amount.
- **Absent** is shown as absent (nothing, or "-"), never as 0, `null`, or a
  blank label with no value.
- **Percentages** with one decimal (`37.5%`).
- **Admin-authored sentences** (paused messages, commitment intro/renewal,
  strategy description, plan and category names) are text and may contain
  `<`, `&` and quotes; they are rendered as text, never as markup.
- **Internal ids** (`subscription_id`, `plan_id`, `category_id`, ledger `id`)
  are noise for the person except the ledger id, which they quote to support,
  and the subscription `name`, which is their technical identity.
- **Internal diagnostics** on the wire (`subscription_lookup_method`,
  `movement_subscription_id`, `cache_keys`, `refresh_required`, panel names or
  addresses) are never shown.

---

## §14 The mock data layer

Binding.

**A world, not fixtures.** One typed in-memory store holding: users (with
audience and admin flags), wallets, ledger transactions, commitment periods and
movements, categories (with switches, audience, wallet, commitment settings,
renewal strategy, paused messages), plans (with trial flag, discount,
assignment to categories with display order), subscriptions, per-subscription
panel state (usage, links, configurations), and the general settings. Seeded
deterministically at first load; persisted in browser storage across reloads;
resettable to seed.

**The access module is the wire contract.** Function names, argument shapes,
return shapes (exactly §7), error objects (`{status, code|detail, message?}`)
and latency (150–600 ms per call; usage batches ~250 ms per five ids; one in
twenty calls 2–3 s) are what the real client will have. The interface never
reads the store.

**Time passes.**
- Usage grows: every active subscription's `usage_gb` increases by a small
  random amount each minute of wall-clock time, so a person watching sees the
  percentage move and one seeded subscription crosses from warning to critical
  within a few minutes.
- Expiry: one seeded subscription expires within five minutes of first load
  (its remaining goes "1 days" → "Expires today"), and the mock monitor then
  moves it to status 20, making it deletable.
- Commitment: one seeded category's commitment is in `grace` with
  `grace_days_left = 1`; another is `active` with `days_left = 2`.
- Cache TTL is real: usage refetches after 180 s.

**Failures on demand** (all reachable from the seed):
- a subscription whose usage fetch returns `"Error"`, and one returning `"N/A"`;
- a subscription with `link = null` (post-revoke, no new link) so copy/QR/app
  say "No subscription link available";
- a subscription with empty configurations;
- a subscription whose renew offer is refused `RENEW_DISABLED` with a category
  message, and a whole account state where `GENERAL_RENEW_ENABLED` is off;
- a category that is `SELLING_DISABLED` with its own message, and a settings
  switch to turn `GENERAL_SELLING_ENABLED` off;
- a category outside the audience (empty plans by direct id);
- a commitment category in each of `none`, `active`, `grace`;
- a wallet balance too low for a chosen plan (→ `INSUFFICIENT_BALANCE`), and
  too low for a commitment (402 with `required`/`balance`);
- a trial plan already used (→ `TRIAL_ALREADY_USED`) and one not yet used;
- a bulk purchase that partially succeeds (seed the wallet so 10 units afford
  7) and one where a panel error stops unit 3 with a text error;
- a subscription marked busy for 20 s after any renew (→ `SUBSCRIPTION_BUSY`
  on a second renew or a delete);
- a delete that fails `PANEL_DELETE_FAILED` once and succeeds on retry;
- a revoke that returns `new_url = null`;
- a rename longer than 100 characters and an empty one;
- a session-failure switch that makes every call answer 401
  `Init data expired`;
- a history batch fetch that fails once (to exercise "Retry");
- a QR generation failure (an absurdly long link).

**Accounts.** Discoverable in-app (a "demo accounts" affordance is acceptable
in the mock build only):
- `normal` — a normal user with 3 subscriptions (one active, one expired,
  one not-found-on-panel), one wallet;
- `partner` — a partner with 140 subscriptions across statuses and two
  wallets with separate histories;
- `newcomer` — no subscriptions, zero balance, one commitment category never
  paid;
- `staff-as-partner` — the partner world with `as_user_id` set (delete never
  offered).

**Cross-cutting consistency chains** (implement all):
1. **Buying** N units: N `Pay` rows (reason 10, related 10) or `c`-rows when
   commitment credit covers the price, each linked to its new subscription;
   the wallet balance drops by the wallet-paid part; commitment `remaining`
   drops by the credit part; N new subscriptions appear with `usage_gb = 0`,
   status 10, expiry = now + duration; the category's trial plan disappears
   from offers if it was a trial.
2. **Renewing**: one `Pay` row (reason 20, related 10, linked to the
   subscription); balance/credit drop; the subscription's `expiration_date`
   and `transfer_limit` change per the strategy (1–8) and `usage_gb` resets
   when the strategy resets volume; `auto_renew_plan_name` unchanged.
3. **Paying a commitment**: one `Pay` row (reason 130, related 70); a new
   period with full credit; state `active`; plans appear; in grace the old
   period is retired first.
4. **Deleting**: the subscription leaves every set and every count; no ledger
   change; a later fetch of it answers 403.
5. **Revoking**: `link` changes everywhere (copy, QR, apps) or becomes null.
6. **Deposit + cashback** (seeded only): a `Deposit` row (reason 40, related
   20, with a payment method) and a linked `Deposit` row (reason 70) appear as
   a pair; a reason-120 correction linked to a deposit appears alone.

---

## §15 Seed dataset requirements

Every value of every enum vocabulary appears at least once. Every non-happy
state is reachable. Every narrowing option matches something and some
search matches nothing. Dates spread over months; some in the future.

- **Users**: 4 (§14 accounts). Telegram usernames varied: one with dots
  (`Ali.Reza`), one with none (auto-names fall back to the id), one partner.
- **Wallets**: 10 and 20 with the seed names; the partner holds both with
  non-round balances (`3,487,250` / `12,040,000`), the normal user
  `215,500`, the newcomer `0`.
- **Ledger**: ≥ 60 rows for the partner across both wallets and ≥ 25 for the
  normal user, covering every type (1, 10), every status (1, 10, 20, 30, 40 —
  pending and failed deposits exist and do not count), every reason 10–130,
  every related record kind 10–70, every payment method 10–40 on deposits, at least
  3 deposit+cashback pairs, 1 reason-120 correction, ≥ 4 `c`-rows, ≥ 2
  partial-commitment purchases with `commitment_used_amount`, ids in the
  `81xxx` range with `c9xx` movement ids, timestamps over 8 months, some
  naive UTC strings and some ISO with `Z`.
- **Categories**: 6 — a plain one; one paused with its own message; one
  partner-only; one normal-only; one commitment-gated (amount `2,000,000`,
  30 days, 5 grace) in `active` for the partner and `none` for the normal
  user; one commitment-gated in `grace` for the partner. Strategies across
  them cover 1, 3, 4, 6, 8. Friendly names Persian and realistic (e.g.
  «سرور آلمان», «ویژه همکاران»).
- **Plans**: ≥ 12 — prices from `90,000` to `2,400,000`, discounts 0/10/25 %,
  data 500 MB, 20 GB, 50 GB, 200 GB, unlimited (0), durations 7, 30, 90, 180,
  two trials (one used by the normal user), display orders set.
- **Subscriptions**: normal user 3; partner 140 with statuses 10 (≈100), 20
  (≈25), 25 (5), 50 (3) (30 and 40 exist only as deleted rows that never
  list — seed 2 of each to prove they never appear); names shaped like
  `ali_reza_1000`, `7712345678_1003`, custom names like `office-tehran-01`;
  ≥ 10 with `display_name`; usage spread 0–100 % including several ≥ 80 %,
  several 60–80 %, unlimited ones; expiries from "Expired 40 days ago"
  through "Expires today" to 170 days; ≥ 15 with `auto_renew` and a plan
  name, ≥ 2 with `auto_renew` and no plan name; `link` shaped like
  `https://sub.example.net/sub/AbC123xyz`; 1 with `link = null`; usage
  states Error and N/A present; configurations 0–4 per subscription with
  `vless://`, `vmess://`, `trojan://` shapes.
- **Commitment periods**: for the partner, one `active` (days_left 2,
  remaining `650,000`), one `grace` (grace_days_left 1, remaining 0), one
  `expired`; movements of every type 10–40 appear.
- **Settings**: general selling on, general renew on, with a way to flip each
  and their messages populated in Persian.
- **Text**: all admin-authored sentences plausible Persian with at least one
  containing `<`, `&` and a quotation mark.

---

## §16 Non-happy states

Each must be distinguishable and must offer the way forward.

- **First load vs. background refresh.** The first fetch of a set shows
  nothing yet; a refresh (cache expiry, post-action refetch) keeps what is
  known and updates it — the set never blinks empty.
- **Empty because nothing exists**: no subscriptions ("You have no
  subscriptions." with the way to buy); no transactions in a wallet ("No
  transactions found."); no categories offered (only possible when selling is
  paused — show the paused message); no plans in a category ("No plans
  available").
- **Empty because a filter matched nothing**: "No matching transactions"; "No
  subscriptions match "{term}"" — with the filter/search still visible to
  undo.
- **Failed to load**, with retry: subscriptions, wallet, history batch, renew
  offer ("Failed to load renewal plans"), auto-renew offer, plan offer.
  Generic "Something went wrong." only when no more specific sentence exists.
- **Partially available with the reason**: usage per subscription (loading /
  Error / N/A) while the rest of the subscription is fully known; a bulk
  purchase with some created and an error; a revoke with no new link.
- **Not permitted with the reason**: delete not offered (not expired /
  acting-as-user); trial unselectable at quantity > 1; renew/auto-renew paused
  (the admin's sentence); category paused; not eligible.
- **Nonexistent**: a subscription id that answers 403 reads as "Access
  denied." — the product never confirms whether it exists.
- **In flight, re-trigger refused**: every write control while its request is
  pending; `SUBSCRIPTION_BUSY` when the server is the one refusing.
- **Stale vs. old**: usage older than `cache_ttl_seconds` is stale and is
  refetched; an expiry that is in the past is not stale, it is a fact
  ("Expired {N} days ago").
- **Invalid session**: every call fails 401; one sentence, one way forward.
- **Clipboard unavailable**: "Failed to copy to clipboard" with the value
  still visible to select by hand.
- **App not installed**: the download offer (§7.15), cancellable.

---

## §17 Acceptance checklist

A reviewer performs each without editing code, in Persian at phone width
unless stated.

1. Open as `normal`. See three subscriptions with three different statuses;
   watch usage arrive after the set; confirm the expired one offers permanent
   deletion and the others do not.
2. Copy the active subscription's link; scan its QR; copy its configurations
   and read the count; hand it to v2rayNG and let the 7-second fallback offer
   the download; cancel it.
3. Rename the active subscription to a 101-character name (refused), to an
   empty name (refused), to `دفتر تهران` (accepted; original name still
   visible); clear it.
4. Renew the active subscription: read the strategy, pick a plan, confirm,
   watch success then the refreshed expiry and allowance; open the wallet and
   find the reason-20 row linked to it with the balance reduced.
5. Renew again within 20 s and receive the busy sentence with nothing charged.
6. Enable auto-renewal: read the warning, pick a plan, confirm; see the plan
   named; change it; disable it; enable again and cancel at the warning —
   state restored.
7. Change the link: read the consequence, confirm, see the new link in
   copy/QR/app; do it on the seeded subscription that returns no new link and
   see "no link" states everywhere.
8. Delete the expired subscription: read the warning, confirm, get the
   seeded `PANEL_DELETE_FAILED`, retry, succeed; it is gone from the set, the
   count, the search, and the wallet history is unchanged.
9. Wait for the seeded subscription to reach "Expires today", then status
   20, and see deletion become available without reloading.
10. Open as `newcomer`: no subscriptions, zero balance, the way to buy is
    clear; enter the commitment category, read the intro and terms, attempt
    to pay (402 with required/balance), be told the balance is short.
11. Open as `partner`: see two wallets with separate balances and separate
    histories; narrow to cashback and see paired deposit+cashback stories;
    search `81352`; export the second wallet's CSV and check the headers,
    BOM, CRLF and Latin digits.
12. As partner, load history batches to the end (no further "more"), force
    the seeded batch failure and retry.
13. As partner, find one subscription among 140 by chosen name; sort by usage
    (watch the "loading all" state) and by time; reveal more in tens.
14. As partner, buy 10 units in the plain category with pattern
    `branch-{n}`: preview shows `branch-1, branch-2, branch-3, …`; confirm;
    receive 7 created and the `INSUFFICIENT_BALANCE` stop; the seven appear
    with usage 0 and the wallet shows seven rows.
15. As partner, buy 1 unit with a custom name containing spaces and Persian
    letters; the confirmed name is the sanitised one.
16. As partner, choose the trial plan at quantity 3 (unselectable, reason
    shown); at quantity 1 buy it; try again — `TRIAL_ALREADY_USED`.
17. As partner, enter the `active` commitment category: see days left and
    credit; buy a plan cheaper than the credit → a `c`-row in history, wallet
    unchanged, credit reduced; buy one dearer → a partial row showing
    commitment used and purchase total.
18. As partner, enter the `grace` commitment category: plans hidden, renewal
    message, "Renewal window: 1 days"; renew it; plans appear; the old period
    is retired.
19. Flip general selling off: the purchase offer is the admin's sentence.
    Flip general renew off: every renew and auto-renew offer is the admin's
    sentence. Flip both back.
20. Open the paused category: its own paused sentence. Open the normal-only
    category as partner by direct id: no plans, no error.
21. Open as `staff-as-partner`: everything identical to partner except no
    deletion anywhere; force the delete call and read the owner-only sentence.
22. Switch to English; reload; still English; switch back; hold the reset for
    five seconds (or use the reset affordance) — seed restored.
23. Flip the session-failure switch: every action answers with the invalid
    session sentence and the way forward; flip it back.
24. Narrow viewport (360 px) pass of items 1, 4, 11, 14 in Persian: nothing
    truncates a technical string without a way to get the full value; every
    confirmation is readable without scrolling past the confirming control.
25. Keyboard-only pass of everything: every control reachable, focus visible,
    every confirmation dismissible with Escape, every copy action operable.
26. Both Telegram themes: every status and usage tone remains unmistakable.
