Files
malenia/plans/SUBSCRIPTION_FIX.md
hexdev 287f8eebda feat: add subscription and transaction repositories
- Implemented SubscriptionRepository with methods to get, create, and update subscriptions.
- Added BalanceTXRepository for creating and retrieving balance transactions.
- Introduced a test script for simulating Pally webhook for local testing.
2026-04-27 21:33:25 +07:00

1183 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🔄 SUBSCRIPTION_FIX — Исправление логики смены и продления тарифного плана
> **Связанный документ:** [`FINANCE_REFINEMENT.md`](./FINANCE_REFINEMENT.md) —
> Фазы 1 и 3 являются обязательными предварительными условиями для данного плана.
---
## Содержание
1. [Постановка проблемы](#1-постановка-проблемы)
- 1.1 [«То же продление» — ожидаемое поведение (корректно)](#11-то-же-продление--ожидаемое-поведение-корректно)
- 1.2 [«Апгрейд» — скрытая потеря выручки](#12-апгрейд--скрытая-потеря-выручки)
- 1.3 [«Даунгрейд» — пользователь теряет переплаченные дни](#13-даунгрейд--пользователь-теряет-переплаченные-дни)
- 1.4 [«Истёкшая → новая» — уже обрабатывается верно](#14-истёкшая--новая--уже-обрабатывается-верно)
- 1.5 [Конкретный числовой пример](#15-конкретный-числовой-пример)
2. [Выбранное решение: «Замена со зачётом»](#2-выбранное-решение-замена-со-зачётом)
- 2.1 [Алгоритм](#21-алгоритм)
- 2.2 [Числовой пример решения](#22-числовой-пример-решения)
- 2.3 [Обоснование выбора](#23-обоснование-выбора)
- 2.4 [Отклонённые альтернативы](#24-отклонённые-альтернативы)
3. [Предварительные условия (Prerequisites)](#3-предварительные-условия-prerequisites)
4. [Поэтапный план реализации](#4-поэтапный-план-реализации)
- [Фаза 0 — Завершение предварительных условий](#фаза-0--завершение-предварительных-условий)
- [Фаза 1 — Функция сравнения планов `is_same_plan`](#фаза-1--функция-сравнения-планов-is_same_plan)
- [Фаза 2 — Функция расчёта зачёта `calculate_remaining_credit`](#фаза-2--функция-расчёта-зачёта-calculate_remaining_credit)
- [Фаза 3 — Три ветки в `buy_subscription`](#фаза-3--три-ветки-в-buy_subscription)
- [Фаза 4 — UI: отображение зачёта пользователю](#фаза-4--ui-отображение-зачёта-пользователю)
- [Фаза 5 — Граничный случай: истёкшая подписка в Remnawave](#фаза-5--граничный-случай-истёкшая-подписка-в-remnawave)
5. [Изменения модели данных](#5-изменения-модели-данных)
6. [Граничные случаи и обработка ошибок](#6-граничные-случаи-и-обработка-ошибок)
7. [Сводная таблица задач](#7-сводная-таблица-задач)
8. [Рекомендуемый порядок реализации](#8-рекомендуемый-порядок-реализации)
---
## 1. Постановка проблемы
Текущий метод `UserService.buy_subscription` обрабатывает всех существующих пользователей
Remnawave единственным способом: **добавить дни к текущему сроку истечения**. Никакого
понятия «смена плана» не существует. Это порождает четыре принципиально разных сценария,
из которых только один обрабатывается правильно.
---
### 1.1 «То же продление» — ожидаемое поведение (корректно)
**Сценарий:** Пользователь купил подписку на 3 устройства на 1 месяц. Через 10 дней покупает
ещё раз **те же** 3 устройства на 1 месяц.
**Текущее поведение (после исправлений Bug 13 из FINANCE_REFINEMENT Фаза 1):**
- `add_days(rw_user.uuid, 30)` — срок продлевается на 30 дней ✅
- `set_hwid_limit(rw_user.uuid, 3)` — лимит не меняется (уже 3) ✅
- `update_squads(...)` — squads не меняются ✅
**Вывод:** Этот сценарий работает корректно после применения Фазы 1 из FINANCE_REFINEMENT.
Дополнительных изменений не требует. **Настоящий документ его не трогает.**
---
### 1.2 «Апгрейд» — скрытая потеря выручки
**Сценарий:** Пользователь купил подписку на 3 устройства на 1 месяц (150₽). На 3-й день
покупает **6 устройств на 1 месяц** (300₽).
**Текущее поведение (после Bug 13):**
1. `rw_user` найден → входим в ветку продления.
2. `add_days(rw_user.uuid, 30)` — добавляет 30 дней поверх оставшихся ~27 дней.
3. `set_hwid_limit(rw_user.uuid, 6)` — лимит устройств обновляется до 6.
4. Итог: пользователь получает **57 дней** подписки на 6 устройств за **450₽** суммарно.
**Ожидаемое поведение:**
- 3 дня подписки на 3 устройства (уже использованы).
- 30 новых дней на 6 устройств (оплачены 300₽).
- Оставшиеся 27 дней прежнего плана должны либо сгореть, либо быть учтены как зачёт.
**Потеря выручки:**
- Пользователь получает 27 дней «апгрейда» на 6 устройств **бесплатно**.
- Стоимость этих дней по тарифу 6 устройств: 27/30 × 300₽ = **270₽** — это
деньги, которые бизнес недополучает с каждой такой операции.
---
### 1.3 «Даунгрейд» — пользователь теряет переплаченные дни
**Сценарий:** Пользователь купил подписку на 6 устройств + whitelist на 3 месяца (2 100₽
с учётом скидки). Через 2 недели переходит на 3 устройства без whitelist на 1 месяц (150₽).
**Текущее поведение (после Bug 13):**
1. `rw_user` найден → ветка продления.
2. `add_days(rw_user.uuid, 30)` — добавляет 30 дней поверх оставшихся ~76 дней.
3. `set_hwid_limit(rw_user.uuid, 3)` — лимит устройств понижается до 3.
4. `update_squads(...)` — whitelist squad удаляется.
5. Итог: пользователь получает **106 дней** подписки на 3 устройства за 2 250₽ суммарно.
**Проблемы:**
- За 76 оставшихся дней 6-устройственного плана пользователь заплатил пропорционально
~1 680₽ — и эти дни не возвращаются и не компенсируются.
- Пользователь чувствует себя обманутым: переход на более дешёвый план не даёт ни возврата,
ни кредита.
- С точки зрения бизнеса: это честно (деньги уже получены), но **приводит к оттоку**
лояльных пользователей.
---
### 1.4 «Истёкшая → новая» — уже обрабатывается верно
**Сценарий:** Срок подписки истёк. Remnawave удаляет или деактивирует пользователя.
Пользователь покупает новый план.
**Текущее поведение:**
- `get_user_by_telegram_id` возвращает `None` (пользователь удалён из RW).
- Выполняется ветка **создания** (`create_user`) с новыми параметрами.
**Вывод:** Этот сценарий работает правильно **при условии**, что Remnawave действительно
удаляет истёкших пользователей. Если пользователь остаётся в RW со статусом `"EXPIRED"`,
нужна дополнительная обработка (см. [Фаза 5](#фаза-5--граничный-случай-истёкшая-подписка-в-remnawave)).
---
### 1.5 Конкретный числовой пример
| Момент | Действие | Стоимость | Состояние в RW |
|--------|----------|-----------|----------------|
| День 0 | Куплено: 3 устройства × 1 мес | 150₽ | hwid=3, expire=День 30 |
| День 2 | Куплено: 6 устройств × 1 мес | 300₽ | hwid=6, expire=День 60 (добавили 30) |
| Итог | — | Заплачено: 450₽ | **~58 дней** на 6 устройств |
**Корректная стоимость 58 дней на 6 устройств** была бы:
- Справедливая пропорция: 2 дня × (150₽/30) + 28 дней × (300₽/30) + 30 дней × 300₽/30 = 10 + 280 + 300 = **590₽**
- Пользователь заплатил: **450₽**
- **Недополучено: 140₽** за каждую такую транзакцию.
---
## 2. Выбранное решение: «Замена со зачётом»
### 2.1 Алгоритм
При наличии **активной** подписки (rw_user существует И `expire_at > now`):
```
Если новый план == текущий план:
→ Чистое продление: add_days(duration.days) [текущее поведение, корректно]
Если новый план != текущий план:
1. Рассчитать remaining_days = (rw_user.expire_at - now).days
2. Получить из БД: plan_price_paid и duration_days активной подписки
3. credit = floor(plan_price_paid * remaining_days / duration_days)
4. Зачислить credit на баланс пользователя (если credit > 0)
5. Обновить Remnawave:
a. Установить новый expire_at = now + new_duration (через delta add_days)
b. set_hwid_limit(new_devices)
c. update_squads(new_squad_list)
6. Обновить запись Subscription в БД с новыми параметрами плана
7. Записать зачёт в лог (BalanceTransaction, если реализован из FINANCE_REFINEMENT Фаза 3)
```
При наличии **истёкшей** подписки (`rw_user` существует, но `expire_at <= now` или
`status in ("EXPIRED", "DISABLED")`):
- Кредит не начисляется (период уже закончился).
- Устанавливаем `expire_at = now + new_duration`, обновляем HWID и squads, включаем пользователя.
При отсутствии `rw_user` (пользователь не найден в Remnawave):
- Свежее создание (текущая ветка `create_user`), без изменений.
---
### 2.2 Числовой пример решения
**Те же исходные данные: покупка 6 устройств на День 2 (28 оставшихся дней из 30)**
```
remaining_days = 28
plan_price_paid = 150₽
duration_days = 30
credit = floor(150 * 28 / 30) = floor(140.0) = 140₽
```
| Статья | Сумма |
|--------|-------|
| Первая покупка (3 устр., 30 дней) | 150₽ |
| Зачёт за 28 неиспользованных дней | 140₽ |
| Вторая покупка (6 устр., 30 дней) | 300₽ |
| **Итого фактически уплачено** | **310₽** |
| **Справедливая стоимость (пропорция)** | **310₽** |
> 2 дня на 3 устр.: 2 × 5₽/день = 10₽
> 30 дней на 6 устр.: 30 × (300₽/30) = 300₽
> **Итого: 310₽** — совпадает точно.
**Состояние после замены:**
- Баланс пользователя: пополнен на 140₽ (кредит), затем списано 300₽ за новый план.
- Remnawave: hwid=6, expire=День 32 (сегодня + 30 дней), squads актуальны.
- Пользователь видит: «Тариф обновлён. На ваш баланс зачислено 140₽ за неиспользованные дни.»
---
### 2.3 Обоснование выбора
| Критерий | Replace-and-Credit |
|---|---|
| Финансовая честность для бизнеса | ✅ Нет «бесплатных апгрейдов» |
| Честность для пользователя | ✅ Нет потери переплаченных дней |
| Прозрачность | ✅ Пользователь видит начисленный зачёт |
| Сложность UX | ✅ Один экран, один клик — без доп. шагов |
| Сложность реализации | ✅ Умеренная (новый модуль + 3 ветки в сервисе) |
| Требует изменений модели БД | ⚠️ Да, но они уже запланированы в FINANCE_REFINEMENT |
Ключевое свойство: **зачёт появляется на балансе до списания новой суммы**. Это означает,
что после зачисления пользователь может себе «позволить» новый план даже если у него было
мало денег — логика проверки баланса в хендлере должна это учитывать
(см. [Секцию 6](#6-граничные-случаи-и-обработка-ошибок)).
---
### 2.4 Отклонённые альтернативы
**❌ «Запретить смену плана до окончания периода»**
Самый простой в реализации подход: показывать пользователю сообщение «Смена тарифа
недоступна до X дата». Отклонён по причине плохого UX — пользователь, которому нужно
больше устройств прямо сейчас, уйдёт к конкуренту.
**❌ «Списать полную стоимость, игнорируя остаток»**
Пользователь платит полную цену нового плана, оставшиеся дни сгорают без компенсации.
Это двойное снятие денег (за уже оплаченный период + новый период), что воспринимается
как мошенничество и является прямой причиной chargebacks.
**❌ «Показать счёт на доплату (proratedinvoice)»**
Вычислить только разницу и попросить пользователя доплатить. Концептуально верно, но
требует отдельного экрана подтверждения, динамически рассчитываемой суммы в `InlineKeyboard`,
и повторного прохождения платёжного флоу. Значительно сложнее как в UX, так и в реализации.
Отклонён как избыточный — «Замена со зачётом» даёт тот же финансовый результат проще.
---
## 3. Предварительные условия (Prerequisites)
Следующие задачи из [`FINANCE_REFINEMENT.md`](./FINANCE_REFINEMENT.md) **должны быть
выполнены до начала работы по настоящему плану**. Они перечислены здесь для ясности —
реализация описана в том документе.
---
### P1 — Bug 1: Исправить аргумент `rw.add_days` *(FINANCE_REFINEMENT, Фаза 1, §1.1)*
```python
# БЫЛО (сломано):
await rw.add_days(rw_sdk, user.id, duration.days) # user.id = Telegram int ID
# СТАЛО (правильно):
await rw.add_days(rw_sdk, rw_user.uuid, duration.days) # rw_user.uuid = UUID-строка
```
**Почему это prerequisite:** Весь текущий документ опирается на корректную работу
`add_days`. Без этого исправления Ветка B (чистое продление) не работает вовсе.
---
### P2 — Gap 7: Расширить модель `Subscription` *(FINANCE_REFINEMENT, Фаза 3, §3.1)*
FINANCE_REFINEMENT Gap 7 добавляет поля `devices`, `duration_months`, `plan_price_paid`,
`whitelists`, `rw_uuid` в таблицу `subscriptions`. **Настоящий документ требует
дополнительного поля `duration_days`**, которого нет в Gap 7 (там только `duration_months`).
`duration_days` необходим для точного расчёта зачёта, потому что месяцы имеют разное
количество дней (2831), а формула зачёта оперирует днями, а не месяцами.
Подробности — в [Секции 5](#5-изменения-модели-данных).
**Почему это prerequisite:** `calculate_remaining_credit()` читает `subscription.plan_price_paid`
и `subscription.duration_days`. Без этих полей зачёт невозможно рассчитать.
---
### P3 — Bug 5: Атомарность `proceed_to_checkout` *(FINANCE_REFINEMENT, Фаза 2, §2.1)*
```python
# ИСПРАВЛЕННЫЙ порядок:
# 1. Сначала buy_subscription (Remnawave + DB subscription)
# 2. Потом decrease_user_balance
# 3. При ошибке Remnawave — ни баланс, ни подписка не тронуты
```
**Почему это prerequisite:** В сценарии «Замена со зачётом» мы начисляем кредит
(`increase_user_balance`) внутри `buy_subscription` **до** того, как новая подписка
создана в Remnawave. Если Remnawave упадёт после начисления кредита — баланс окажется
некорректным. Атомарная структура из P3 позволяет корректно откатить состояние.
---
## 4. Поэтапный план реализации
---
### Фаза 0 — Завершение предварительных условий
**Задача:** Выполнить P1, P2, P3 из Секции 3 (если они ещё не реализованы).
**Проверка готовности перед Фазой 1:**
- [ ] `rw.add_days` принимает `rw_user.uuid`, не `user.id`.
- [ ] В модели `Subscription` присутствуют поля `devices`, `duration_days`, `plan_price_paid`,
`whitelists`, `rw_uuid`.
- [ ] Миграция Alembic применена.
- [ ] `subscription_repository.update_sub` и `create_subscription` принимают новые поля.
- [ ] `buy_subscription` принимает параметр `amount: int` и сохраняет поля плана в БД.
- [ ] Порядок в `proceed_to_checkout` исправлен (сначала RW, потом списание).
---
### Фаза 1 — Функция сравнения планов `is_same_plan`
**Файл:** `services/subscription_calculator.py` (новый файл)
**Цель:** Определить, совпадает ли выбранный пользователем новый план с тем, что
уже активен в Remnawave, чтобы выбрать между «чистым продлением» (Ветка B) и
«заменой со зачётом» (Ветка C).
**Сравнение производится по двум параметрам:**
1. `hwid_device_limit` в Remnawave vs `new_plan.devices`.
2. Наличие UUID whitelist-squad в `rw_user.active_squads` vs `new_plan.whitelists`.
```python
# services/subscription_calculator.py
from services.rw import RWUserInfo, InternalSquads
from schemas.subscriptions import SubscriptionPlan
def is_same_plan(rw_user: RWUserInfo, new_plan: SubscriptionPlan) -> bool:
"""
Возвращает True, если новый план идентичен текущему активному плану в Remnawave.
Сравниваются только те параметры, которые отражаются в Remnawave:
- лимит устройств (hwid_device_limit)
- наличие whitelist squad
Длительность (количество дней) НЕ является признаком «другого плана» —
продление на тот же тариф считается чистым extension независимо от срока.
"""
# 1. Сравниваем лимит HWID-устройств
if rw_user.hwid_device_limit != new_plan.devices:
return False
# 2. Сравниваем наличие whitelist squad
active_squad_uuids = {squad["uuid"] for squad in rw_user.active_squads}
current_has_whitelist = InternalSquads.WHITELISTS in active_squad_uuids
if current_has_whitelist != new_plan.whitelists:
return False
return True
```
**Конкретные шаги:**
1. Создать файл `services/subscription_calculator.py`.
2. Добавить функцию `is_same_plan` (код выше).
3. Убедиться, что `InternalSquads` экспортируется из `services/rw.py` или
`schemas/subscriptions.py` — если нет, вынести в `config.py` или `constants.py`.
4. Написать минимальные unit-тесты:
- Тест: `hwid=3` в RW, `new_plan.devices=3`, whitelist=False в обоих → `True`.
- Тест: `hwid=3` в RW, `new_plan.devices=6``False`.
- Тест: `hwid=6` в RW, whitelist squad присутствует, `new_plan.whitelists=False``False`.
- Тест: `hwid=6` в RW, `new_plan.devices=6`, whitelist одинаков → `True` (продление 6→6).
---
### Фаза 2 — Функция расчёта зачёта `calculate_remaining_credit`
**Файл:** `services/subscription_calculator.py` (расширить)
**Цель:** Вычислить сумму в рублях, которую нужно вернуть пользователю за неиспользованные
дни текущего оплаченного периода.
**Формула:**
```
credit = floor(plan_price_paid × remaining_days / duration_days)
где:
remaining_days = max(0, (subscription.end_date now).days)
duration_days = subscription.duration_days (из БД)
plan_price_paid = subscription.plan_price_paid (из БД, в рублях)
```
**Свойства формулы:**
- Округление вниз (`floor`) — в пользу пользователя (нельзя вернуть дробные рубли).
- При `remaining_days = 0` (подписка только что истекла) → `credit = 0`.
- При `remaining_days > duration_days` (такого не должно быть в норме, но защита нужна)
`remaining_days` ограничивается `duration_days`.
```python
# services/subscription_calculator.py (продолжение)
import math
from datetime import datetime, UTC
from db.models.subscriptions import Subscription
def calculate_remaining_credit(
subscription: Subscription,
now: datetime | None = None,
) -> int:
"""
Рассчитывает зачёт в рублях за неиспользованные дни активной подписки.
Возвращает 0, если:
- подписка уже истекла (end_date <= now)
- в модели нет данных о цене или длительности (plan_price_paid=0 или duration_days=0)
Args:
subscription: Объект активной подписки из БД. Должен содержать поля
plan_price_paid (int, руб.) и duration_days (int, дней).
now: Текущий момент (UTC). По умолчанию datetime.now(UTC).
Returns:
int: Сумма зачёта в рублях, >= 0.
"""
if now is None:
now = datetime.now(UTC)
# Защита: нет данных о плане → нет зачёта
if not subscription.plan_price_paid or not subscription.duration_days:
return 0
# Подписка уже истекла
if subscription.end_date <= now:
return 0
remaining_days = (subscription.end_date - now).days
if remaining_days <= 0:
return 0
# Аномальная защита: не возвращать больше, чем оплаченный период
remaining_days = min(remaining_days, subscription.duration_days)
credit = math.floor(
subscription.plan_price_paid * remaining_days / subscription.duration_days
)
return max(0, credit)
```
**Конкретные шаги:**
1. Дополнить `services/subscription_calculator.py` функцией `calculate_remaining_credit`.
2. Добавить импорты: `math`, `datetime`, `UTC`, модель `Subscription`.
3. Написать unit-тесты:
- `plan_price_paid=150, duration_days=30, remaining_days=28``credit=140`.
- `plan_price_paid=150, duration_days=30, remaining_days=0``credit=0`.
- `plan_price_paid=0, duration_days=30, remaining_days=20``credit=0` (нет данных).
- `plan_price_paid=2100, duration_days=90, remaining_days=76``credit=floor(2100*76/90)=1773`.
- `plan_price_paid=150, duration_days=30, end_date=вчера``credit=0` (истекла).
---
### Фаза 3 — Три ветки в `buy_subscription`
**Файл:** `services/user_service.py`
**Цель:** Переписать `buy_subscription` с явным разделением на три ветки:
- **Ветка A:** Пользователя нет в Remnawave → свежее создание.
- **Ветка B:** Активная подписка, тот же план → чистое продление (add days).
- **Ветка C:** Активная или истёкшая подписка, другой план → замена со зачётом (replace).
**Возвращаемое значение:** Для поддержки UI в Фазе 4 функция возвращает именованный
кортеж / датакласс `BuyResult`, чтобы хендлер знал, был ли начислен кредит.
```python
# services/user_service.py
# Добавить импорты в начало файла:
import logging
import math
from dataclasses import dataclass
from datetime import datetime, UTC
from services.subscription_calculator import is_same_plan, calculate_remaining_credit
logger = logging.getLogger(__name__)
@dataclass
class BuyResult:
"""Результат выполнения buy_subscription."""
subscription: "Subscription"
credit_issued: int # 0 если зачёт не производился (продление / свежее создание)
is_renewal: bool # True если чистое продление того же плана
is_replace: bool # True если была замена плана (Ветка C)
class UserService:
# ... существующий код ...
async def buy_subscription(
self,
session,
subscription: SubscriptionPlan,
rw_sdk,
user,
amount: int, # фактически рассчитанная и списанная сумма в рублях
) -> BuyResult:
"""
Создаёт, продлевает или заменяет подписку пользователя.
Три ветки:
A) rw_user не найден → create_user (свежая подписка)
B) rw_user активен, тот же план → add_days (чистое продление)
C) rw_user найден (активный или истёкший), другой план → replace + credit
"""
duration = convert_duration_to_timedelta(subscription.duration)
now = datetime.now(UTC)
rw_user = await rw.get_user_by_telegram_id(rw_sdk, user.id)
new_squads = [InternalSquads.DEFAULT]
if subscription.whitelists:
new_squads.append(InternalSquads.WHITELISTS)
# ═══════════════════════════════════════════════════════════════════
# ВЕТКА A — Пользователь не найден в Remnawave → свежее создание
# ═══════════════════════════════════════════════════════════════════
if rw_user is None:
expire = now + duration
rw_user = await rw.create_user(
rw_sdk,
username=f"tg_{user.id}",
telegram_id=user.id,
expire_at=expire,
hwid_device_limit=subscription.devices,
squad_uuids=new_squads,
)
if rw_user is None:
raise RuntimeError(
f"Remnawave create_user вернул None для tg_id={user.id}"
)
db_subscription = await self.subscription_repository.create_subscription(
session,
user_id=user.id,
end_date=expire,
autorenew=False,
status=SubscriptionStatus.ACTIVE,
devices=subscription.devices,
duration_days=duration.days,
plan_price_paid=amount,
whitelists=subscription.whitelists,
rw_uuid=rw_user.uuid,
)
logger.info(
"Новая подписка создана: user_id=%s, devices=%s, days=%s, amount=%s",
user.id, subscription.devices, duration.days, amount,
)
return BuyResult(
subscription=db_subscription,
credit_issued=0,
is_renewal=False,
is_replace=False,
)
# Определяем, активна ли подписка
is_expired = rw_user.expire_at <= now or rw_user.status in ("EXPIRED", "DISABLED")
# ═══════════════════════════════════════════════════════════════════
# ВЕТКА B — Активная подписка, ТОТ ЖЕ план → чистое продление
# ═══════════════════════════════════════════════════════════════════
if not is_expired and is_same_plan(rw_user, subscription):
new_expire = rw_user.expire_at + duration
await rw.add_days(rw_sdk, rw_user.uuid, duration.days)
# Примечание: set_hwid_limit и update_squads не нужны — план не менялся.
# Они вызывались в Bug 2/3 фикс как «всегда обновлять», но здесь
# is_same_plan гарантирует, что значения в RW уже актуальны.
db_subscription = await self.subscription_repository.update_sub(
session,
user_id=user.id,
end_date=new_expire,
autorenew=False,
status=SubscriptionStatus.ACTIVE,
# Обновляем финансовые поля для корректного расчёта следующего зачёта:
plan_price_paid=amount,
duration_days=duration.days,
)
logger.info(
"Подписка продлена (тот же план): user_id=%s, new_expire=%s, amount=%s",
user.id, new_expire, amount,
)
return BuyResult(
subscription=db_subscription,
credit_issued=0,
is_renewal=True,
is_replace=False,
)
# ═══════════════════════════════════════════════════════════════════
# ВЕТКА C — Другой план (или истёкшая подписка) → замена со зачётом
# ═══════════════════════════════════════════════════════════════════
# Рассчитываем зачёт ТОЛЬКО для активных подписок с другим планом.
# Для истёкших — кредит = 0 (период уже закончился).
credit = 0
if not is_expired:
db_sub = await self.subscription_repository.get_subscription_by_user_id(
session, user.id
)
if db_sub:
credit = calculate_remaining_credit(db_sub, now)
if credit > 0:
await self.user_repository.increase_user_balance(session, user.id, credit)
logger.info(
"Зачёт за смену плана: user_id=%s, credit=%s₽ | "
"старый план: devices=%s, whitelists=%s | "
"новый план: devices=%s, whitelists=%s",
user.id, credit,
db_sub.devices if db_sub else "?",
db_sub.whitelists if db_sub else "?",
subscription.devices,
subscription.whitelists,
)
# Целевой expire = сейчас + новая длительность (НЕ аддитивно к старому!)
new_expire = now + duration
# Включаем пользователя, если он был отключён
if rw_user.status == "DISABLED":
await rw.enable_user(rw_sdk, rw_user.uuid)
# Remnawave не имеет метода set_expire_at — используем add_days с дельтой.
# delta может быть отрицательным (даунгрейд + короткий период).
# Большинство реализаций RW SDK поддерживают отрицательное значение.
delta_days = (new_expire - rw_user.expire_at).days
if delta_days != 0:
await rw.add_days(rw_sdk, rw_user.uuid, delta_days)
await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices)
await rw.update_squads(rw_sdk, rw_user.uuid, new_squads)
db_subscription = await self.subscription_repository.update_sub(
session,
user_id=user.id,
end_date=new_expire,
autorenew=False,
status=SubscriptionStatus.ACTIVE,
devices=subscription.devices,
duration_days=duration.days,
plan_price_paid=amount,
whitelists=subscription.whitelists,
)
logger.info(
"Подписка заменена: user_id=%s, devices=%s, days=%s, amount=%s₽, credit=%s",
user.id, subscription.devices, duration.days, amount, credit,
)
return BuyResult(
subscription=db_subscription,
credit_issued=credit,
is_renewal=False,
is_replace=True,
)
```
**Конкретные шаги:**
1. Открыть `services/user_service.py`.
2. Добавить `@dataclass BuyResult` (или вынести в `schemas/subscriptions.py`).
3. Добавить импорты: `is_same_plan`, `calculate_remaining_credit` из нового модуля.
4. Полностью переписать метод `buy_subscription` согласно коду выше.
5. Убедиться, что `subscription_repository` имеет метод `get_subscription_by_user_id`.
Если нет — добавить в `repositories/subscriptions.py`.
6. Убедиться, что `user_repository` имеет метод `increase_user_balance`.
Если нет — добавить в `repositories/users.py` (симметрично `decrease_user_balance`).
7. Обновить хендлер `proceed_to_checkout` в `bot/handlers/buy.py`:
- `buy_subscription` теперь возвращает `BuyResult`, не `Subscription`.
- Передавать `amount=amount` как аргумент.
- Передавать `result.credit_issued` в логику отображения сообщения (Фаза 4).
---
### Фаза 4 — UI: отображение зачёта пользователю
**Файл:** `bot/handlers/buy.py` и `bot/texts.py` (или аналогичный файл с текстами)
**Цель:** Показывать разные сообщения в зависимости от результата операции:
- Чистое создание или продление → стандартный текст (уже есть).
- Замена плана с зачётом → новый текст с упоминанием начисленного зачёта.
- Замена плана без зачёта (истёкшая подписка) → текст про повторную активацию.
**Новые текстовые константы:**
```python
# bot/texts.py (или аналог)
# Уже существующий:
SUCCESSFULLY_CREATED_SUBSCRIPTION = (
"✅ <b>Подписка активирована!</b>\n\n"
"Используйте кнопку ниже для получения конфигурации."
)
# Новый — для чистого продления:
SUCCESSFULLY_RENEWED_SUBSCRIPTION = (
"✅ <b>Подписка продлена!</b>\n\n"
"Срок действия вашей текущей подписки увеличен."
)
# Новый — для замены плана с зачётом:
SUBSCRIPTION_REPLACED_WITH_CREDIT = (
"✅ <b>Тариф обновлён!</b>\n\n"
"На ваш баланс зачислено <b>{credit}₽</b> за неиспользованные дни прежнего тарифа.\n"
"Новая подписка активна с сегодняшнего дня."
)
# Новый — для замены плана без зачёта (истёкшая):
SUBSCRIPTION_REACTIVATED = (
"✅ <b>Подписка возобновлена!</b>\n\n"
"Ваша подписка была неактивна. Новый тариф действует с сегодняшнего дня."
)
```
**Обновлённый хендлер `proceed_to_checkout`:**
```python
# bot/handlers/buy.py
@router.callback_query(SubscriptionStorage.duration, F.data == "confirm")
async def proceed_to_checkout(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
# Немедленно блокируем повторные нажатия (FINANCE_REFINEMENT Bug 6)
await state.set_state(SubscriptionStorage.checkout)
data = await state.get_data()
ctx: SubscriptionPlan | None = data.get("ctx")
if not ctx:
await state.clear()
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
return
amount = calculate_price(ctx)
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
# Проверка баланса ПЕРЕД операцией
# Примечание: после начисления кредита баланс вырастет, но мы не знаем credit заранее.
# Если баланс без кредита достаточен — пропускаем. Иначе — нужно отдельно обработать
# (см. Секцию 6 «Баланс после зачёта»).
if user.balance < amount:
await state.clear()
await cb.message.edit_text(
INSUFFICIENT_FUNDS.format(balance=user.balance, amount=amount),
reply_markup=deposit_kb,
)
return
# 1. Создать/продлить/заменить подписку (Remnawave + DB subscription + credit)
try:
result: BuyResult = await deps.user_service.buy_subscription(
session, ctx, deps.rw_sdk, cb.from_user, amount=amount
)
except Exception:
logger.exception(
"buy_subscription упал: user_id=%s, amount=%s. Баланс не тронут.",
cb.from_user.id,
amount,
)
await state.clear()
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
return
# 2. Списать баланс (после успешной операции)
await deps.user_repository.decrease_user_balance(session, user.id, amount)
await state.clear()
# 3. Показать соответствующее сообщение
if result.is_renewal:
text = SUCCESSFULLY_RENEWED_SUBSCRIPTION
elif result.is_replace and result.credit_issued > 0:
text = SUBSCRIPTION_REPLACED_WITH_CREDIT.format(credit=result.credit_issued)
elif result.is_replace and result.credit_issued == 0:
text = SUBSCRIPTION_REACTIVATED
else:
text = SUCCESSFULLY_CREATED_SUBSCRIPTION
await cb.message.edit_text(text, reply_markup=return_to_menu)
```
**Конкретные шаги:**
1. Добавить три новых константы в файл с текстами.
2. Обновить `proceed_to_checkout` согласно коду выше.
3. Убедиться, что `BuyResult` импортирован из `services/user_service.py`.
4. Проверить, что `INSUFFICIENT_FUNDS` и `deposit_kb` импортированы.
---
### Фаза 5 — Граничный случай: истёкшая подписка в Remnawave
**Файл:** `services/user_service.py`
**Цель:** Корректно обработать ситуацию, когда `get_user_by_telegram_id` возвращает
`RWUserInfo`, но пользователь уже неактивен (`status == "EXPIRED"` или
`expire_at <= now`).
**Поведение (уже заложено в Ветку C Фазы 3):**
- `is_expired = rw_user.expire_at <= now or rw_user.status in ("EXPIRED", "DISABLED")`
- Если `is_expired=True` → кредит = 0, `new_expire = now + duration`.
- Если `status == "DISABLED"` → вызвать `enable_user` перед обновлением.
- `delta_days = (new_expire - rw_user.expire_at).days` корректно считается как положительное
число (например, если expire_at был 5 дней назад, а мы добавляем 30, delta=35).
**Специальный случай: статус `"EXPIRED"` в Remnawave**
Некоторые версии Remnawave при истечении подписки не удаляют пользователя, а меняют
его статус на `"EXPIRED"`. В этом случае:
```python
# Явная проверка и включение пользователя если нужно
if rw_user.status in ("DISABLED", "EXPIRED"):
await rw.enable_user(rw_sdk, rw_user.uuid)
# enable_user должен переводить пользователя в ACTIVE;
# иначе нужно добавить хелпер set_expire_at или использовать update_user напрямую.
```
**Конкретные шаги:**
1. Убедиться, что Ветка C (Фаза 3) содержит проверку `rw_user.status in ("EXPIRED", "DISABLED")`.
2. Проверить, что `rw.enable_user` действительно реактивирует истёкшего пользователя
в используемой версии Remnawave SDK (зависит от конфигурации панели).
3. Если `enable_user` не переводит из `EXPIRED` в `ACTIVE` автоматически — добавить
хелпер `rw.reset_expire(sdk, uuid, new_expire: datetime)` поверх нужного API-эндпоинта RW.
4. Логировать факт реактивации отдельно от обычной замены плана:
```python
logger.info(
"Реактивация истёкшей подписки: user_id=%s, old_expire=%s, new_expire=%s",
user.id, rw_user.expire_at, new_expire,
)
```
---
## 5. Изменения модели данных
### 5.1 Дополнение к Gap 7 из FINANCE_REFINEMENT
FINANCE_REFINEMENT Фаза 3 (Gap 7) добавляет в `Subscription` поля:
`devices`, `duration_months`, `plan_price_paid`, `whitelists`, `rw_uuid`.
**Настоящий документ требует одно дополнительное поле:** `duration_days`.
Обоснование: `duration_months` нужен для автопродления (повторить тот же срок).
`duration_days` нужен для расчёта зачёта (формула использует дни, а не месяцы,
потому что месяцы имеют разное количество дней и кредит должен быть точным).
| Поле | Тип | NULL? | Назначение |
|------|-----|-------|-----------|
| `devices` | `int` | NOT NULL | Кол-во HWID-устройств в плане. Для автопродления и `is_same_plan`. |
| `duration_months` | `int` | NOT NULL | Срок в месяцах (1, 3, 6, 12). Для автопродления. |
| `duration_days` | `int` | NOT NULL | Фактические дни оплаченного периода (30, 90, 180, 365). Для `calculate_remaining_credit`. |
| `plan_price_paid` | `int` | NOT NULL | Списанная сумма в рублях. Для зачёта и аудита. |
| `whitelists` | `bool` | NOT NULL, default False | Whitelist addon в плане. Для `is_same_plan` и автопродления. |
| `rw_uuid` | `str` | NULL | UUID в Remnawave. Для прямых обращений без повторного GET. |
### 5.2 Обновление модели SQLAlchemy
```python
# db/models/subscriptions.py
from sqlalchemy import Boolean, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
class Subscription(Base):
__tablename__ = "subscriptions"
user_id: Mapped[int] = mapped_column(primary_key=True)
end_date: Mapped[datetime] = mapped_column(nullable=False)
autorenew: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
status: Mapped[SubscriptionStatus] = mapped_column(nullable=False)
# ── Поля плана (добавлены в FINANCE_REFINEMENT Gap 7 + настоящий документ) ──
devices: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
duration_months: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
duration_days: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
plan_price_paid: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
whitelists: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
rw_uuid: Mapped[str | None] = mapped_column(Text, nullable=True, unique=True)
```
### 5.3 Alembic-миграция
Если FINANCE_REFINEMENT Gap 7 уже применён (без `duration_days`), создать
отдельную миграцию:
```bash
alembic revision --autogenerate -m "add_duration_days_to_subscriptions"
```
Проверить и при необходимости скорректировать автогенерированный файл:
```python
# alembic/versions/xxxx_add_duration_days_to_subscriptions.py
def upgrade() -> None:
op.add_column(
"subscriptions",
sa.Column("duration_days", sa.Integer(), nullable=False, server_default="30"),
)
# Удалить server_default после заполнения существующих строк (опционально):
# op.alter_column("subscriptions", "duration_days", server_default=None)
def downgrade() -> None:
op.drop_column("subscriptions", "duration_days")
```
Если Gap 7 ещё не применялся — добавить `duration_days` в ту же миграцию вместе
с остальными полями Gap 7.
### 5.4 Обновление `subscription_repository`
```python
# repositories/subscriptions.py
async def create_subscription(
self,
session,
*,
user_id: int,
end_date: datetime,
autorenew: bool,
status: SubscriptionStatus,
devices: int,
duration_months: int,
duration_days: int,
plan_price_paid: int,
whitelists: bool,
rw_uuid: str | None = None,
) -> Subscription:
sub = Subscription(
user_id=user_id,
end_date=end_date,
autorenew=autorenew,
status=status,
devices=devices,
duration_months=duration_months,
duration_days=duration_days,
plan_price_paid=plan_price_paid,
whitelists=whitelists,
rw_uuid=rw_uuid,
)
session.add(sub)
await session.flush()
return sub
async def update_sub(
self,
session,
*,
user_id: int,
end_date: datetime | None = None,
autorenew: bool | None = None,
status: SubscriptionStatus | None = None,
devices: int | None = None,
duration_months: int | None = None,
duration_days: int | None = None,
plan_price_paid: int | None = None,
whitelists: bool | None = None,
) -> Subscription | None:
sub = await self.get_subscription_by_user_id(session, user_id)
if sub is None:
return None
if end_date is not None:
sub.end_date = end_date
if autorenew is not None:
sub.autorenew = autorenew
if status is not None:
sub.status = status
if devices is not None:
sub.devices = devices
if duration_months is not None:
sub.duration_months = duration_months
if duration_days is not None:
sub.duration_days = duration_days
if plan_price_paid is not None:
sub.plan_price_paid = plan_price_paid
if whitelists is not None:
sub.whitelists = whitelists
await session.flush()
return sub
```
---
## 6. Граничные случаи и обработка ошибок
### 6.1 Remnawave недоступен во время замены
**Проблема:** `buy_subscription` уже начислил кредит (`increase_user_balance`), но
следующий вызов `rw.add_days` / `rw.set_hwid_limit` / `rw.update_squads` упал с
исключением.
**Решение:** Порядок операций в Ветке C критически важен:
```
1. calculate_remaining_credit() ← только вычисление, без side effects
2. increase_user_balance(credit) ← DB операция
3. rw.enable_user() если нужно ← RW
4. rw.add_days(delta) ← RW ← если здесь падает:
5. rw.set_hwid_limit() ← RW кредит уже начислен!
6. rw.update_squads() ← RW
7. update_sub() в БД ← DB
```
Если шаги 46 падают, кредит уже на балансе, но подписка в RW не обновлена.
Пользователь не в убытке (деньги вернулись), а оператор получает ошибку в логах.
**Рекомендация:** Обернуть шаги 46 в отдельный `try/except` с компенсирующей
транзакцией — снять начисленный кредит обратно при неудаче RW:
```python
# Ветка C: обёртка для компенсации
try:
if rw_user.status == "DISABLED":
await rw.enable_user(rw_sdk, rw_user.uuid)
if delta_days != 0:
await rw.add_days(rw_sdk, rw_user.uuid, delta_days)
await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices)
await rw.update_squads(rw_sdk, rw_user.uuid, new_squads)
except Exception as rw_error:
# RW недоступен — откатываем кредит
if credit > 0:
await self.user_repository.decrease_user_balance(session, user.id, credit)
logger.warning(
"Кредит %s₽ отозван для user_id=%s: RW вызов упал (%s)",
credit, user.id, rw_error,
)
raise # Передаём исключение в хендлер, который не будет списывать баланс
```
Это обеспечивает полный откат: кредит не начислен, баланс (для покупки) не списан,
подписка не изменена — состояние пользователя как до нажатия кнопки.
---
### 6.2 Отрицательный `delta_days` (даунгрейд с одновременным сокращением срока)
**Пример:** У пользователя 6 устройств, 60 дней остатка. Покупает 3 устройства на
30 дней. `new_expire = now + 30 дней`, `delta_days = (now+30) - (now+60) = -30`.
Remnawave SDK принимает отрицательные значения `add_days` в большинстве реализаций
(внутри вызывается `expire_at += timedelta(days=days)`, что работает для отрицательных).
**Действие:** Перед выпуском в продакшен проверить на тестовом Remnawave-инстансе,
что вызов `add_days(uuid, -30)` корректно уменьшает срок. Если нет — добавить хелпер:
```python
# services/rw.py — добавить если нужно:
async def set_expire_at(sdk, user_uuid: str, expire_at: datetime) -> bool:
"""
Устанавливает точную дату истечения через прямой PATCH /users/{uuid}.
Использовать если add_days с отрицательным значением не поддерживается.
"""
try:
result = await sdk.users.update_user(uuid=user_uuid, expire_at=expire_at)
return result is not None
except Exception as e:
logger.warning("set_expire_at failed for %s: %s", user_uuid, e)
return False
```
---
### 6.3 Зачёт делает покупку «доступной» (баланс + кредит ≥ цены)
**Сценарий:** У пользователя 50₽ на балансе. Хочет купить план за 300₽. Баланс проверяется
в хендлере: 50 < 300 → «Недостаточно средств». Но у пользователя есть активная подписка,
за которую при замене он получил бы 140₽ кредита. 50 + 140 = 190 < 300 → всё равно нет.
Это не является багом — зачёт начисляется **только внутри `buy_subscription`**, поэтому
хендлер всегда проверяет баланс **без учёта будущего кредита**. Это консервативная,
безопасная проверка.
Если требуется «провести» пользователя несмотря на недостаточный баланс при условии
достаточности (баланс + ожидаемый кредит ≥ цены), это потребует отдельного API-вызова
для предварительного расчёта кредита в хендлере до проверки баланса. **Не реализовывать
без явного требования от продукта** — усложняет код и может привести к race condition.
---
### 6.4 Округление зачёта
Формула использует `math.floor`, что всегда даёт значение ≤ «математически точного» кредита.
Это означает, что бизнес иногда получает на 1₽ больше, чем строго пропорционально. Это
намеренно и является стандартной практикой (дробных рублей не существует, округление в пользу
бизнеса корректно для платёжных систем).
Пример: `plan_price_paid=150, remaining_days=29, duration_days=30`
→ `credit = floor(150 × 29 / 30) = floor(145.0) = 145₽` (не 145₽ 0 коп).
---
### 6.5 Конкурентные покупки (двойное нажатие)
Обрабатывается FSM-блокировкой из FINANCE_REFINEMENT Фаза 2 (Bug 6): состояние
переключается на `SubscriptionStorage.checkout` **первым действием** в хендлере, что
делает повторный callback необрабатываемым.
Дополнительная защита на уровне БД: если два запроса всё же прошли одновременно,
`update_sub` работает по `user_id` (PK) — второй UPDATE применится поверх первого,
а не создаст дубль. Кредит может быть начислен дважды только при параллельном исполнении
обоих `buy_subscription` одновременно — FSM-lock предотвращает это.
---
### 6.6 Пользователь существует в БД, но отсутствует в Remnawave
**Сценарий:** Запись в `subscriptions` есть, но `get_user_by_telegram_id` вернул `None`
(пользователь удалён вручную из панели администратором).
**Поведение текущего кода:** Выполняется ветка `create_user` — создаётся новый RW-пользователь.
Это корректно. Запись в `subscriptions` будет обновлена с новым `rw_uuid`.
**Единственный риск:** Если у старого RW-пользователя было активное соединение, оно будет
потеряно. Пользователю нужно будет скачать новую конфигурацию. Это приемлемо как редкий
случай ручного вмешательства администратора.
---
## 7. Сводная таблица задач
| # | Задача | Фаза | Файлы | Зависимость | Сложность |
|---|--------|------|-------|-------------|-----------|
| SF-0 | Завершить P1, P2, P3 (FINANCE_REFINEMENT) | 0 | Различные | — | 🔴 Средняя |
| SF-1 | Создать `subscription_calculator.py` + `is_same_plan` | 1 | `services/subscription_calculator.py` | SF-0 | 🟡 Лёгкая |
| SF-2 | Добавить `calculate_remaining_credit` | 2 | `services/subscription_calculator.py` | SF-1 | 🟡 Лёгкая |
| SF-3 | Добавить `BuyResult` датакласс | 3 | `services/user_service.py` | SF-2 | 🟢 Мин |
| SF-4 | Переписать `buy_subscription` (3 ветки) | 3 | `services/user_service.py` | SF-3 | 🔴 Средняя |
| SF-5 | Добавить `increase_user_balance` в репозиторий | 3 | `repositories/users.py` | SF-0 | 🟢 Мин |
| SF-6 | Добавить `get_subscription_by_user_id` в репозиторий | 3 | `repositories/subscriptions.py` | SF-0 | 🟢 Мин |
| SF-7 | Обновить `proceed_to_checkout` (BuyResult + amount) | 4 | `bot/handlers/buy.py` | SF-4 | 🟡 Лёгкая |
| SF-8 | Добавить новые текстовые константы | 4 | `bot/texts.py` | SF-7 | 🟢 Мин |
| SF-9 | Обработка статусов EXPIRED/DISABLED в Ветке C | 5 | `services/user_service.py` | SF-4 | 🟢 Мин |
| SF-10 | Добавить `duration_days` в миграцию Alembic | 5 | `alembic/versions/` | SF-0 | 🟡 Лёгкая |
| SF-11 | Unit-тесты для `subscription_calculator.py` | — | `tests/` | SF-2 | 🟡 Лёгкая |
| SF-12 | Проверить `add_days(negative)` в RW на тест-инстансе | — | `services/rw.py` | SF-4 | 🟢 Мин |
**Обозначения сложности:**
- 🟢 Минимальная — 115 строк, без новых зависимостей
- 🟡 Лёгкая — до 60 строк, возможно новый файл или небольшой рефакторинг
- 🔴 Средняя — существенная переработка существующей логики или новая миграция
---
## 8. Рекомендуемый порядок реализации
### Спринт 1 — Предварительные условия + новый модуль (~12 дня)
Выполнить Фазу 0 (если FINANCE_REFINEMENT ещё не реализован) или убедиться, что
все три prerequisite выполнены. Затем:
- Создать `services/subscription_calculator.py` с `is_same_plan` и
`calculate_remaining_credit` (SF-1, SF-2).
- Написать unit-тесты для обеих функций (SF-11).
- Добавить `duration_days` в модель и миграцию (SF-10).
### Спринт 2 — Переработка `buy_subscription` (~12 дня)
- Добавить `BuyResult`, `increase_user_balance`, `get_subscription_by_user_id` (SF-3, SF-5, SF-6).
- Переписать `buy_subscription` с тремя ветками (SF-4).
- Добавить обработку EXPIRED/DISABLED (SF-9).
- Проверить `add_days(negative)` на тест-инстансе Remnawave (SF-12).
### Спринт 3 — UI и финализация (~0.5 дня)
- Обновить `proceed_to_checkout` для работы с `BuyResult` (SF-7).
- Добавить новые текстовые константы (SF-8).
- Сквозное тестирование всех трёх сценариев: создание, продление, замена.
---
> **Итоговый инвариант системы после реализации:**
>
> - Пользователь **никогда не получает бесплатный апгрейд** за счёт бизнеса.
> - Пользователь **никогда не теряет оплаченные дни** без компенсации.
> - Срок подписки при смене плана всегда равен `now + new_duration` (не аддитивен к остатку).
> - Кредит отображается явно — прозрачно для пользователя.
> - При недоступности Remnawave — состояние пользователя остаётся неизменным.
---
*Документ создан на основе анализа кодовой базы и описания проблемы. Актуализировать
при изменении API Remnawave или схемы тарификации.*