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

65 KiB
Raw Blame History

🏦 FINANCE_REFINEMENT — План рефинансирования системы управления подписками

Цель документа: пошаговый план перехода бота с фрагментарного состояния (баги + архитектурные пробелы) к надёжной, аудируемой, внутренней системе управления балансом и подписками.

Каждая фаза приоритизирована по принципу «сначала не ломать, затем улучшать». Фазы 12 устраняют критические баги, блокирующие корректную работу прямо сейчас. Фазы 34 закладывают фундамент модели данных, необходимый для всех последующих фаз. Фазы 57 добавляют новую функциональность поверх исправленной основы.


Содержание

  1. Фаза 1 — Критические исправления бизнес-логики
  2. Фаза 2 — Финансовая безопасность: атомарность и защита от двойных списаний
  3. Фаза 3 — Расширение модели данных
  4. Фаза 4 — Архитектурная согласованность (DI)
  5. Фаза 5 — Система автопродления подписок
  6. Фаза 6 — Реферальная система: учёт покупок за баланс
  7. Фаза 7 — Синхронизация состояния с Remnawave

Фаза 1 — Критические исправления бизнес-логики

Цель

Устранить баги, при которых базовый флоу покупки подписки молча ломается: подписка в Remnawave не продлевается, лимиты устройств не обновляются, а кнопка пополнения баланса вообще не работает. Все четыре проблемы этой фазы не требуют изменений схемы БД и реализуются правками в существующих файлах.

Приоритет: НЕМЕДЛЕННО — без этих исправлений основной бизнес-процесс не работает.


1.1 Bug 1 — Неправильный аргумент в rw.add_days

Файл: services/user_service.py, метод UserService.buy_subscription

Проблема:

# БЫЛО (неправильно):
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. Нужно использовать его:

# СТАЛО (правильно):
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 в ветке продления:

# В ветке 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. Добавить следующей строкой:
    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 при продлении:

# В ветке 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.pydeposit_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 и сохраняет минимальную сумму пополнения в контексте.
# 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 = (
    "💳 <b>Пополнение баланса</b>\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. Пример:
    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

Проблема:

# ТЕКУЩИЙ КОД (опасный):
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 выбрасывает исключение, баланс пользователя уже уменьшен, а подписка не создана.

Исправление: Инвертировать порядок: сначала попытаться создать подписку, затем списать баланс. Если создание подписки упало — ни баланс, ни подписка не затронуты. Если списание упало после успешного создания подписки — выполнить компенсирующую транзакцию (вернуть деньги).

# ИСПРАВЛЕННЫЙ КОД в 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" не сработает.

# В начале 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 следующие поля:

# 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:

# 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:
    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:

from misc.pally import BillStatus

Это значит, что модель БД зависит от конкретного платёжного провайдера. При добавлении нового провайдера (криптовалюты, ЮKassa и т.д.) возникнет конфликт статусов или придётся менять схему.

Исправление: Ввести провайдеронезависимый DepositStatus и расширить Bill полем provider:

  1. Создать schemas/billing.py (или расширить существующий):

    # Добавить в 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:

    # Заменить:
    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.pyper_device_cost=50, whitelist_cost=100). Но нет ни комментариев, ни соглашения. При добавлении частичных платежей или криптовалют с дробными суммами это станет источником ошибок.

Исправление: Зафиксировать соглашение явно в трёх местах:

  1. В db/models/users.py добавить комментарий к колонке:

    balance: Mapped[int] = mapped_column(
        INTEGER,
        nullable=False,
        default=0,
        comment="Баланс пользователя в РУБЛЯХ (целые числа). "
                "Дробные значения НЕ поддерживаются. "
                "Минимальная единица = 1 рубль.",
    )
    
  2. В config.py добавить комментарий рядом с ценовыми параметрами:

    # Все ценовые параметры указаны в РУБЛЯХ (целые числа).
    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

Проблема:

@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 недостающими полями:

# 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 часов (рекомендуется 612 часов) вызывает задачу проверки истекающих подписок.

Задача 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:

# 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:

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:

# 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 есть логика:

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:
    # Начисление реферального бонуса
    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 является источником истины для статуса и дат подписки, периодически (раз в 14 часа) вытягивать актуальное состояние всех пользователей из 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:

# 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 добавить вторую задачу:

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'ом:

# 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 (новый) 🔴 Средняя

Обозначения сложности:

  • 🟢 Минимальная — 110 строк, без новых зависимостей
  • 🟡 Лёгкая — до 50 строк, возможно новый файл
  • 🔴 Средняя — новые модели/миграции или существенный рефакторинг

Порядок реализации (рекомендуемый спринт-план)

Спринт 1 (Фазы 12): ~12 дня

Исправить все 6 критических багов. Минимальный риск регрессий, максимальный эффект.

Спринт 2 (Фаза 3): ~23 дня

Расширить модели данных. Написать и применить миграции. Обновить репозитории и сервисы. Тщательно протестировать, что существующий флоу покупки не сломан.

Спринт 3 (Фаза 4): ~0.5 дня

Быстрый рефакторинг DI — небольшой объём работы, но необходим до реализации следующих фаз.

Спринт 4 (Фаза 5): ~23 дня

Реализовать автопродление. Тщательно тестировать на тестовых пользователях с ненастоящими деньгами перед запуском в продакшен.

Спринт 5 (Фазы 67): ~12 дня

Реферальная система и синхронизация — завершающий этап.


Документ создан на основе анализа кодовой базы. Актуализировать при изменении архитектуры.