- 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.
1299 lines
65 KiB
Markdown
1299 lines
65 KiB
Markdown
# 🏦 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)
|
||
|
||
---
|
||
|
||
<a name="фаза-1"></a>
|
||
## Фаза 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 = (
|
||
"💳 <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`.
|
||
Пример:
|
||
```python
|
||
from bot.handlers.deposit import router as deposit_router
|
||
# и добавить его в список роутеров вместе с остальными
|
||
```
|
||
3. Уточнить логику `BillingContext.amount`: если сейчас `billing.py` использует `ctx.amount`
|
||
для создания счёта, нужно либо добавить ввод суммы пользователем (State для ввода числа),
|
||
либо временно хардкодить минимальную сумму (например, 100₽). Это решение принимает команда.
|
||
|
||
---
|
||
|
||
<a name="фаза-2"></a>
|
||
## Фаза 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`, чтобы убрать крутящийся индикатор у пользователя.
|
||
|
||
---
|
||
|
||
<a name="фаза-3"></a>
|
||
## Фаза 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. **Важно:** не менять тип колонки в БД в этой фазе — только зафиксировать соглашение.
|
||
Если в будущем потребуются дробные суммы — отдельный тикет с полной миграцией.
|
||
|
||
---
|
||
|
||
<a name="фаза-4"></a>
|
||
## Фаза 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` не сломался (он не использует новые
|
||
поля, так что просто добавление полей обратно совместимо).
|
||
|
||
---
|
||
|
||
<a name="фаза-5"></a>
|
||
## Фаза 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`
|
||
→ показать подтверждение.
|
||
|
||
---
|
||
|
||
<a name="фаза-6"></a>
|
||
## Фаза 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` для реферальных бонусов (для аудита).
|
||
|
||
---
|
||
|
||
<a name="фаза-7"></a>
|
||
## Фаза 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 дня
|
||
Реферальная система и синхронизация — завершающий этап.
|
||
|
||
---
|
||
|
||
*Документ создан на основе анализа кодовой базы. Актуализировать при изменении архитектуры.* |