# 🔄 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 1–3 из 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 1–3):**
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 1–3):**
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` необходим для точного расчёта зачёта, потому что месяцы имеют разное
количество дней (28–31), а формула зачёта оперирует днями, а не месяцами.
Подробности — в [Секции 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 = (
"✅ Подписка активирована!\n\n"
"Используйте кнопку ниже для получения конфигурации."
)
# Новый — для чистого продления:
SUCCESSFULLY_RENEWED_SUBSCRIPTION = (
"✅ Подписка продлена!\n\n"
"Срок действия вашей текущей подписки увеличен."
)
# Новый — для замены плана с зачётом:
SUBSCRIPTION_REPLACED_WITH_CREDIT = (
"✅ Тариф обновлён!\n\n"
"На ваш баланс зачислено {credit}₽ за неиспользованные дни прежнего тарифа.\n"
"Новая подписка активна с сегодняшнего дня."
)
# Новый — для замены плана без зачёта (истёкшая):
SUBSCRIPTION_REACTIVATED = (
"✅ Подписка возобновлена!\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
```
Если шаги 4–6 падают, кредит уже на балансе, но подписка в RW не обновлена.
Пользователь не в убытке (деньги вернулись), а оператор получает ошибку в логах.
**Рекомендация:** Обернуть шаги 4–6 в отдельный `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 | 🟢 Мин |
**Обозначения сложности:**
- 🟢 Минимальная — 1–15 строк, без новых зависимостей
- 🟡 Лёгкая — до 60 строк, возможно новый файл или небольшой рефакторинг
- 🔴 Средняя — существенная переработка существующей логики или новая миграция
---
## 8. Рекомендуемый порядок реализации
### Спринт 1 — Предварительные условия + новый модуль (~1–2 дня)
Выполнить Фазу 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` (~1–2 дня)
- Добавить `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 или схемы тарификации.*