# 🔄 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 или схемы тарификации.*