- 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.
1183 lines
64 KiB
Markdown
1183 lines
64 KiB
Markdown
# 🔄 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 = (
|
||
"✅ <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
|
||
```
|
||
|
||
Если шаги 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 или схемы тарификации.* |