- 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.
65 KiB
🏦 FINANCE_REFINEMENT — План рефинансирования системы управления подписками
Цель документа: пошаговый план перехода бота с фрагментарного состояния (баги + архитектурные пробелы) к надёжной, аудируемой, внутренней системе управления балансом и подписками.
Каждая фаза приоритизирована по принципу «сначала не ломать, затем улучшать». Фазы 1–2 устраняют критические баги, блокирующие корректную работу прямо сейчас. Фазы 3–4 закладывают фундамент модели данных, необходимый для всех последующих фаз. Фазы 5–7 добавляют новую функциональность поверх исправленной основы.
Содержание
- Фаза 1 — Критические исправления бизнес-логики
- Фаза 2 — Финансовая безопасность: атомарность и защита от двойных списаний
- Фаза 3 — Расширение модели данных
- Фаза 4 — Архитектурная согласованность (DI)
- Фаза 5 — Система автопродления подписок
- Фаза 6 — Реферальная система: учёт покупок за баланс
- Фаза 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-..."
Конкретные шаги:
- Открыть
services/user_service.py. - Найти строку
await rw.add_days(rw_sdk, user.id, duration.days)в веткеif rw_user:. - Заменить
user.idнаrw_user.uuid. - Убедиться, что переменная
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)
Конкретные шаги:
- Открыть
services/user_service.py. - В ветке
if rw_user:найти строкуawait rw.add_days(rw_sdk, rw_user.uuid, duration.days). - Добавить следующей строкой:
await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices) - Параметр
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])
Конкретные шаги:
- Открыть
services/user_service.py. - Убедиться, что
InternalSquadsуже импортирован (он есть вschemas.subscriptions). - В ветке
if rw_user:, послеrw.set_hwid_limit, добавить три строки выше. - Обратить внимание:
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 с роутером, который:
- Перехватывает
F.data == "deposit". - Отображает клавиатуру выбора платёжного провайдера (
payment_gateways). - Устанавливает 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)
Конкретные шаги:
- Создать файл
bot/handlers/deposit.pyс содержимым выше. - В
bot/handlers/__init__.pyимпортировать и зарегистрировать роутерdeposit.router. Пример:from bot.handlers.deposit import router as deposit_router # и добавить его в список роутеров вместе с остальными - Уточнить логику
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)
Конкретные шаги:
- Открыть
bot/handlers/buy.py. - В хендлере
proceed_to_checkoutпереместитьdecrease_user_balanceпосле вызоваbuy_subscription, обернувbuy_subscriptionвtry/except. - Добавить импорт
logger = logging.getLogger(__name__)если его нет (он уже есть). - Добавить текст
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() # Очистить состояние после успешного завершения
Конкретные шаги:
- Открыть
bot/handlers/buy.py. - В начале
proceed_to_checkout, сразу послеdata = await state.get_data(), добавитьawait state.set_state(SubscriptionStorage.checkout). - Убедиться, что в
bot/states/buy.pyв классеSubscriptionStorageуже есть состояниеcheckout = State()— оно есть, можно использовать. - В конце хендлера (как при успехе, так и при ошибке) вызывать
await state.clear(). - Дополнительно: добавить
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 для обратной совместимости (заполняется при первой покупке/синхронизации).
Конкретные шаги:
- Открыть
db/models/subscriptions.py. - Добавить 5 новых колонок (см. выше) в класс
Subscription. - Добавить недостающие импорты:
Booleanуже есть,INTEGER— добавить,TEXT— добавить. - Обновить
repositories/subscriptions.py:- В
create_subscription: принимать и сохранятьdevices,duration_months,plan_price_paid,whitelists,rw_uuid. - В
update_sub: принимать и обновлять те же поля (добавить как keyword-only аргументы с дефолтными значениями для обратной совместимости).
- В
- Обновить
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).
- Передавать
- Сигнатура
buy_subscriptionдолжна принятьamount: intкак параметр (его вычисляетproceed_to_checkoutизcalculate_price(ctx)— передавать явно). - Создать миграцию:
alembic revision --autogenerate -m "add_plan_details_to_subscriptions". - Проверить и применить:
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")
Конкретные шаги:
- Создать
db/models/balance_transactions.pyс содержимым выше. - В
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" ) - В
db/models/__init__.pyэкспортироватьBalanceTransactionиBalanceTxType. - Создать
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)для истории.
- Метод
- Обновить
repositories/users.py— вincrease_user_balanceиdecrease_user_balanceдобавить запись вbalance_transactions. Для этого репозиторий пользователей должен либо приниматьBalanceTransactionRepositoryв параметрах метода, либо создавать запись напрямую черезsession.add(BalanceTransaction(...)). - Обновить
api/routes/pally.py: передаватьdescription="Депозит Pally bill_id={bill_id}",tx_type=BalanceTxType.DEPOSITпри пополнении, иtx_type=BalanceTxType.REFERRAL_BONUSпри зачислении реферального бонуса. - Обновить
bot/handlers/buy.py(proceed_to_checkout): при списании передаватьtx_type=BalanceTxType.PURCHASEиdescriptionс параметрами плана. - Создать и применить миграцию:
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:
-
Создать
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" # Ручное пополнение администратором -
Обновить
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 ) -
Обновить
api/routes/pally.py: заменитьBillStatus.SUCCESSнаDepositStatus.SUCCESS. -
Обновить
services/billing_service.py: использоватьDepositStatus.PENDINGпри создании. -
Обновить
repositories/bills.py: методыcreateиget_bill_by_idне требуют изменений, но нужно убрать импортBillStatusизmisc.pallyесли он там есть. -
Создать и применить миграцию:
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). Но нет ни комментариев, ни соглашения. При добавлении частичных платежей
или криптовалют с дробными суммами это станет источником ошибок.
Исправление: Зафиксировать соглашение явно в трёх местах:
-
В
db/models/users.pyдобавить комментарий к колонке:balance: Mapped[int] = mapped_column( INTEGER, nullable=False, default=0, comment="Баланс пользователя в РУБЛЯХ (целые числа). " "Дробные значения НЕ поддерживаются. " "Минимальная единица = 1 рубль.", ) -
В
config.pyдобавить комментарий рядом с ценовыми параметрами:# Все ценовые параметры указаны в РУБЛЯХ (целые числа). per_device_cost: int = Field(50, alias="PER_DEVICE_COST") # ₽/устройство/месяц whitelist_cost: int = Field(100, alias="WHITELIST_COST") # ₽/месяц за whitelist опцию -
Создать
docs/CONVENTIONS.md(или добавить раздел вREADME.md) с явным описанием:Деноминация баланса: все балансовые операции производятся в целых рублях (RUB). Дробные суммы округляются вверх (
math.ceil) при расчёте цены подписки. Если в будущем потребуется поддержка копеек — выполнить миграцию умножением всех балансов на 100 и изменением всех ценовых параметров. -
Важно: не менять тип колонки в БД в этой фазе — только зафиксировать соглашение. Если в будущем потребуются дробные суммы — отдельный тикет с полной миграцией.
Фаза 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 # ← добавить
Конкретные шаги:
- Открыть
schemas/di.py. - Добавить импорты
RemnawaveSDKиSubscriptionRepository. - Добавить два поля в
APIDependenciesDTO. - Найти функцию
get_depsвapi/core/deps.py(или там, где инициализируетсяAPIDependenciesDTO) и передать в неёrw_sdkиsubscriptions_repository. ИнстансRemnawaveSDKскорее всего создаётся при старте приложения — использовать тот же инстанс черезapp.stateили dependency injection FastAPI. - Проверить, что существующий
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:
# 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
Конкретные шаги:
- Добавить
apscheduler>=3.10вrequirements.txt. - Создать
services/renewal_service.py. - Добавить
get_expiring_subscriptionsвrepositories/subscriptions.py. - Создать
entrypoints/scheduler.py. - Интегрировать вызов
start_scheduler(bot, rw_sdk)в точку запуска бота (bot/main.pyилиentrypoints/bot.py— где инициализируется dispatcher). - Добавить кнопку «🔄 Автопродление» в меню подписки, которая переключает
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:
- В
api/routes/pally.py— убрать блок начисления реферального бонуса при пополнении. - В
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 - Добавить импорт
import mathвbot/handlers/buy.py(он уже есть).
Конкретные шаги:
- Принять решение о стратегии (A или B) — зафиксировать в комментарии кода.
- Реализовать выбранную стратегию.
- При выборе B: протестировать что при пополнении через Pally реферальный бонус больше не начисляется (регрессионный тест).
- Добавить запись в
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:
# 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}
Конкретные шаги:
- Создать
services/sync_service.py. - Добавить
sync_jobвentrypoints/scheduler.py. - Создать
api/routes/admin.pyс защищённым endpoint'ом. - Зарегистрировать
admin.routerвapi/main.py. - Добавить
ADMIN_TOKENвconfig.pyи.env(отдельный токен, неremnawave_token). - Учесть ограничение по 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 дня
Реферальная система и синхронизация — завершающий этап.
Документ создан на основе анализа кодовой базы. Актуализировать при изменении архитектуры.