# 🏦 FINANCE_REFINEMENT — План рефинансирования системы управления подписками > **Цель документа:** пошаговый план перехода бота с фрагментарного состояния (баги + архитектурные > пробелы) к надёжной, аудируемой, внутренней системе управления балансом и подписками. > > Каждая фаза приоритизирована по принципу **«сначала не ломать, затем улучшать»**. > Фазы 1–2 устраняют критические баги, блокирующие корректную работу прямо сейчас. > Фазы 3–4 закладывают фундамент модели данных, необходимый для всех последующих фаз. > Фазы 5–7 добавляют новую функциональность поверх исправленной основы. --- ## Содержание 1. [Фаза 1 — Критические исправления бизнес-логики](#фаза-1) 2. [Фаза 2 — Финансовая безопасность: атомарность и защита от двойных списаний](#фаза-2) 3. [Фаза 3 — Расширение модели данных](#фаза-3) 4. [Фаза 4 — Архитектурная согласованность (DI)](#фаза-4) 5. [Фаза 5 — Система автопродления подписок](#фаза-5) 6. [Фаза 6 — Реферальная система: учёт покупок за баланс](#фаза-6) 7. [Фаза 7 — Синхронизация состояния с Remnawave](#фаза-7) --- ## Фаза 1 — Критические исправления бизнес-логики ### Цель Устранить баги, при которых базовый флоу покупки подписки **молча ломается**: подписка в Remnawave не продлевается, лимиты устройств не обновляются, а кнопка пополнения баланса вообще не работает. Все четыре проблемы этой фазы не требуют изменений схемы БД и реализуются правками в существующих файлах. **Приоритет: НЕМЕДЛЕННО** — без этих исправлений основной бизнес-процесс не работает. --- ### 1.1 Bug 1 — Неправильный аргумент в `rw.add_days` **Файл:** `services/user_service.py`, метод `UserService.buy_subscription` **Проблема:** ```python # БЫЛО (неправильно): await rw.add_days(rw_sdk, user.id, duration.days) # ↑ # user.id — это Telegram ID (int), например 123456789 # rw.add_days() ожидает UUID-строку вида "550e8400-e29b-41d4-a716-446655440000" # Внутри функция вызывает sdk.users.get_user_by_uuid(uuid=user_uuid), # что всегда завершается ошибкой, которая перехватывается и логируется как warning. # В итоге срок подписки в Remnawave НИКОГДА не продлевается. ``` **Исправление:** В ветке `if rw_user:` функции `buy_subscription` у нас уже есть объект `rw_user` типа `RWUserInfo`, который содержит поле `uuid` — корректный UUID пользователя в Remnawave. Нужно использовать его: ```python # СТАЛО (правильно): await rw.add_days(rw_sdk, rw_user.uuid, duration.days) # ↑ # rw_user.uuid — строковый UUID из Remnawave, например "550e8400-..." ``` **Конкретные шаги:** 1. Открыть `services/user_service.py`. 2. Найти строку `await rw.add_days(rw_sdk, user.id, duration.days)` в ветке `if rw_user:`. 3. Заменить `user.id` на `rw_user.uuid`. 4. Убедиться, что переменная `rw_user` объявлена выше этой строки (она уже есть — `rw_user = await rw.get_user_by_telegram_id(rw_sdk, user.id)`). --- ### 1.2 Bug 2 — Лимит HWID-устройств не обновляется при продлении **Файл:** `services/user_service.py`, метод `UserService.buy_subscription`, ветка `if rw_user:` **Проблема:** При продлении существующей подписки вызывается только `rw.add_days(...)`. Если пользователь менял количество устройств (например, покупал 6 вместо прежних 3), `hwid_device_limit` в Remnawave остаётся старым. Функция `rw.set_hwid_limit` уже существует в `services/rw.py`, но не вызывается. **Исправление:** Добавить вызов `rw.set_hwid_limit` сразу после `rw.add_days` в ветке продления: ```python # В ветке if rw_user: после вызова rw.add_days await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices) ``` **Конкретные шаги:** 1. Открыть `services/user_service.py`. 2. В ветке `if rw_user:` найти строку `await rw.add_days(rw_sdk, rw_user.uuid, duration.days)`. 3. Добавить **следующей строкой**: ```python await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices) ``` 4. Параметр `subscription.devices` уже доступен — это поле объекта `SubscriptionPlan`. --- ### 1.3 Bug 3 — Squad (Whitelist) не обновляется при продлении **Файл:** `services/user_service.py`, метод `UserService.buy_subscription`, ветка `if rw_user:` **Проблема:** При продлении squads пользователя в Remnawave не обновляются. Если пользователь добавил опцию `whitelists=True` при продлении, а до этого у него был только `DEFAULT` squad, он так и останется без whitelist squad. Функция `rw.update_squads` уже существует в `services/rw.py`. **Исправление:** Сформировать актуальный список squad UUID на основе `subscription.whitelists` и вызвать `rw.update_squads` при продлении: ```python # В ветке if rw_user: после rw.set_hwid_limit new_squads = [InternalSquads.DEFAULT] if subscription.whitelists: new_squads.append(InternalSquads.WHITELISTS) await rw.update_squads(rw_sdk, rw_user.uuid, [s for s in new_squads]) ``` **Конкретные шаги:** 1. Открыть `services/user_service.py`. 2. Убедиться, что `InternalSquads` уже импортирован (он есть в `schemas.subscriptions`). 3. В ветке `if rw_user:`, после `rw.set_hwid_limit`, добавить три строки выше. 4. Обратить внимание: `InternalSquads` — это `StrEnum`, значения которого являются UUID-строками из `config.py` (`general_squad`, `whitelist_squad`). Передавать их в `update_squads` напрямую как строки корректно. --- ### 1.4 Bug 4 — Отсутствует обработчик кнопки «💸 Пополнить» **Файлы:** - Создать или расширить: `bot/handlers/deposit.py` (новый файл) - Зарегистрировать роутер: `bot/handlers/__init__.py` - Клавиатура уже есть: `bot/keyboards/client.py` → `deposit_kb`, `payment_gateways` **Проблема:** В `bot/keyboards/client.py` определены кнопки с `callback_data="deposit"` (в `main_menu` и в `deposit_kb`). Ни в одном из роутеров (`buy.py`, `billing.py`, `menus.py`, `common.py`, `referals.py`, `support.py`, `ads.py`) нет обработчика `F.data == "deposit"`. При нажатии кнопки Telegram просто показывает крутящееся колёсико, бот не реагирует. **Исправление:** Создать файл `bot/handlers/deposit.py` с роутером, который: 1. Перехватывает `F.data == "deposit"`. 2. Отображает клавиатуру выбора платёжного провайдера (`payment_gateways`). 3. Устанавливает FSM-состояние `BillingStorage.pending` и сохраняет минимальную сумму пополнения в контексте. ```python # bot/handlers/deposit.py import logging from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery from bot.keyboards.client import payment_gateways from bot.states.buy import BillingStorage from schemas.billing import BillingContext router = Router() logger = logging.getLogger(__name__) DEPOSIT_PROMPT = ( "💳 Пополнение баланса\n\n" "Выберите способ оплаты. После оплаты баланс будет зачислен автоматически." ) @router.callback_query(F.data == "deposit") async def deposit_init(cb: CallbackQuery, state: FSMContext): await state.clear() # Устанавливаем состояние ожидания выбора провайдера. # BillingContext.amount = 0 — сумму пользователь выберет или введёт на следующем шаге. # Если в будущем добавить ввод произвольной суммы — добавить отдельный State. billing_ctx = BillingContext(amount=0) await state.set_state(BillingStorage.pending) await state.set_data({"ctx": billing_ctx}) await cb.message.edit_text(DEPOSIT_PROMPT, reply_markup=payment_gateways) ``` **Конкретные шаги:** 1. Создать файл `bot/handlers/deposit.py` с содержимым выше. 2. В `bot/handlers/__init__.py` импортировать и зарегистрировать роутер `deposit.router`. Пример: ```python from bot.handlers.deposit import router as deposit_router # и добавить его в список роутеров вместе с остальными ``` 3. Уточнить логику `BillingContext.amount`: если сейчас `billing.py` использует `ctx.amount` для создания счёта, нужно либо добавить ввод суммы пользователем (State для ввода числа), либо временно хардкодить минимальную сумму (например, 100₽). Это решение принимает команда. --- ## Фаза 2 — Финансовая безопасность: атомарность и защита от двойных списаний ### Цель Гарантировать, что деньги пользователя не будут списаны без предоставления подписки (Bug 5) и что одна покупка не спишет баланс дважды (Bug 6). Обе проблемы решаются на уровне логики хендлера без изменения схемы БД. **Приоритет: ВЫСОКИЙ** — прямые финансовые потери для пользователей или для сервиса. --- ### 2.1 Bug 5 — Отсутствие атомарности: деньги списываются до создания подписки **Файл:** `bot/handlers/buy.py`, хендлер `proceed_to_checkout` **Проблема:** ```python # ТЕКУЩИЙ КОД (опасный): user = await deps.user_repository.decrease_user_balance(session, user.id, amount) # ← Баланс уже списан. Если следующая строка упадёт — деньги потеряны. await deps.user_service.buy_subscription(session, ctx, deps.rw_sdk, cb.from_user) ``` Если Remnawave недоступен или `buy_subscription` выбрасывает исключение, баланс пользователя уже уменьшен, а подписка не создана. **Исправление:** Инвертировать порядок: сначала попытаться создать подписку, затем списать баланс. Если создание подписки упало — ни баланс, ни подписка не затронуты. Если списание упало после успешного создания подписки — выполнить компенсирующую транзакцию (вернуть деньги). ```python # ИСПРАВЛЕННЫЙ КОД в proceed_to_checkout: amount = calculate_price(ctx) user = await deps.user_repository.get_user_by_id(session, cb.from_user.id) if user.balance < amount: await cb.message.edit_text( INSUFFICIENT_FUNDS.format(balance=user.balance, amount=amount), reply_markup=deposit_kb, ) return # 1. Сначала попытаться создать/продлить подписку в Remnawave. # Если это упадёт — баланс не тронут, пользователь видит ошибку. try: await deps.user_service.buy_subscription(session, ctx, deps.rw_sdk, cb.from_user) except Exception: logger.exception( "buy_subscription failed for user_id=%s, amount=%s. Balance NOT deducted.", cb.from_user.id, amount, ) await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu) return # 2. Только после успешного создания подписки — списать баланс. deducted_user = await deps.user_repository.decrease_user_balance(session, user.id, amount) if not deducted_user: # Крайне редкий случай: подписка создана, но списание не прошло. # Логируем как критическую ошибку для ручной reconciliation. logger.critical( "RECONCILIATION NEEDED: subscription created for user_id=%s " "but balance deduction FAILED. Amount=%s.", cb.from_user.id, amount, ) # Пользователю всё равно показываем успех — подписка у него есть. await cb.message.edit_text(SUCCESSFULLY_CREATED_SUBSCRIPTION, reply_markup=return_to_menu) ``` **Конкретные шаги:** 1. Открыть `bot/handlers/buy.py`. 2. В хендлере `proceed_to_checkout` переместить `decrease_user_balance` **после** вызова `buy_subscription`, обернув `buy_subscription` в `try/except`. 3. Добавить импорт `logger = logging.getLogger(__name__)` если его нет (он уже есть). 4. Добавить текст `STANDARD_FALLBACK` в импорты из `bot.texts` (он уже импортирован). > **Примечание о полной атомарности:** Идеальное решение — использовать Postgres транзакцию > с `SAVEPOINT` или паттерн «двухфазного коммита». Это реализуется в Фазе 3 после введения > таблицы `BalanceTransaction`. До тех пор описанный выше подход кратно безопаснее текущего. --- ### 2.2 Bug 6 — Двойное списание: нет защиты от повторных нажатий **Файл:** `bot/handlers/buy.py`, хендлер `proceed_to_checkout` **Проблема:** Если пользователь быстро нажмёт кнопку «Подтвердить» дважды (или отправит дублирующий callback), два запроса попадут в хендлер почти одновременно. `decrease_user_balance` выполнится дважды до того, как первый коммит завершится, и баланс спишется двойной суммой. **Исправление:** Установить FSM-состояние `SubscriptionStorage.checkout` **в самом начале хендлера**, до любых операций. Повторный callback не пройдёт, так как состояние уже изменилось и хендлер `SubscriptionStorage.duration, F.data == "confirm"` не сработает. ```python # В начале proceed_to_checkout — ПЕРВЫМ ДЕЙСТВИЕМ: @router.callback_query(SubscriptionStorage.duration, F.data == "confirm") async def proceed_to_checkout( cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO ): data = await state.get_data() # Немедленно переключаем состояние, чтобы заблокировать повторные нажатия. await state.set_state(SubscriptionStorage.checkout) 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 # ... далее вся логика checkout ... await state.clear() # Очистить состояние после успешного завершения ``` **Конкретные шаги:** 1. Открыть `bot/handlers/buy.py`. 2. В начале `proceed_to_checkout`, сразу после `data = await state.get_data()`, добавить `await state.set_state(SubscriptionStorage.checkout)`. 3. Убедиться, что в `bot/states/buy.py` в классе `SubscriptionStorage` уже есть состояние `checkout = State()` — оно есть, можно использовать. 4. В конце хендлера (как при успехе, так и при ошибке) вызывать `await state.clear()`. 5. Дополнительно: добавить `await cb.answer()` с `cache_time=5` при получении повторного callback в состоянии `checkout`, чтобы убрать крутящийся индикатор у пользователя. --- ## Фаза 3 — Расширение модели данных ### Цель Устранить структурные пробелы в схеме БД: добавить недостающие поля в `Subscription`, создать таблицу аудита балансовых операций, отвязать модель `Bill` от Pally, и зафиксировать денежную деноминацию. Эта фаза является **обязательным фундаментом** для Фазы 5 (автопродление) и существенно упрощает будущую реализацию тарификации. **Приоритет: ВЫСОКИЙ** — без этих изменений невозможно реализовать автопродление и аудит. Все задачи этой фазы требуют миграции Alembic. --- ### 3.1 Gap 7 — Модель `Subscription` не хранит параметры плана **Файл:** `db/models/subscriptions.py` **Проблема:** Таблица `subscriptions` содержит только `user_id`, `end_date`, `autorenew`, `status`. Без хранения параметров активного плана невозможно: - Автоматически продлить подписку (неизвестно сколько устройств и какой whitelist был) - Рассчитать доплату при апгрейде плана (пропорциональный пересчёт) - Показать пользователю «текущий план» без запроса к Remnawave **Исправление:** Добавить в модель `Subscription` следующие поля: ```python # db/models/subscriptions.py — добавить поля: from sqlalchemy import INTEGER, TEXT, Numeric # Новые колонки: devices: Mapped[int] = mapped_column(INTEGER, nullable=False, default=1) # Количество HWID-устройств в плане. Нужно для автопродления. duration_months: Mapped[int] = mapped_column(INTEGER, nullable=False, default=1) # Длительность последнего оплаченного периода в месяцах (1, 3, 6, 12). # Нужно для автопродления — повторить ту же длительность. 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] = mapped_column(TEXT, nullable=True, unique=True) # UUID пользователя в Remnawave. Хранить здесь, а не только в User.subscription_link, # чтобы не парсить ссылку каждый раз. # Nullable=True для обратной совместимости (заполняется при первой покупке/синхронизации). ``` **Конкретные шаги:** 1. Открыть `db/models/subscriptions.py`. 2. Добавить 5 новых колонок (см. выше) в класс `Subscription`. 3. Добавить недостающие импорты: `Boolean` уже есть, `INTEGER` — добавить, `TEXT` — добавить. 4. Обновить `repositories/subscriptions.py`: - В `create_subscription`: принимать и сохранять `devices`, `duration_months`, `plan_price_paid`, `whitelists`, `rw_uuid`. - В `update_sub`: принимать и обновлять те же поля (добавить как keyword-only аргументы с дефолтными значениями для обратной совместимости). 5. Обновить `services/user_service.py`, метод `buy_subscription`: - Передавать `devices=subscription.devices`, `duration_months=convert_duration_to_int(subscription.duration)`, `plan_price_paid=amount` (получать `amount` как параметр метода или вычислять внутри), `whitelists=subscription.whitelists`, `rw_uuid=rw_user.uuid` (при продлении) или `rw_user.uuid` (при создании из нового `rw_user`). 6. Сигнатура `buy_subscription` должна принять `amount: int` как параметр (его вычисляет `proceed_to_checkout` из `calculate_price(ctx)` — передавать явно). 7. Создать миграцию: `alembic revision --autogenerate -m "add_plan_details_to_subscriptions"`. 8. Проверить и применить: `alembic upgrade head`. --- ### 3.2 Gap 8 — Отсутствует таблица аудита балансовых операций **Файл:** создать `db/models/balance_transactions.py` **Проблема:** Баланс изменяется через прямые `user.balance += amount` / `user.balance -= amount` без записи истории. При спорной ситуации (пользователь говорит «у меня списали дважды») невозможно проверить, что произошло. **Исправление:** Создать таблицу `balance_transactions`: ```python # db/models/balance_transactions.py from datetime import datetime from enum import StrEnum from typing import TYPE_CHECKING from sqlalchemy import BIGINT, INTEGER, TEXT, DateTime, Enum, ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from db.base import Base if TYPE_CHECKING: from db.models.users import User class BalanceTxType(StrEnum): DEPOSIT = "deposit" # Пополнение через платёжный провайдер PURCHASE = "purchase" # Списание за покупку подписки REFUND = "refund" # Возврат (компенсация при ошибке) REFERRAL_BONUS = "referral" # Реферальный бонус MANUAL = "manual" # Ручная корректировка администратором class BalanceTransaction(Base): __tablename__ = "balance_transactions" id: Mapped[int] = mapped_column(BIGINT, autoincrement=True, primary_key=True) user_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False, index=True) amount: Mapped[int] = mapped_column(INTEGER, nullable=False) # amount > 0 → приход (deposit, refund, referral) # amount < 0 → расход (purchase) tx_type: Mapped[BalanceTxType] = mapped_column( Enum(BalanceTxType), nullable=False ) balance_before: Mapped[int] = mapped_column(INTEGER, nullable=False) balance_after: Mapped[int] = mapped_column(INTEGER, nullable=False) description: Mapped[str] = mapped_column(TEXT, nullable=True) # Человекочитаемое описание: "Покупка: 3 уст., 1 мес.", "Депозит Pally #12345" и т.д. created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=lambda: datetime.now() ) user: Mapped["User"] = relationship("User", back_populates="balance_transactions") ``` **Конкретные шаги:** 1. Создать `db/models/balance_transactions.py` с содержимым выше. 2. В `db/models/users.py` добавить relationship: ```python from db.models.balance_transactions import BalanceTransaction balance_transactions: Mapped[list["BalanceTransaction"]] = relationship( "BalanceTransaction", back_populates="user", cascade="all, delete" ) ``` 3. В `db/models/__init__.py` экспортировать `BalanceTransaction` и `BalanceTxType`. 4. Создать `repositories/balance_transactions.py` с классом `BalanceTransactionRepository`: - Метод `create(session, user_id, amount, tx_type, balance_before, balance_after, description)`. - Метод `get_by_user_id(session, user_id, limit=50)` для истории. 5. Обновить `repositories/users.py` — в `increase_user_balance` и `decrease_user_balance` добавить запись в `balance_transactions`. Для этого репозиторий пользователей должен либо принимать `BalanceTransactionRepository` в параметрах метода, либо создавать запись напрямую через `session.add(BalanceTransaction(...))`. 6. Обновить `api/routes/pally.py`: передавать `description="Депозит Pally bill_id={bill_id}"`, `tx_type=BalanceTxType.DEPOSIT` при пополнении, и `tx_type=BalanceTxType.REFERRAL_BONUS` при зачислении реферального бонуса. 7. Обновить `bot/handlers/buy.py` (`proceed_to_checkout`): при списании передавать `tx_type=BalanceTxType.PURCHASE` и `description` с параметрами плана. 8. Создать и применить миграцию: `alembic revision --autogenerate -m "add_balance_transactions"`. --- ### 3.3 Gap 12 — Модель `Bill` жёстко привязана к Pally **Файл:** `db/models/bills.py`, `misc/pally.py` **Проблема:** `Bill.status` использует `BillStatus` из `misc.pally`: ```python from misc.pally import BillStatus ``` Это значит, что модель БД зависит от конкретного платёжного провайдера. При добавлении нового провайдера (криптовалюты, ЮKassa и т.д.) возникнет конфликт статусов или придётся менять схему. **Исправление:** Ввести провайдеронезависимый `DepositStatus` и расширить `Bill` полем `provider`: 1. Создать `schemas/billing.py` (или расширить существующий): ```python # Добавить в schemas/billing.py: from enum import StrEnum class DepositStatus(StrEnum): PENDING = "pending" # Счёт создан, ожидает оплаты SUCCESS = "success" # Оплачен успешно FAILED = "failed" # Истёк или отменён EXPIRED = "expired" # Истёк по таймауту class DepositProvider(StrEnum): PALLY = "pally" MANUAL = "manual" # Ручное пополнение администратором ``` 2. Обновить `db/models/bills.py`: ```python # Заменить: from misc.pally import BillStatus # На: from schemas.billing import DepositStatus, DepositProvider # В классе Bill изменить: status: Mapped[DepositStatus] = mapped_column( Enum(DepositStatus), nullable=False, default=DepositStatus.PENDING ) provider: Mapped[DepositProvider] = mapped_column( Enum(DepositProvider), nullable=False, default=DepositProvider.PALLY ) ``` 3. Обновить `api/routes/pally.py`: заменить `BillStatus.SUCCESS` на `DepositStatus.SUCCESS`. 4. Обновить `services/billing_service.py`: использовать `DepositStatus.PENDING` при создании. 5. Обновить `repositories/bills.py`: методы `create` и `get_bill_by_id` не требуют изменений, но нужно убрать импорт `BillStatus` из `misc.pally` если он там есть. 6. Создать и применить миграцию: `alembic revision --autogenerate -m "decouple_bill_from_pally"`. --- ### 3.4 Gap 14 — Неопределённая деноминация поля `balance` **Файлы:** `db/models/users.py`, `repositories/users.py`, `config.py` **Проблема:** `User.balance: Mapped[int] = mapped_column(INTEGER, ...)` не имеет явного указания деноминации. По коду можно предположить, что это **рубли целым числом** (цены в `config.py` — `per_device_cost=50`, `whitelist_cost=100`). Но нет ни комментариев, ни соглашения. При добавлении частичных платежей или криптовалют с дробными суммами это станет источником ошибок. **Исправление:** Зафиксировать соглашение явно в трёх местах: 1. В `db/models/users.py` добавить комментарий к колонке: ```python balance: Mapped[int] = mapped_column( INTEGER, nullable=False, default=0, comment="Баланс пользователя в РУБЛЯХ (целые числа). " "Дробные значения НЕ поддерживаются. " "Минимальная единица = 1 рубль.", ) ``` 2. В `config.py` добавить комментарий рядом с ценовыми параметрами: ```python # Все ценовые параметры указаны в РУБЛЯХ (целые числа). per_device_cost: int = Field(50, alias="PER_DEVICE_COST") # ₽/устройство/месяц whitelist_cost: int = Field(100, alias="WHITELIST_COST") # ₽/месяц за whitelist опцию ``` 3. Создать `docs/CONVENTIONS.md` (или добавить раздел в `README.md`) с явным описанием: > **Деноминация баланса:** все балансовые операции производятся в **целых рублях (RUB)**. > Дробные суммы округляются вверх (`math.ceil`) при расчёте цены подписки. > Если в будущем потребуется поддержка копеек — выполнить миграцию умножением всех > балансов на 100 и изменением всех ценовых параметров. 4. **Важно:** не менять тип колонки в БД в этой фазе — только зафиксировать соглашение. Если в будущем потребуются дробные суммы — отдельный тикет с полной миграцией. --- ## Фаза 4 — Архитектурная согласованность (DI) ### Цель Устранить несоответствие между `DependenciesDTO` (бот) и `APIDependenciesDTO` (API): у API-слоя нет доступа к `rw_sdk` и `subscriptions_repository`, что блокирует любые операции с Remnawave на стороне API без рефакторинга. **Приоритет: СРЕДНИЙ** — не ломает текущий код, но заблокирует реализацию синхронизации (Фаза 7) и другие API-endpoint'ы, которые потребуют Remnawave. --- ### 4.1 Gap 11 — `APIDependenciesDTO` не содержит `rw_sdk` и `subscriptions_repository` **Файлы:** `schemas/di.py`, `api/core/deps.py` (если существует), `api/main.py` **Проблема:** ```python @dataclass class APIDependenciesDTO: user_repository: UserRepository user_service: UserService bills_repository: BillsRepository billing_service: BillingService # ← Нет rw_sdk, нет subscriptions_repository ``` `DependenciesDTO` (для бота) содержит оба поля. API-маршруты (`api/routes/pally.py`) получают `APIDependenciesDTO` через `Depends(get_deps)`, и если понадобится выполнить операцию с Remnawave (например, в новом webhook или admin endpoint), придётся дублировать инициализацию. **Исправление:** Расширить `APIDependenciesDTO` недостающими полями: ```python # schemas/di.py from remnawave import RemnawaveSDK from repositories.subscriptions import SubscriptionRepository @dataclass class APIDependenciesDTO: user_repository: UserRepository user_service: UserService bills_repository: BillsRepository billing_service: BillingService subscriptions_repository: SubscriptionRepository # ← добавить rw_sdk: RemnawaveSDK # ← добавить ``` **Конкретные шаги:** 1. Открыть `schemas/di.py`. 2. Добавить импорты `RemnawaveSDK` и `SubscriptionRepository`. 3. Добавить два поля в `APIDependenciesDTO`. 4. Найти функцию `get_deps` в `api/core/deps.py` (или там, где инициализируется `APIDependenciesDTO`) и передать в неё `rw_sdk` и `subscriptions_repository`. Инстанс `RemnawaveSDK` скорее всего создаётся при старте приложения — использовать тот же инстанс через `app.state` или dependency injection FastAPI. 5. Проверить, что существующий `api/routes/pally.py` не сломался (он не использует новые поля, так что просто добавление полей обратно совместимо). --- ## Фаза 5 — Система автопродления подписок ### Цель Реализовать автоматическое продление истекающих подписок. Это требует: планировщика задач, логики проверки баланса, списания, продления в Remnawave, и уведомлений пользователей. **Приоритет: СРЕДНИЙ** — не критично прямо сейчас, но является ключевой бизнес-функцией. **Зависит от:** Фазы 3 (поля `devices`, `duration_months`, `whitelists`, `rw_uuid` в `Subscription`) и Фазы 3.2 (таблица `BalanceTransaction` для аудита списаний). --- ### 5.1 Gap 9 — Автопродление не реализовано **Новые файлы:** - `services/renewal_service.py` — логика проверки и продления - `entrypoints/scheduler.py` — запуск APScheduler - Обновить `requirements.txt`: добавить `apscheduler>=3.10` **Архитектура:** APScheduler запускается как отдельный процесс (или в рамках того же процесса бота) и каждые N часов (рекомендуется 6–12 часов) вызывает задачу проверки истекающих подписок. ``` Задача renewal_job(): 1. Запросить из БД все подписки где: - autorenew = True - status = ACTIVE - end_date <= NOW() + 3 дня (уведомлять заранее) 2. Для каждой такой подписки: a. Вычислить стоимость продления (devices, duration_months, whitelists → calculate_price) b. Проверить баланс пользователя c. Если баланс достаточен → списать → продлить в Remnawave → обновить end_date в БД d. Если баланс недостаточен → отправить уведомление за 3 дня, за 1 день, в день X e. Если end_date уже прошла → status = EXPIRED → disable_user в Remnawave ``` **Создать `services/renewal_service.py`:** ```python # services/renewal_service.py import logging from datetime import UTC, datetime, timedelta from aiogram import Bot from remnawave import RemnawaveSDK from sqlalchemy.ext.asyncio import AsyncSession from db.models.balance_transactions import BalanceTxType from db.models.subscriptions import Subscription, SubscriptionStatus from misc import utils from repositories.subscriptions import SubscriptionRepository from repositories.users import UserRepository from services import rw logger = logging.getLogger(__name__) RENEWAL_NOTICE_DAYS = 3 # За сколько дней предупреждать RENEWAL_TEXT_SUCCESS = "✅ Подписка автоматически продлена на {months} мес. Списано {amount}₽." RENEWAL_TEXT_LOW_BALANCE = ( "⚠️ Через {days} дн. истекает подписка. " "Для автопродления нужно {needed}₽, на балансе {balance}₽. " "Пополните баланс: /start" ) RENEWAL_TEXT_EXPIRED = "❌ Подписка истекла. Пополните баланс и купите новую: /start" class RenewalService: def __init__( self, user_repo: UserRepository, sub_repo: SubscriptionRepository, ): self.user_repo = user_repo self.sub_repo = sub_repo async def run_renewal_cycle( self, session: AsyncSession, bot: Bot, rw_sdk: RemnawaveSDK ) -> None: now = datetime.now(UTC) notice_threshold = now + timedelta(days=RENEWAL_NOTICE_DAYS) # Получить все подписки, которые истекают в ближайшие RENEWAL_NOTICE_DAYS дней # или уже истекли (нужен метод в репозитории — см. ниже) expiring = await self.sub_repo.get_expiring_subscriptions( session, before=notice_threshold ) for sub in expiring: try: await self._process_subscription(session, bot, rw_sdk, sub, now) except Exception: logger.exception( "Renewal cycle error for user_id=%s", sub.user_id ) async def _process_subscription( self, session: AsyncSession, bot: Bot, rw_sdk: RemnawaveSDK, sub: Subscription, now: datetime, ) -> None: user = await self.user_repo.get_user_by_id(session, sub.user_id) if not user: return # Гарантируем timezone-aware end_date = sub.end_date if end_date.tzinfo is None: end_date = end_date.replace(tzinfo=UTC) # 1. Подписка уже истекла → деактивировать if end_date < now: if sub.rw_uuid: await rw.disable_user(rw_sdk, sub.rw_uuid) await self.sub_repo.update_sub( session, sub.user_id, end_date=end_date, autorenew=sub.autorenew, status=SubscriptionStatus.EXPIRED, devices=sub.devices, duration_months=sub.duration_months, plan_price_paid=sub.plan_price_paid, whitelists=sub.whitelists, rw_uuid=sub.rw_uuid, ) try: await bot.send_message(sub.user_id, RENEWAL_TEXT_EXPIRED) except Exception: pass return # 2. Подписка истекает в ближайшие дни days_left = (end_date - now).days if not sub.autorenew: # Не автопродление — только уведомить if days_left <= RENEWAL_NOTICE_DAYS: try: await bot.send_message( sub.user_id, f"⏰ До конца подписки осталось {days_left} дн. " f"Продлите её через бот: /start", ) except Exception: pass return # 3. autorenew=True — пытаемся продлить # Вычислить стоимость: нужен SubscriptionPlan-объект from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan plan = SubscriptionPlan( devices=sub.devices, duration=utils.convert_int_to_duration(sub.duration_months), whitelists=sub.whitelists, discount=1, # без скидок при автопродлении (решение команды) ) amount = utils.calculate_price(plan) if user.balance < amount: try: await bot.send_message( sub.user_id, RENEWAL_TEXT_LOW_BALANCE.format( days=days_left, needed=amount, balance=user.balance ), ) except Exception: pass return # Списать и продлить if not sub.rw_uuid: logger.error("Cannot renew sub for user_id=%s: rw_uuid is None", sub.user_id) return duration_delta = utils.convert_duration_to_timedelta(plan.duration) await rw.add_days(rw_sdk, sub.rw_uuid, duration_delta.days) await self.user_repo.decrease_user_balance(session, user.id, amount) new_end_date = end_date + duration_delta await self.sub_repo.update_sub( session, sub.user_id, end_date=new_end_date, autorenew=True, status=SubscriptionStatus.ACTIVE, devices=sub.devices, duration_months=sub.duration_months, plan_price_paid=amount, whitelists=sub.whitelists, rw_uuid=sub.rw_uuid, ) logger.info( "Auto-renewed subscription for user_id=%s, amount=%s, new_end=%s", sub.user_id, amount, new_end_date, ) try: await bot.send_message( sub.user_id, RENEWAL_TEXT_SUCCESS.format(months=sub.duration_months, amount=amount), ) except Exception: pass ``` **Обновить `repositories/subscriptions.py`:** Добавить метод `get_expiring_subscriptions`: ```python async def get_expiring_subscriptions( self, session: AsyncSession, before: datetime ) -> list[Subscription]: """Вернуть подписки, у которых end_date <= before и status = ACTIVE.""" stmt = ( select(Subscription) .where( Subscription.end_date <= before, Subscription.status == SubscriptionStatus.ACTIVE, ) ) res = await session.execute(stmt) return list(res.scalars().all()) ``` **Создать `entrypoints/scheduler.py`:** ```python # entrypoints/scheduler.py import asyncio import logging from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger from db.session import get_session # или аналогичная фабрика сессий from repositories.subscriptions import SubscriptionRepository from repositories.users import UserRepository from services.renewal_service import RenewalService # bot и rw_sdk инициализировать так же, как в bot/main.py logger = logging.getLogger(__name__) async def renewal_job(bot, rw_sdk): user_repo = UserRepository() sub_repo = SubscriptionRepository() service = RenewalService(user_repo, sub_repo) async with get_session() as session: await service.run_renewal_cycle(session, bot, rw_sdk) def start_scheduler(bot, rw_sdk): scheduler = AsyncIOScheduler() scheduler.add_job( renewal_job, trigger=IntervalTrigger(hours=6), args=[bot, rw_sdk], id="renewal_cycle", replace_existing=True, misfire_grace_time=300, # 5 минут — если задача пропущена, всё равно запустить ) scheduler.start() logger.info("Renewal scheduler started (interval=6h)") return scheduler ``` **Конкретные шаги:** 1. Добавить `apscheduler>=3.10` в `requirements.txt`. 2. Создать `services/renewal_service.py`. 3. Добавить `get_expiring_subscriptions` в `repositories/subscriptions.py`. 4. Создать `entrypoints/scheduler.py`. 5. Интегрировать вызов `start_scheduler(bot, rw_sdk)` в точку запуска бота (`bot/main.py` или `entrypoints/bot.py` — где инициализируется dispatcher). 6. Добавить кнопку «🔄 Автопродление» в меню подписки, которая переключает `sub.autorenew`. Соответствующий хендлер: `F.data == "autorenew:toggle"` → инвертировать `sub.autorenew` → показать подтверждение. --- ## Фаза 6 — Реферальная система: учёт покупок за баланс ### Цель Реферальный бонус сейчас начисляется **только при пополнении баланса через Pally**. Если реферал использует накопленный баланс для покупки подписки, реферер не получает бонус. Это создаёт несоответствие: при одинаковом экономическом результате (реферал заплатил за VPN) реферер получает бонус только в части случаев. **Приоритет: НИЗКИЙ** — не блокирует работу, но влияет на справедливость реферальной системы. **Зависит от:** Фазы 3.2 (таблица `BalanceTransaction` — нужна для запрета двойного начисления). --- ### 6.1 Gap 10 — Реферальный бонус не начисляется при покупке за баланс **Файл:** `bot/handlers/buy.py`, хендлер `proceed_to_checkout` **Проблема:** В `api/routes/pally.py` есть логика: ```python referal = bill.user.referal_id if referal is not None: referal_amount = math.floor(amount * (settings.referal_bonus / 100)) await deps.user_repository.increase_user_balance(session, referal, referal_amount) ``` Но в `proceed_to_checkout` (покупка за баланс) аналогичной логики нет. **Решение:** Добавить начисление реферального бонуса в `proceed_to_checkout` после успешного списания. **Архитектурный выбор — две стратегии:** **Стратегия A (простая):** Начислять бонус реферу при каждой покупке за баланс, независимо от источника пополнения. Минус: если пользователь пополнил через Pally и сразу купил, реферер получит бонус дважды (при пополнении и при покупке). **Стратегия B (рекомендуемая):** Начислять бонус рефереру только при покупке, но **убрать** начисление при пополнении через Pally. Реферальный бонус = % от фактических трат на VPN, а не от пополнений. Это честнее и проще в аудите. **Реализация Стратегии B:** 1. В `api/routes/pally.py` — убрать блок начисления реферального бонуса при пополнении. 2. В `bot/handlers/buy.py`, `proceed_to_checkout`, после успешного `decrease_user_balance`: ```python # Начисление реферального бонуса user_full = await deps.user_repository.get_user_by_id(session, cb.from_user.id) if user_full.referal_id is not None: referal_bonus_amount = math.floor(amount * (settings.referal_bonus / 100)) if referal_bonus_amount > 0: await deps.user_repository.increase_user_balance( session, user_full.referal_id, referal_bonus_amount ) # Записать в BalanceTransaction с tx_type=REFERRAL_BONUS logger.info( "Referral bonus %s credited to user_id=%s (referral of %s)", referal_bonus_amount, user_full.referal_id, cb.from_user.id, ) # Отправить уведомление рефереру (опционально) try: await cb.bot.send_message( user_full.referal_id, f"🎁 Ваш реферал купил подписку. " f"Вам начислен бонус {referal_bonus_amount}₽." ) except Exception: pass ``` 3. Добавить импорт `import math` в `bot/handlers/buy.py` (он уже есть). **Конкретные шаги:** 1. Принять решение о стратегии (A или B) — зафиксировать в комментарии кода. 2. Реализовать выбранную стратегию. 3. При выборе B: протестировать что при пополнении через Pally реферальный бонус больше не начисляется (регрессионный тест). 4. Добавить запись в `BalanceTransaction` для реферальных бонусов (для аудита). --- ## Фаза 7 — Синхронизация состояния с Remnawave ### Цель Устранить возможную рассинхронизацию между БД бота и состоянием в Remnawave. Если администратор вручную изменяет данные пользователя в панели Remnawave (меняет дату, устройства, отключает), бот об этом не знает и показывает устаревшие данные. **Приоритет: НИЗКИЙ** — актуально при активном ручном управлении через панель Remnawave. Может быть реализован как периодическая задача в рамках того же APScheduler из Фазы 5. **Зависит от:** Фазы 3.1 (поле `rw_uuid` в `Subscription`), Фазы 4 (DI в API). --- ### 7.1 Gap 13 — Нет механизма синхронизации состояния из Remnawave **Новый файл:** `services/sync_service.py` **Стратегия синхронизации:** Поскольку Remnawave является источником истины для статуса и дат подписки, периодически (раз в 1–4 часа) вытягивать актуальное состояние всех пользователей из Remnawave и обновлять `Subscription` в локальной БД. **Сценарии рассинхронизации, которые нужно обработать:** | Ситуация в Remnawave | Действие в БД бота | |---|---| | `expire_at` изменён вручную | Обновить `end_date` в `Subscription` | | Статус изменён на `DISABLED` | Обновить `status = EXPIRED` в `Subscription` | | `hwid_device_limit` изменён | Обновить `devices` в `Subscription` | | Пользователь удалён из Remnawave | `status = EXPIRED`, `rw_uuid = None` | | Пользователь добавлен в Remnawave вручную | Создать `Subscription` в БД бота | **Создать `services/sync_service.py`:** ```python # services/sync_service.py import logging from datetime import UTC, datetime from remnawave import RemnawaveSDK from sqlalchemy.ext.asyncio import AsyncSession from db.models.subscriptions import SubscriptionStatus from repositories.subscriptions import SubscriptionRepository from repositories.users import UserRepository from services import rw logger = logging.getLogger(__name__) class SyncService: def __init__( self, user_repo: UserRepository, sub_repo: SubscriptionRepository, ): self.user_repo = user_repo self.sub_repo = sub_repo async def sync_all_users( self, session: AsyncSession, rw_sdk: RemnawaveSDK ) -> dict[str, int]: """ Синхронизировать состояние всех подписок с Remnawave. Возвращает статистику: {"synced": N, "expired": M, "errors": K}. """ stats = {"synced": 0, "expired": 0, "errors": 0} users = await self.user_repo.get_users(session) for user in users: try: rw_user = await rw.get_user_by_telegram_id(rw_sdk, user.id) sub = await self.sub_repo.get_subscription_by_user_id(session, user.id) if rw_user is None: # Пользователя нет в Remnawave if sub and sub.status == SubscriptionStatus.ACTIVE: await self.sub_repo.update_sub( session, user.id, end_date=sub.end_date, autorenew=False, status=SubscriptionStatus.EXPIRED, devices=sub.devices, duration_months=sub.duration_months, plan_price_paid=sub.plan_price_paid, whitelists=sub.whitelists, rw_uuid=None, ) stats["expired"] += 1 continue # Определить актуальный статус rw_status_map = { "ACTIVE": SubscriptionStatus.ACTIVE, "DISABLED": SubscriptionStatus.EXPIRED, "EXPIRED": SubscriptionStatus.EXPIRED, "LIMITED": SubscriptionStatus.EXPIRED, "ON_HOLD": SubscriptionStatus.EXPIRED, } new_status = rw_status_map.get(rw_user.status, SubscriptionStatus.EXPIRED) # Нормализовать дату expire_at = rw_user.expire_at if expire_at.tzinfo is None: expire_at = expire_at.replace(tzinfo=UTC) if sub: # Обновить существующую запись await self.sub_repo.update_sub( session, user.id, end_date=expire_at, autorenew=sub.autorenew, status=new_status, devices=rw_user.hwid_device_limit or sub.devices, duration_months=sub.duration_months, plan_price_paid=sub.plan_price_paid, whitelists=sub.whitelists, rw_uuid=rw_user.uuid, ) else: # Пользователь есть в Remnawave, но нет в нашей БД # (добавлен вручную через панель) await self.sub_repo.create_subscription( session, user_id=user.id, end_date=expire_at, autorenew=False, status=new_status, devices=rw_user.hwid_device_limit or 1, duration_months=1, plan_price_paid=0, whitelists=False, rw_uuid=rw_user.uuid, ) stats["synced"] += 1 except Exception: logger.exception("Sync error for user_id=%s", user.id) stats["errors"] += 1 logger.info("Sync complete: %s", stats) return stats ``` **Интеграция в планировщик:** В `entrypoints/scheduler.py` добавить вторую задачу: ```python from services.sync_service import SyncService async def sync_job(rw_sdk): user_repo = UserRepository() sub_repo = SubscriptionRepository() service = SyncService(user_repo, sub_repo) async with get_session() as session: await service.sync_all_users(session, rw_sdk) # В start_scheduler: scheduler.add_job( sync_job, trigger=IntervalTrigger(hours=2), args=[rw_sdk], id="remnawave_sync", replace_existing=True, misfire_grace_time=120, ) ``` **Admin-endpoint для ручного запуска синхронизации:** Добавить в `api/routes/` файл `admin.py` с защищённым endpoint'ом: ```python # api/routes/admin.py from fastapi import Depends, Header, HTTPException from fastapi.routing import APIRouter from sqlalchemy.ext.asyncio import AsyncSession from api.core.deps import get_db, get_deps from config import settings from schemas.di import APIDependenciesDTO from services.sync_service import SyncService from repositories.subscriptions import SubscriptionRepository from repositories.users import UserRepository router = APIRouter(prefix="/admin") @router.post("/sync") async def trigger_sync( x_admin_token: str = Header(...), session: AsyncSession = Depends(get_db), deps: APIDependenciesDTO = Depends(get_deps), ): # Простая защита через статический токен в заголовке if x_admin_token != settings.remnawave_token: # или отдельный ADMIN_TOKEN в config raise HTTPException(403, "Forbidden") service = SyncService(deps.user_repository, deps.subscriptions_repository) stats = await service.sync_all_users(session, deps.rw_sdk) return {"ok": True, "stats": stats} ``` **Конкретные шаги:** 1. Создать `services/sync_service.py`. 2. Добавить `sync_job` в `entrypoints/scheduler.py`. 3. Создать `api/routes/admin.py` с защищённым endpoint'ом. 4. Зарегистрировать `admin.router` в `api/main.py`. 5. Добавить `ADMIN_TOKEN` в `config.py` и `.env` (отдельный токен, не `remnawave_token`). 6. Учесть ограничение по RPS к Remnawave API: если пользователей много (> 1000), добавить `asyncio.sleep(0.1)` между запросами в `sync_all_users` или использовать batched-запросы если Remnawave API их поддерживает. --- ## Сводная таблица задач | # | Проблема | Фаза | Файлы | Сложность | |---|---|---|---|---| | 1 | `add_days` получает `user.id` вместо UUID | 1 | `services/user_service.py` | 🟢 Мин | | 2 | HWID не обновляется при продлении | 1 | `services/user_service.py` | 🟢 Мин | | 3 | Squads не обновляются при продлении | 1 | `services/user_service.py` | 🟢 Мин | | 4 | Нет обработчика `deposit` | 1 | `bot/handlers/deposit.py` (новый) | 🟡 Лёгкая | | 5 | Нет атомарности в checkout | 2 | `bot/handlers/buy.py` | 🟡 Лёгкая | | 6 | Двойное списание без защиты | 2 | `bot/handlers/buy.py` | 🟢 Мин | | 7 | `Subscription` без полей плана | 3 | `db/models/subscriptions.py` + миграция | 🔴 Средняя | | 8 | Нет аудит-лога баланса | 3 | `db/models/balance_transactions.py` (новый) + миграция | 🔴 Средняя | | 9 | `Bill` привязан к Pally | 3 | `db/models/bills.py`, `schemas/billing.py` | 🟡 Лёгкая | | 10 | Деноминация не зафиксирована | 3 | `db/models/users.py`, `config.py` | 🟢 Мин | | 11 | `APIDependenciesDTO` неполный | 4 | `schemas/di.py`, `api/core/deps.py` | 🟡 Лёгкая | | 12 | Автопродление не реализовано | 5 | `services/renewal_service.py` (новый) | 🔴 Средняя | | 13 | Реферал не начисляется при покупке | 6 | `bot/handlers/buy.py` | 🟡 Лёгкая | | 14 | Нет синхронизации с Remnawave | 7 | `services/sync_service.py` (новый) | 🔴 Средняя | **Обозначения сложности:** - 🟢 Минимальная — 1–10 строк, без новых зависимостей - 🟡 Лёгкая — до 50 строк, возможно новый файл - 🔴 Средняя — новые модели/миграции или существенный рефакторинг --- ## Порядок реализации (рекомендуемый спринт-план) ### Спринт 1 (Фазы 1–2): ~1–2 дня Исправить все 6 критических багов. Минимальный риск регрессий, максимальный эффект. ### Спринт 2 (Фаза 3): ~2–3 дня Расширить модели данных. Написать и применить миграции. Обновить репозитории и сервисы. Тщательно протестировать, что существующий флоу покупки не сломан. ### Спринт 3 (Фаза 4): ~0.5 дня Быстрый рефакторинг DI — небольшой объём работы, но необходим до реализации следующих фаз. ### Спринт 4 (Фаза 5): ~2–3 дня Реализовать автопродление. Тщательно тестировать на тестовых пользователях с ненастоящими деньгами перед запуском в продакшен. ### Спринт 5 (Фазы 6–7): ~1–2 дня Реферальная система и синхронизация — завершающий этап. --- *Документ создан на основе анализа кодовой базы. Актуализировать при изменении архитектуры.*