diff --git a/api/main.py b/api/main.py index d1bb6f6..59f52b1 100644 --- a/api/main.py +++ b/api/main.py @@ -3,6 +3,7 @@ from contextlib import asynccontextmanager from aiogram import Bot from aiogram.client.default import DefaultBotProperties +from aiogram.client.session.aiohttp import AiohttpSession from fastapi import FastAPI from api.routes import routers @@ -18,8 +19,9 @@ from services.user_service import UserService @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator: # Bot Init - bot_default = DefaultBotProperties(parse_mode="HTML") - bot = Bot(token=settings.bot_token, default=bot_default) + aiohttp_session = AiohttpSession(proxy=settings.proxy) if settings.proxy else None + default = DefaultBotProperties(parse_mode="HTML") + bot = Bot(token=settings.bot_token, default=default, session=aiohttp_session) app.state.bot = bot user_repository = UserRepository() diff --git a/plans/FINANCE_REFINEMENT.md b/plans/FINANCE_REFINEMENT.md deleted file mode 100644 index 3a450f5..0000000 --- a/plans/FINANCE_REFINEMENT.md +++ /dev/null @@ -1,1299 +0,0 @@ -# 🏦 FINANCE_REFINEMENT — План рефинансирования системы управления подписками - -> **Цель документа:** пошаговый план перехода бота с фрагментарного состояния (баги + архитектурные -> пробелы) к надёжной, аудируемой, внутренней системе управления балансом и подписками. -> -> Каждая фаза приоритизирована по принципу **«сначала не ломать, затем улучшать»**. -> Фазы 1–2 устраняют критические баги, блокирующие корректную работу прямо сейчас. -> Фазы 3–4 закладывают фундамент модели данных, необходимый для всех последующих фаз. -> Фазы 5–7 добавляют новую функциональность поверх исправленной основы. - ---- - -## Содержание - -1. [Фаза 1 — Критические исправления бизнес-логики](#фаза-1) -2. [Фаза 2 — Финансовая безопасность: атомарность и защита от двойных списаний](#фаза-2) -3. [Фаза 3 — Расширение модели данных](#фаза-3) -4. [Фаза 4 — Архитектурная согласованность (DI)](#фаза-4) -5. [Фаза 5 — Система автопродления подписок](#фаза-5) -6. [Фаза 6 — Реферальная система: учёт покупок за баланс](#фаза-6) -7. [Фаза 7 — Синхронизация состояния с Remnawave](#фаза-7) - ---- - - -## Фаза 1 — Критические исправления бизнес-логики - -### Цель - -Устранить баги, при которых базовый флоу покупки подписки **молча ломается**: подписка в Remnawave -не продлевается, лимиты устройств не обновляются, а кнопка пополнения баланса вообще не работает. -Все четыре проблемы этой фазы не требуют изменений схемы БД и реализуются правками в существующих -файлах. - -**Приоритет: НЕМЕДЛЕННО** — без этих исправлений основной бизнес-процесс не работает. - ---- - -### 1.1 Bug 1 — Неправильный аргумент в `rw.add_days` - -**Файл:** `services/user_service.py`, метод `UserService.buy_subscription` - -**Проблема:** -```python -# БЫЛО (неправильно): -await rw.add_days(rw_sdk, user.id, duration.days) -# ↑ -# user.id — это Telegram ID (int), например 123456789 -# rw.add_days() ожидает UUID-строку вида "550e8400-e29b-41d4-a716-446655440000" -# Внутри функция вызывает sdk.users.get_user_by_uuid(uuid=user_uuid), -# что всегда завершается ошибкой, которая перехватывается и логируется как warning. -# В итоге срок подписки в Remnawave НИКОГДА не продлевается. -``` - -**Исправление:** -В ветке `if rw_user:` функции `buy_subscription` у нас уже есть объект `rw_user` типа `RWUserInfo`, -который содержит поле `uuid` — корректный UUID пользователя в Remnawave. Нужно использовать его: - -```python -# СТАЛО (правильно): -await rw.add_days(rw_sdk, rw_user.uuid, duration.days) -# ↑ -# rw_user.uuid — строковый UUID из Remnawave, например "550e8400-..." -``` - -**Конкретные шаги:** -1. Открыть `services/user_service.py`. -2. Найти строку `await rw.add_days(rw_sdk, user.id, duration.days)` в ветке `if rw_user:`. -3. Заменить `user.id` на `rw_user.uuid`. -4. Убедиться, что переменная `rw_user` объявлена выше этой строки (она уже есть — - `rw_user = await rw.get_user_by_telegram_id(rw_sdk, user.id)`). - ---- - -### 1.2 Bug 2 — Лимит HWID-устройств не обновляется при продлении - -**Файл:** `services/user_service.py`, метод `UserService.buy_subscription`, ветка `if rw_user:` - -**Проблема:** -При продлении существующей подписки вызывается только `rw.add_days(...)`. Если пользователь менял -количество устройств (например, покупал 6 вместо прежних 3), `hwid_device_limit` в Remnawave -остаётся старым. Функция `rw.set_hwid_limit` уже существует в `services/rw.py`, но не вызывается. - -**Исправление:** -Добавить вызов `rw.set_hwid_limit` сразу после `rw.add_days` в ветке продления: - -```python -# В ветке if rw_user: после вызова rw.add_days -await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices) -``` - -**Конкретные шаги:** -1. Открыть `services/user_service.py`. -2. В ветке `if rw_user:` найти строку `await rw.add_days(rw_sdk, rw_user.uuid, duration.days)`. -3. Добавить **следующей строкой**: - ```python - await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices) - ``` -4. Параметр `subscription.devices` уже доступен — это поле объекта `SubscriptionPlan`. - ---- - -### 1.3 Bug 3 — Squad (Whitelist) не обновляется при продлении - -**Файл:** `services/user_service.py`, метод `UserService.buy_subscription`, ветка `if rw_user:` - -**Проблема:** -При продлении squads пользователя в Remnawave не обновляются. Если пользователь добавил -опцию `whitelists=True` при продлении, а до этого у него был только `DEFAULT` squad, он так и -останется без whitelist squad. Функция `rw.update_squads` уже существует в `services/rw.py`. - -**Исправление:** -Сформировать актуальный список squad UUID на основе `subscription.whitelists` и вызвать -`rw.update_squads` при продлении: - -```python -# В ветке if rw_user: после rw.set_hwid_limit -new_squads = [InternalSquads.DEFAULT] -if subscription.whitelists: - new_squads.append(InternalSquads.WHITELISTS) -await rw.update_squads(rw_sdk, rw_user.uuid, [s for s in new_squads]) -``` - -**Конкретные шаги:** -1. Открыть `services/user_service.py`. -2. Убедиться, что `InternalSquads` уже импортирован (он есть в `schemas.subscriptions`). -3. В ветке `if rw_user:`, после `rw.set_hwid_limit`, добавить три строки выше. -4. Обратить внимание: `InternalSquads` — это `StrEnum`, значения которого являются UUID-строками - из `config.py` (`general_squad`, `whitelist_squad`). Передавать их в `update_squads` напрямую - как строки корректно. - ---- - -### 1.4 Bug 4 — Отсутствует обработчик кнопки «💸 Пополнить» - -**Файлы:** -- Создать или расширить: `bot/handlers/deposit.py` (новый файл) -- Зарегистрировать роутер: `bot/handlers/__init__.py` -- Клавиатура уже есть: `bot/keyboards/client.py` → `deposit_kb`, `payment_gateways` - -**Проблема:** -В `bot/keyboards/client.py` определены кнопки с `callback_data="deposit"` (в `main_menu` и в -`deposit_kb`). Ни в одном из роутеров (`buy.py`, `billing.py`, `menus.py`, `common.py`, -`referals.py`, `support.py`, `ads.py`) нет обработчика `F.data == "deposit"`. При нажатии кнопки -Telegram просто показывает крутящееся колёсико, бот не реагирует. - -**Исправление:** -Создать файл `bot/handlers/deposit.py` с роутером, который: -1. Перехватывает `F.data == "deposit"`. -2. Отображает клавиатуру выбора платёжного провайдера (`payment_gateways`). -3. Устанавливает FSM-состояние `BillingStorage.pending` и сохраняет минимальную сумму - пополнения в контексте. - -```python -# bot/handlers/deposit.py - -import logging - -from aiogram import F, Router -from aiogram.fsm.context import FSMContext -from aiogram.types import CallbackQuery - -from bot.keyboards.client import payment_gateways -from bot.states.buy import BillingStorage -from schemas.billing import BillingContext - -router = Router() -logger = logging.getLogger(__name__) - -DEPOSIT_PROMPT = ( - "💳 Пополнение баланса\n\n" - "Выберите способ оплаты. После оплаты баланс будет зачислен автоматически." -) - - -@router.callback_query(F.data == "deposit") -async def deposit_init(cb: CallbackQuery, state: FSMContext): - await state.clear() - - # Устанавливаем состояние ожидания выбора провайдера. - # BillingContext.amount = 0 — сумму пользователь выберет или введёт на следующем шаге. - # Если в будущем добавить ввод произвольной суммы — добавить отдельный State. - billing_ctx = BillingContext(amount=0) - await state.set_state(BillingStorage.pending) - await state.set_data({"ctx": billing_ctx}) - - await cb.message.edit_text(DEPOSIT_PROMPT, reply_markup=payment_gateways) -``` - -**Конкретные шаги:** -1. Создать файл `bot/handlers/deposit.py` с содержимым выше. -2. В `bot/handlers/__init__.py` импортировать и зарегистрировать роутер `deposit.router`. - Пример: - ```python - from bot.handlers.deposit import router as deposit_router - # и добавить его в список роутеров вместе с остальными - ``` -3. Уточнить логику `BillingContext.amount`: если сейчас `billing.py` использует `ctx.amount` - для создания счёта, нужно либо добавить ввод суммы пользователем (State для ввода числа), - либо временно хардкодить минимальную сумму (например, 100₽). Это решение принимает команда. - ---- - - -## Фаза 2 — Финансовая безопасность: атомарность и защита от двойных списаний - -### Цель - -Гарантировать, что деньги пользователя не будут списаны без предоставления подписки -(Bug 5) и что одна покупка не спишет баланс дважды (Bug 6). Обе проблемы решаются -на уровне логики хендлера без изменения схемы БД. - -**Приоритет: ВЫСОКИЙ** — прямые финансовые потери для пользователей или для сервиса. - ---- - -### 2.1 Bug 5 — Отсутствие атомарности: деньги списываются до создания подписки - -**Файл:** `bot/handlers/buy.py`, хендлер `proceed_to_checkout` - -**Проблема:** -```python -# ТЕКУЩИЙ КОД (опасный): -user = await deps.user_repository.decrease_user_balance(session, user.id, amount) -# ← Баланс уже списан. Если следующая строка упадёт — деньги потеряны. -await deps.user_service.buy_subscription(session, ctx, deps.rw_sdk, cb.from_user) -``` -Если Remnawave недоступен или `buy_subscription` выбрасывает исключение, баланс пользователя -уже уменьшен, а подписка не создана. - -**Исправление:** -Инвертировать порядок: сначала попытаться создать подписку, затем списать баланс. Если -создание подписки упало — ни баланс, ни подписка не затронуты. Если списание упало после -успешного создания подписки — выполнить компенсирующую транзакцию (вернуть деньги). - -```python -# ИСПРАВЛЕННЫЙ КОД в proceed_to_checkout: - -amount = calculate_price(ctx) -user = await deps.user_repository.get_user_by_id(session, cb.from_user.id) - -if user.balance < amount: - await cb.message.edit_text( - INSUFFICIENT_FUNDS.format(balance=user.balance, amount=amount), - reply_markup=deposit_kb, - ) - return - -# 1. Сначала попытаться создать/продлить подписку в Remnawave. -# Если это упадёт — баланс не тронут, пользователь видит ошибку. -try: - await deps.user_service.buy_subscription(session, ctx, deps.rw_sdk, cb.from_user) -except Exception: - logger.exception( - "buy_subscription failed for user_id=%s, amount=%s. Balance NOT deducted.", - cb.from_user.id, - amount, - ) - await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu) - return - -# 2. Только после успешного создания подписки — списать баланс. -deducted_user = await deps.user_repository.decrease_user_balance(session, user.id, amount) -if not deducted_user: - # Крайне редкий случай: подписка создана, но списание не прошло. - # Логируем как критическую ошибку для ручной reconciliation. - logger.critical( - "RECONCILIATION NEEDED: subscription created for user_id=%s " - "but balance deduction FAILED. Amount=%s.", - cb.from_user.id, - amount, - ) - # Пользователю всё равно показываем успех — подписка у него есть. - -await cb.message.edit_text(SUCCESSFULLY_CREATED_SUBSCRIPTION, reply_markup=return_to_menu) -``` - -**Конкретные шаги:** -1. Открыть `bot/handlers/buy.py`. -2. В хендлере `proceed_to_checkout` переместить `decrease_user_balance` **после** вызова - `buy_subscription`, обернув `buy_subscription` в `try/except`. -3. Добавить импорт `logger = logging.getLogger(__name__)` если его нет (он уже есть). -4. Добавить текст `STANDARD_FALLBACK` в импорты из `bot.texts` (он уже импортирован). - -> **Примечание о полной атомарности:** Идеальное решение — использовать Postgres транзакцию -> с `SAVEPOINT` или паттерн «двухфазного коммита». Это реализуется в Фазе 3 после введения -> таблицы `BalanceTransaction`. До тех пор описанный выше подход кратно безопаснее текущего. - ---- - -### 2.2 Bug 6 — Двойное списание: нет защиты от повторных нажатий - -**Файл:** `bot/handlers/buy.py`, хендлер `proceed_to_checkout` - -**Проблема:** -Если пользователь быстро нажмёт кнопку «Подтвердить» дважды (или отправит дублирующий -callback), два запроса попадут в хендлер почти одновременно. `decrease_user_balance` выполнится -дважды до того, как первый коммит завершится, и баланс спишется двойной суммой. - -**Исправление:** -Установить FSM-состояние `SubscriptionStorage.checkout` **в самом начале хендлера**, до любых -операций. Повторный callback не пройдёт, так как состояние уже изменилось и хендлер -`SubscriptionStorage.duration, F.data == "confirm"` не сработает. - -```python -# В начале proceed_to_checkout — ПЕРВЫМ ДЕЙСТВИЕМ: -@router.callback_query(SubscriptionStorage.duration, F.data == "confirm") -async def proceed_to_checkout( - cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO -): - data = await state.get_data() - # Немедленно переключаем состояние, чтобы заблокировать повторные нажатия. - await state.set_state(SubscriptionStorage.checkout) - - ctx: SubscriptionPlan | None = data.get("ctx") - if not ctx: - await state.clear() - await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu) - return - - # ... далее вся логика checkout ... - - await state.clear() # Очистить состояние после успешного завершения -``` - -**Конкретные шаги:** -1. Открыть `bot/handlers/buy.py`. -2. В начале `proceed_to_checkout`, сразу после `data = await state.get_data()`, добавить - `await state.set_state(SubscriptionStorage.checkout)`. -3. Убедиться, что в `bot/states/buy.py` в классе `SubscriptionStorage` уже есть состояние - `checkout = State()` — оно есть, можно использовать. -4. В конце хендлера (как при успехе, так и при ошибке) вызывать `await state.clear()`. -5. Дополнительно: добавить `await cb.answer()` с `cache_time=5` при получении повторного - callback в состоянии `checkout`, чтобы убрать крутящийся индикатор у пользователя. - ---- - - -## Фаза 3 — Расширение модели данных - -### Цель - -Устранить структурные пробелы в схеме БД: добавить недостающие поля в `Subscription`, -создать таблицу аудита балансовых операций, отвязать модель `Bill` от Pally, и зафиксировать -денежную деноминацию. Эта фаза является **обязательным фундаментом** для Фазы 5 (автопродление) -и существенно упрощает будущую реализацию тарификации. - -**Приоритет: ВЫСОКИЙ** — без этих изменений невозможно реализовать автопродление и аудит. -Все задачи этой фазы требуют миграции Alembic. - ---- - -### 3.1 Gap 7 — Модель `Subscription` не хранит параметры плана - -**Файл:** `db/models/subscriptions.py` - -**Проблема:** -Таблица `subscriptions` содержит только `user_id`, `end_date`, `autorenew`, `status`. Без -хранения параметров активного плана невозможно: -- Автоматически продлить подписку (неизвестно сколько устройств и какой whitelist был) -- Рассчитать доплату при апгрейде плана (пропорциональный пересчёт) -- Показать пользователю «текущий план» без запроса к Remnawave - -**Исправление:** -Добавить в модель `Subscription` следующие поля: - -```python -# db/models/subscriptions.py — добавить поля: - -from sqlalchemy import INTEGER, TEXT, Numeric - -# Новые колонки: -devices: Mapped[int] = mapped_column(INTEGER, nullable=False, default=1) -# Количество HWID-устройств в плане. Нужно для автопродления. - -duration_months: Mapped[int] = mapped_column(INTEGER, nullable=False, default=1) -# Длительность последнего оплаченного периода в месяцах (1, 3, 6, 12). -# Нужно для автопродления — повторить ту же длительность. - -plan_price_paid: Mapped[int] = mapped_column(INTEGER, nullable=False, default=0) -# Сумма в рублях, фактически списанная за последнюю оплату. -# Нужна для аудита и расчёта пропорциональных доплат. - -whitelists: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) -# Включена ли опция «Обход белых списков» в текущем плане. -# Нужно для автопродления. - -rw_uuid: Mapped[str] = mapped_column(TEXT, nullable=True, unique=True) -# UUID пользователя в Remnawave. Хранить здесь, а не только в User.subscription_link, -# чтобы не парсить ссылку каждый раз. -# Nullable=True для обратной совместимости (заполняется при первой покупке/синхронизации). -``` - -**Конкретные шаги:** -1. Открыть `db/models/subscriptions.py`. -2. Добавить 5 новых колонок (см. выше) в класс `Subscription`. -3. Добавить недостающие импорты: `Boolean` уже есть, `INTEGER` — добавить, `TEXT` — добавить. -4. Обновить `repositories/subscriptions.py`: - - В `create_subscription`: принимать и сохранять `devices`, `duration_months`, - `plan_price_paid`, `whitelists`, `rw_uuid`. - - В `update_sub`: принимать и обновлять те же поля (добавить как keyword-only аргументы - с дефолтными значениями для обратной совместимости). -5. Обновить `services/user_service.py`, метод `buy_subscription`: - - Передавать `devices=subscription.devices`, `duration_months=convert_duration_to_int(subscription.duration)`, - `plan_price_paid=amount` (получать `amount` как параметр метода или вычислять внутри), - `whitelists=subscription.whitelists`, `rw_uuid=rw_user.uuid` (при продлении) или - `rw_user.uuid` (при создании из нового `rw_user`). -6. Сигнатура `buy_subscription` должна принять `amount: int` как параметр (его вычисляет - `proceed_to_checkout` из `calculate_price(ctx)` — передавать явно). -7. Создать миграцию: `alembic revision --autogenerate -m "add_plan_details_to_subscriptions"`. -8. Проверить и применить: `alembic upgrade head`. - ---- - -### 3.2 Gap 8 — Отсутствует таблица аудита балансовых операций - -**Файл:** создать `db/models/balance_transactions.py` - -**Проблема:** -Баланс изменяется через прямые `user.balance += amount` / `user.balance -= amount` без записи -истории. При спорной ситуации (пользователь говорит «у меня списали дважды») невозможно -проверить, что произошло. - -**Исправление:** -Создать таблицу `balance_transactions`: - -```python -# db/models/balance_transactions.py - -from datetime import datetime -from enum import StrEnum -from typing import TYPE_CHECKING - -from sqlalchemy import BIGINT, INTEGER, TEXT, DateTime, Enum, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from db.base import Base - -if TYPE_CHECKING: - from db.models.users import User - - -class BalanceTxType(StrEnum): - DEPOSIT = "deposit" # Пополнение через платёжный провайдер - PURCHASE = "purchase" # Списание за покупку подписки - REFUND = "refund" # Возврат (компенсация при ошибке) - REFERRAL_BONUS = "referral" # Реферальный бонус - MANUAL = "manual" # Ручная корректировка администратором - - -class BalanceTransaction(Base): - __tablename__ = "balance_transactions" - - id: Mapped[int] = mapped_column(BIGINT, autoincrement=True, primary_key=True) - user_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False, index=True) - amount: Mapped[int] = mapped_column(INTEGER, nullable=False) - # amount > 0 → приход (deposit, refund, referral) - # amount < 0 → расход (purchase) - - tx_type: Mapped[BalanceTxType] = mapped_column( - Enum(BalanceTxType), nullable=False - ) - balance_before: Mapped[int] = mapped_column(INTEGER, nullable=False) - balance_after: Mapped[int] = mapped_column(INTEGER, nullable=False) - description: Mapped[str] = mapped_column(TEXT, nullable=True) - # Человекочитаемое описание: "Покупка: 3 уст., 1 мес.", "Депозит Pally #12345" и т.д. - - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, default=lambda: datetime.now() - ) - - user: Mapped["User"] = relationship("User", back_populates="balance_transactions") -``` - -**Конкретные шаги:** -1. Создать `db/models/balance_transactions.py` с содержимым выше. -2. В `db/models/users.py` добавить relationship: - ```python - from db.models.balance_transactions import BalanceTransaction - balance_transactions: Mapped[list["BalanceTransaction"]] = relationship( - "BalanceTransaction", back_populates="user", cascade="all, delete" - ) - ``` -3. В `db/models/__init__.py` экспортировать `BalanceTransaction` и `BalanceTxType`. -4. Создать `repositories/balance_transactions.py` с классом `BalanceTransactionRepository`: - - Метод `create(session, user_id, amount, tx_type, balance_before, balance_after, description)`. - - Метод `get_by_user_id(session, user_id, limit=50)` для истории. -5. Обновить `repositories/users.py` — в `increase_user_balance` и `decrease_user_balance` - добавить запись в `balance_transactions`. Для этого репозиторий пользователей должен - либо принимать `BalanceTransactionRepository` в параметрах метода, либо создавать запись - напрямую через `session.add(BalanceTransaction(...))`. -6. Обновить `api/routes/pally.py`: передавать `description="Депозит Pally bill_id={bill_id}"`, - `tx_type=BalanceTxType.DEPOSIT` при пополнении, и `tx_type=BalanceTxType.REFERRAL_BONUS` - при зачислении реферального бонуса. -7. Обновить `bot/handlers/buy.py` (`proceed_to_checkout`): при списании передавать - `tx_type=BalanceTxType.PURCHASE` и `description` с параметрами плана. -8. Создать и применить миграцию: `alembic revision --autogenerate -m "add_balance_transactions"`. - ---- - -### 3.3 Gap 12 — Модель `Bill` жёстко привязана к Pally - -**Файл:** `db/models/bills.py`, `misc/pally.py` - -**Проблема:** -`Bill.status` использует `BillStatus` из `misc.pally`: -```python -from misc.pally import BillStatus -``` -Это значит, что модель БД зависит от конкретного платёжного провайдера. При добавлении -нового провайдера (криптовалюты, ЮKassa и т.д.) возникнет конфликт статусов или придётся -менять схему. - -**Исправление:** -Ввести провайдеронезависимый `DepositStatus` и расширить `Bill` полем `provider`: - -1. Создать `schemas/billing.py` (или расширить существующий): - ```python - # Добавить в schemas/billing.py: - from enum import StrEnum - - class DepositStatus(StrEnum): - PENDING = "pending" # Счёт создан, ожидает оплаты - SUCCESS = "success" # Оплачен успешно - FAILED = "failed" # Истёк или отменён - EXPIRED = "expired" # Истёк по таймауту - - class DepositProvider(StrEnum): - PALLY = "pally" - MANUAL = "manual" # Ручное пополнение администратором - ``` - -2. Обновить `db/models/bills.py`: - ```python - # Заменить: - from misc.pally import BillStatus - # На: - from schemas.billing import DepositStatus, DepositProvider - - # В классе Bill изменить: - status: Mapped[DepositStatus] = mapped_column( - Enum(DepositStatus), nullable=False, default=DepositStatus.PENDING - ) - provider: Mapped[DepositProvider] = mapped_column( - Enum(DepositProvider), nullable=False, default=DepositProvider.PALLY - ) - ``` - -3. Обновить `api/routes/pally.py`: заменить `BillStatus.SUCCESS` на `DepositStatus.SUCCESS`. -4. Обновить `services/billing_service.py`: использовать `DepositStatus.PENDING` при создании. -5. Обновить `repositories/bills.py`: методы `create` и `get_bill_by_id` не требуют изменений, - но нужно убрать импорт `BillStatus` из `misc.pally` если он там есть. -6. Создать и применить миграцию: `alembic revision --autogenerate -m "decouple_bill_from_pally"`. - ---- - -### 3.4 Gap 14 — Неопределённая деноминация поля `balance` - -**Файлы:** `db/models/users.py`, `repositories/users.py`, `config.py` - -**Проблема:** -`User.balance: Mapped[int] = mapped_column(INTEGER, ...)` не имеет явного указания деноминации. -По коду можно предположить, что это **рубли целым числом** (цены в `config.py` — `per_device_cost=50`, -`whitelist_cost=100`). Но нет ни комментариев, ни соглашения. При добавлении частичных платежей -или криптовалют с дробными суммами это станет источником ошибок. - -**Исправление:** -Зафиксировать соглашение явно в трёх местах: - -1. В `db/models/users.py` добавить комментарий к колонке: - ```python - balance: Mapped[int] = mapped_column( - INTEGER, - nullable=False, - default=0, - comment="Баланс пользователя в РУБЛЯХ (целые числа). " - "Дробные значения НЕ поддерживаются. " - "Минимальная единица = 1 рубль.", - ) - ``` - -2. В `config.py` добавить комментарий рядом с ценовыми параметрами: - ```python - # Все ценовые параметры указаны в РУБЛЯХ (целые числа). - per_device_cost: int = Field(50, alias="PER_DEVICE_COST") # ₽/устройство/месяц - whitelist_cost: int = Field(100, alias="WHITELIST_COST") # ₽/месяц за whitelist опцию - ``` - -3. Создать `docs/CONVENTIONS.md` (или добавить раздел в `README.md`) с явным описанием: - > **Деноминация баланса:** все балансовые операции производятся в **целых рублях (RUB)**. - > Дробные суммы округляются вверх (`math.ceil`) при расчёте цены подписки. - > Если в будущем потребуется поддержка копеек — выполнить миграцию умножением всех - > балансов на 100 и изменением всех ценовых параметров. - -4. **Важно:** не менять тип колонки в БД в этой фазе — только зафиксировать соглашение. - Если в будущем потребуются дробные суммы — отдельный тикет с полной миграцией. - ---- - - -## Фаза 4 — Архитектурная согласованность (DI) - -### Цель - -Устранить несоответствие между `DependenciesDTO` (бот) и `APIDependenciesDTO` (API): у API-слоя -нет доступа к `rw_sdk` и `subscriptions_repository`, что блокирует любые операции с Remnawave -на стороне API без рефакторинга. - -**Приоритет: СРЕДНИЙ** — не ломает текущий код, но заблокирует реализацию синхронизации -(Фаза 7) и другие API-endpoint'ы, которые потребуют Remnawave. - ---- - -### 4.1 Gap 11 — `APIDependenciesDTO` не содержит `rw_sdk` и `subscriptions_repository` - -**Файлы:** `schemas/di.py`, `api/core/deps.py` (если существует), `api/main.py` - -**Проблема:** -```python -@dataclass -class APIDependenciesDTO: - user_repository: UserRepository - user_service: UserService - bills_repository: BillsRepository - billing_service: BillingService - # ← Нет rw_sdk, нет subscriptions_repository -``` -`DependenciesDTO` (для бота) содержит оба поля. API-маршруты (`api/routes/pally.py`) получают -`APIDependenciesDTO` через `Depends(get_deps)`, и если понадобится выполнить операцию с -Remnawave (например, в новом webhook или admin endpoint), придётся дублировать инициализацию. - -**Исправление:** -Расширить `APIDependenciesDTO` недостающими полями: - -```python -# schemas/di.py - -from remnawave import RemnawaveSDK -from repositories.subscriptions import SubscriptionRepository - -@dataclass -class APIDependenciesDTO: - user_repository: UserRepository - user_service: UserService - bills_repository: BillsRepository - billing_service: BillingService - subscriptions_repository: SubscriptionRepository # ← добавить - rw_sdk: RemnawaveSDK # ← добавить -``` - -**Конкретные шаги:** -1. Открыть `schemas/di.py`. -2. Добавить импорты `RemnawaveSDK` и `SubscriptionRepository`. -3. Добавить два поля в `APIDependenciesDTO`. -4. Найти функцию `get_deps` в `api/core/deps.py` (или там, где инициализируется - `APIDependenciesDTO`) и передать в неё `rw_sdk` и `subscriptions_repository`. - Инстанс `RemnawaveSDK` скорее всего создаётся при старте приложения — использовать - тот же инстанс через `app.state` или dependency injection FastAPI. -5. Проверить, что существующий `api/routes/pally.py` не сломался (он не использует новые - поля, так что просто добавление полей обратно совместимо). - ---- - - -## Фаза 5 — Система автопродления подписок - -### Цель - -Реализовать автоматическое продление истекающих подписок. Это требует: планировщика задач, -логики проверки баланса, списания, продления в Remnawave, и уведомлений пользователей. - -**Приоритет: СРЕДНИЙ** — не критично прямо сейчас, но является ключевой бизнес-функцией. -**Зависит от:** Фазы 3 (поля `devices`, `duration_months`, `whitelists`, `rw_uuid` в `Subscription`) -и Фазы 3.2 (таблица `BalanceTransaction` для аудита списаний). - ---- - -### 5.1 Gap 9 — Автопродление не реализовано - -**Новые файлы:** -- `services/renewal_service.py` — логика проверки и продления -- `entrypoints/scheduler.py` — запуск APScheduler -- Обновить `requirements.txt`: добавить `apscheduler>=3.10` - -**Архитектура:** - -APScheduler запускается как отдельный процесс (или в рамках того же процесса бота) -и каждые N часов (рекомендуется 6–12 часов) вызывает задачу проверки истекающих подписок. - -``` -Задача renewal_job(): - 1. Запросить из БД все подписки где: - - autorenew = True - - status = ACTIVE - - end_date <= NOW() + 3 дня (уведомлять заранее) - 2. Для каждой такой подписки: - a. Вычислить стоимость продления (devices, duration_months, whitelists → calculate_price) - b. Проверить баланс пользователя - c. Если баланс достаточен → списать → продлить в Remnawave → обновить end_date в БД - d. Если баланс недостаточен → отправить уведомление за 3 дня, за 1 день, в день X - e. Если end_date уже прошла → status = EXPIRED → disable_user в Remnawave -``` - -**Создать `services/renewal_service.py`:** - -```python -# services/renewal_service.py - -import logging -from datetime import UTC, datetime, timedelta - -from aiogram import Bot -from remnawave import RemnawaveSDK -from sqlalchemy.ext.asyncio import AsyncSession - -from db.models.balance_transactions import BalanceTxType -from db.models.subscriptions import Subscription, SubscriptionStatus -from misc import utils -from repositories.subscriptions import SubscriptionRepository -from repositories.users import UserRepository -from services import rw - -logger = logging.getLogger(__name__) - -RENEWAL_NOTICE_DAYS = 3 # За сколько дней предупреждать -RENEWAL_TEXT_SUCCESS = "✅ Подписка автоматически продлена на {months} мес. Списано {amount}₽." -RENEWAL_TEXT_LOW_BALANCE = ( - "⚠️ Через {days} дн. истекает подписка. " - "Для автопродления нужно {needed}₽, на балансе {balance}₽. " - "Пополните баланс: /start" -) -RENEWAL_TEXT_EXPIRED = "❌ Подписка истекла. Пополните баланс и купите новую: /start" - - -class RenewalService: - def __init__( - self, - user_repo: UserRepository, - sub_repo: SubscriptionRepository, - ): - self.user_repo = user_repo - self.sub_repo = sub_repo - - async def run_renewal_cycle( - self, session: AsyncSession, bot: Bot, rw_sdk: RemnawaveSDK - ) -> None: - now = datetime.now(UTC) - notice_threshold = now + timedelta(days=RENEWAL_NOTICE_DAYS) - - # Получить все подписки, которые истекают в ближайшие RENEWAL_NOTICE_DAYS дней - # или уже истекли (нужен метод в репозитории — см. ниже) - expiring = await self.sub_repo.get_expiring_subscriptions( - session, before=notice_threshold - ) - - for sub in expiring: - try: - await self._process_subscription(session, bot, rw_sdk, sub, now) - except Exception: - logger.exception( - "Renewal cycle error for user_id=%s", sub.user_id - ) - - async def _process_subscription( - self, - session: AsyncSession, - bot: Bot, - rw_sdk: RemnawaveSDK, - sub: Subscription, - now: datetime, - ) -> None: - user = await self.user_repo.get_user_by_id(session, sub.user_id) - if not user: - return - - # Гарантируем timezone-aware - end_date = sub.end_date - if end_date.tzinfo is None: - end_date = end_date.replace(tzinfo=UTC) - - # 1. Подписка уже истекла → деактивировать - if end_date < now: - if sub.rw_uuid: - await rw.disable_user(rw_sdk, sub.rw_uuid) - await self.sub_repo.update_sub( - session, sub.user_id, - end_date=end_date, - autorenew=sub.autorenew, - status=SubscriptionStatus.EXPIRED, - devices=sub.devices, - duration_months=sub.duration_months, - plan_price_paid=sub.plan_price_paid, - whitelists=sub.whitelists, - rw_uuid=sub.rw_uuid, - ) - try: - await bot.send_message(sub.user_id, RENEWAL_TEXT_EXPIRED) - except Exception: - pass - return - - # 2. Подписка истекает в ближайшие дни - days_left = (end_date - now).days - - if not sub.autorenew: - # Не автопродление — только уведомить - if days_left <= RENEWAL_NOTICE_DAYS: - try: - await bot.send_message( - sub.user_id, - f"⏰ До конца подписки осталось {days_left} дн. " - f"Продлите её через бот: /start", - ) - except Exception: - pass - return - - # 3. autorenew=True — пытаемся продлить - # Вычислить стоимость: нужен SubscriptionPlan-объект - from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan - plan = SubscriptionPlan( - devices=sub.devices, - duration=utils.convert_int_to_duration(sub.duration_months), - whitelists=sub.whitelists, - discount=1, # без скидок при автопродлении (решение команды) - ) - amount = utils.calculate_price(plan) - - if user.balance < amount: - try: - await bot.send_message( - sub.user_id, - RENEWAL_TEXT_LOW_BALANCE.format( - days=days_left, needed=amount, balance=user.balance - ), - ) - except Exception: - pass - return - - # Списать и продлить - if not sub.rw_uuid: - logger.error("Cannot renew sub for user_id=%s: rw_uuid is None", sub.user_id) - return - - duration_delta = utils.convert_duration_to_timedelta(plan.duration) - - await rw.add_days(rw_sdk, sub.rw_uuid, duration_delta.days) - await self.user_repo.decrease_user_balance(session, user.id, amount) - - new_end_date = end_date + duration_delta - await self.sub_repo.update_sub( - session, sub.user_id, - end_date=new_end_date, - autorenew=True, - status=SubscriptionStatus.ACTIVE, - devices=sub.devices, - duration_months=sub.duration_months, - plan_price_paid=amount, - whitelists=sub.whitelists, - rw_uuid=sub.rw_uuid, - ) - - logger.info( - "Auto-renewed subscription for user_id=%s, amount=%s, new_end=%s", - sub.user_id, amount, new_end_date, - ) - - try: - await bot.send_message( - sub.user_id, - RENEWAL_TEXT_SUCCESS.format(months=sub.duration_months, amount=amount), - ) - except Exception: - pass -``` - -**Обновить `repositories/subscriptions.py`:** -Добавить метод `get_expiring_subscriptions`: -```python -async def get_expiring_subscriptions( - self, session: AsyncSession, before: datetime -) -> list[Subscription]: - """Вернуть подписки, у которых end_date <= before и status = ACTIVE.""" - stmt = ( - select(Subscription) - .where( - Subscription.end_date <= before, - Subscription.status == SubscriptionStatus.ACTIVE, - ) - ) - res = await session.execute(stmt) - return list(res.scalars().all()) -``` - -**Создать `entrypoints/scheduler.py`:** -```python -# entrypoints/scheduler.py - -import asyncio -import logging - -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.interval import IntervalTrigger - -from db.session import get_session # или аналогичная фабрика сессий -from repositories.subscriptions import SubscriptionRepository -from repositories.users import UserRepository -from services.renewal_service import RenewalService -# bot и rw_sdk инициализировать так же, как в bot/main.py - -logger = logging.getLogger(__name__) - - -async def renewal_job(bot, rw_sdk): - user_repo = UserRepository() - sub_repo = SubscriptionRepository() - service = RenewalService(user_repo, sub_repo) - - async with get_session() as session: - await service.run_renewal_cycle(session, bot, rw_sdk) - - -def start_scheduler(bot, rw_sdk): - scheduler = AsyncIOScheduler() - scheduler.add_job( - renewal_job, - trigger=IntervalTrigger(hours=6), - args=[bot, rw_sdk], - id="renewal_cycle", - replace_existing=True, - misfire_grace_time=300, # 5 минут — если задача пропущена, всё равно запустить - ) - scheduler.start() - logger.info("Renewal scheduler started (interval=6h)") - return scheduler -``` - -**Конкретные шаги:** -1. Добавить `apscheduler>=3.10` в `requirements.txt`. -2. Создать `services/renewal_service.py`. -3. Добавить `get_expiring_subscriptions` в `repositories/subscriptions.py`. -4. Создать `entrypoints/scheduler.py`. -5. Интегрировать вызов `start_scheduler(bot, rw_sdk)` в точку запуска бота (`bot/main.py` - или `entrypoints/bot.py` — где инициализируется dispatcher). -6. Добавить кнопку «🔄 Автопродление» в меню подписки, которая переключает `sub.autorenew`. - Соответствующий хендлер: `F.data == "autorenew:toggle"` → инвертировать `sub.autorenew` - → показать подтверждение. - ---- - - -## Фаза 6 — Реферальная система: учёт покупок за баланс - -### Цель - -Реферальный бонус сейчас начисляется **только при пополнении баланса через Pally**. Если -реферал использует накопленный баланс для покупки подписки, реферер не получает бонус. -Это создаёт несоответствие: при одинаковом экономическом результате (реферал заплатил за VPN) -реферер получает бонус только в части случаев. - -**Приоритет: НИЗКИЙ** — не блокирует работу, но влияет на справедливость реферальной системы. -**Зависит от:** Фазы 3.2 (таблица `BalanceTransaction` — нужна для запрета двойного начисления). - ---- - -### 6.1 Gap 10 — Реферальный бонус не начисляется при покупке за баланс - -**Файл:** `bot/handlers/buy.py`, хендлер `proceed_to_checkout` - -**Проблема:** -В `api/routes/pally.py` есть логика: -```python -referal = bill.user.referal_id -if referal is not None: - referal_amount = math.floor(amount * (settings.referal_bonus / 100)) - await deps.user_repository.increase_user_balance(session, referal, referal_amount) -``` -Но в `proceed_to_checkout` (покупка за баланс) аналогичной логики нет. - -**Решение:** -Добавить начисление реферального бонуса в `proceed_to_checkout` после успешного списания. - -**Архитектурный выбор — две стратегии:** - -**Стратегия A (простая):** Начислять бонус реферу при каждой покупке за баланс, независимо -от источника пополнения. Минус: если пользователь пополнил через Pally и сразу купил, -реферер получит бонус дважды (при пополнении и при покупке). - -**Стратегия B (рекомендуемая):** Начислять бонус рефереру только при покупке, но **убрать** -начисление при пополнении через Pally. Реферальный бонус = % от фактических трат на VPN, -а не от пополнений. Это честнее и проще в аудите. - -**Реализация Стратегии B:** - -1. В `api/routes/pally.py` — убрать блок начисления реферального бонуса при пополнении. -2. В `bot/handlers/buy.py`, `proceed_to_checkout`, после успешного `decrease_user_balance`: - ```python - # Начисление реферального бонуса - user_full = await deps.user_repository.get_user_by_id(session, cb.from_user.id) - if user_full.referal_id is not None: - referal_bonus_amount = math.floor(amount * (settings.referal_bonus / 100)) - if referal_bonus_amount > 0: - await deps.user_repository.increase_user_balance( - session, user_full.referal_id, referal_bonus_amount - ) - # Записать в BalanceTransaction с tx_type=REFERRAL_BONUS - logger.info( - "Referral bonus %s credited to user_id=%s (referral of %s)", - referal_bonus_amount, user_full.referal_id, cb.from_user.id, - ) - # Отправить уведомление рефереру (опционально) - try: - await cb.bot.send_message( - user_full.referal_id, - f"🎁 Ваш реферал купил подписку. " - f"Вам начислен бонус {referal_bonus_amount}₽." - ) - except Exception: - pass - ``` -3. Добавить импорт `import math` в `bot/handlers/buy.py` (он уже есть). - -**Конкретные шаги:** -1. Принять решение о стратегии (A или B) — зафиксировать в комментарии кода. -2. Реализовать выбранную стратегию. -3. При выборе B: протестировать что при пополнении через Pally реферальный бонус - больше не начисляется (регрессионный тест). -4. Добавить запись в `BalanceTransaction` для реферальных бонусов (для аудита). - ---- - - -## Фаза 7 — Синхронизация состояния с Remnawave - -### Цель - -Устранить возможную рассинхронизацию между БД бота и состоянием в Remnawave. Если -администратор вручную изменяет данные пользователя в панели Remnawave (меняет дату, -устройства, отключает), бот об этом не знает и показывает устаревшие данные. - -**Приоритет: НИЗКИЙ** — актуально при активном ручном управлении через панель Remnawave. -Может быть реализован как периодическая задача в рамках того же APScheduler из Фазы 5. -**Зависит от:** Фазы 3.1 (поле `rw_uuid` в `Subscription`), Фазы 4 (DI в API). - ---- - -### 7.1 Gap 13 — Нет механизма синхронизации состояния из Remnawave - -**Новый файл:** `services/sync_service.py` - -**Стратегия синхронизации:** - -Поскольку Remnawave является источником истины для статуса и дат подписки, периодически -(раз в 1–4 часа) вытягивать актуальное состояние всех пользователей из Remnawave и -обновлять `Subscription` в локальной БД. - -**Сценарии рассинхронизации, которые нужно обработать:** - -| Ситуация в Remnawave | Действие в БД бота | -|---|---| -| `expire_at` изменён вручную | Обновить `end_date` в `Subscription` | -| Статус изменён на `DISABLED` | Обновить `status = EXPIRED` в `Subscription` | -| `hwid_device_limit` изменён | Обновить `devices` в `Subscription` | -| Пользователь удалён из Remnawave | `status = EXPIRED`, `rw_uuid = None` | -| Пользователь добавлен в Remnawave вручную | Создать `Subscription` в БД бота | - -**Создать `services/sync_service.py`:** - -```python -# services/sync_service.py - -import logging -from datetime import UTC, datetime - -from remnawave import RemnawaveSDK -from sqlalchemy.ext.asyncio import AsyncSession - -from db.models.subscriptions import SubscriptionStatus -from repositories.subscriptions import SubscriptionRepository -from repositories.users import UserRepository -from services import rw - -logger = logging.getLogger(__name__) - - -class SyncService: - def __init__( - self, - user_repo: UserRepository, - sub_repo: SubscriptionRepository, - ): - self.user_repo = user_repo - self.sub_repo = sub_repo - - async def sync_all_users( - self, session: AsyncSession, rw_sdk: RemnawaveSDK - ) -> dict[str, int]: - """ - Синхронизировать состояние всех подписок с Remnawave. - Возвращает статистику: {"synced": N, "expired": M, "errors": K}. - """ - stats = {"synced": 0, "expired": 0, "errors": 0} - - users = await self.user_repo.get_users(session) - - for user in users: - try: - rw_user = await rw.get_user_by_telegram_id(rw_sdk, user.id) - sub = await self.sub_repo.get_subscription_by_user_id(session, user.id) - - if rw_user is None: - # Пользователя нет в Remnawave - if sub and sub.status == SubscriptionStatus.ACTIVE: - await self.sub_repo.update_sub( - session, user.id, - end_date=sub.end_date, - autorenew=False, - status=SubscriptionStatus.EXPIRED, - devices=sub.devices, - duration_months=sub.duration_months, - plan_price_paid=sub.plan_price_paid, - whitelists=sub.whitelists, - rw_uuid=None, - ) - stats["expired"] += 1 - continue - - # Определить актуальный статус - rw_status_map = { - "ACTIVE": SubscriptionStatus.ACTIVE, - "DISABLED": SubscriptionStatus.EXPIRED, - "EXPIRED": SubscriptionStatus.EXPIRED, - "LIMITED": SubscriptionStatus.EXPIRED, - "ON_HOLD": SubscriptionStatus.EXPIRED, - } - new_status = rw_status_map.get(rw_user.status, SubscriptionStatus.EXPIRED) - - # Нормализовать дату - expire_at = rw_user.expire_at - if expire_at.tzinfo is None: - expire_at = expire_at.replace(tzinfo=UTC) - - if sub: - # Обновить существующую запись - await self.sub_repo.update_sub( - session, user.id, - end_date=expire_at, - autorenew=sub.autorenew, - status=new_status, - devices=rw_user.hwid_device_limit or sub.devices, - duration_months=sub.duration_months, - plan_price_paid=sub.plan_price_paid, - whitelists=sub.whitelists, - rw_uuid=rw_user.uuid, - ) - else: - # Пользователь есть в Remnawave, но нет в нашей БД - # (добавлен вручную через панель) - await self.sub_repo.create_subscription( - session, - user_id=user.id, - end_date=expire_at, - autorenew=False, - status=new_status, - devices=rw_user.hwid_device_limit or 1, - duration_months=1, - plan_price_paid=0, - whitelists=False, - rw_uuid=rw_user.uuid, - ) - - stats["synced"] += 1 - - except Exception: - logger.exception("Sync error for user_id=%s", user.id) - stats["errors"] += 1 - - logger.info("Sync complete: %s", stats) - return stats -``` - -**Интеграция в планировщик:** -В `entrypoints/scheduler.py` добавить вторую задачу: -```python -from services.sync_service import SyncService - -async def sync_job(rw_sdk): - user_repo = UserRepository() - sub_repo = SubscriptionRepository() - service = SyncService(user_repo, sub_repo) - async with get_session() as session: - await service.sync_all_users(session, rw_sdk) - -# В start_scheduler: -scheduler.add_job( - sync_job, - trigger=IntervalTrigger(hours=2), - args=[rw_sdk], - id="remnawave_sync", - replace_existing=True, - misfire_grace_time=120, -) -``` - -**Admin-endpoint для ручного запуска синхронизации:** -Добавить в `api/routes/` файл `admin.py` с защищённым endpoint'ом: -```python -# api/routes/admin.py - -from fastapi import Depends, Header, HTTPException -from fastapi.routing import APIRouter -from sqlalchemy.ext.asyncio import AsyncSession - -from api.core.deps import get_db, get_deps -from config import settings -from schemas.di import APIDependenciesDTO -from services.sync_service import SyncService -from repositories.subscriptions import SubscriptionRepository -from repositories.users import UserRepository - -router = APIRouter(prefix="/admin") - - -@router.post("/sync") -async def trigger_sync( - x_admin_token: str = Header(...), - session: AsyncSession = Depends(get_db), - deps: APIDependenciesDTO = Depends(get_deps), -): - # Простая защита через статический токен в заголовке - if x_admin_token != settings.remnawave_token: # или отдельный ADMIN_TOKEN в config - raise HTTPException(403, "Forbidden") - - service = SyncService(deps.user_repository, deps.subscriptions_repository) - stats = await service.sync_all_users(session, deps.rw_sdk) - return {"ok": True, "stats": stats} -``` - -**Конкретные шаги:** -1. Создать `services/sync_service.py`. -2. Добавить `sync_job` в `entrypoints/scheduler.py`. -3. Создать `api/routes/admin.py` с защищённым endpoint'ом. -4. Зарегистрировать `admin.router` в `api/main.py`. -5. Добавить `ADMIN_TOKEN` в `config.py` и `.env` (отдельный токен, не `remnawave_token`). -6. Учесть ограничение по RPS к Remnawave API: если пользователей много (> 1000), добавить - `asyncio.sleep(0.1)` между запросами в `sync_all_users` или использовать batched-запросы - если Remnawave API их поддерживает. - ---- - -## Сводная таблица задач - -| # | Проблема | Фаза | Файлы | Сложность | -|---|---|---|---|---| -| 1 | `add_days` получает `user.id` вместо UUID | 1 | `services/user_service.py` | 🟢 Мин | -| 2 | HWID не обновляется при продлении | 1 | `services/user_service.py` | 🟢 Мин | -| 3 | Squads не обновляются при продлении | 1 | `services/user_service.py` | 🟢 Мин | -| 4 | Нет обработчика `deposit` | 1 | `bot/handlers/deposit.py` (новый) | 🟡 Лёгкая | -| 5 | Нет атомарности в checkout | 2 | `bot/handlers/buy.py` | 🟡 Лёгкая | -| 6 | Двойное списание без защиты | 2 | `bot/handlers/buy.py` | 🟢 Мин | -| 7 | `Subscription` без полей плана | 3 | `db/models/subscriptions.py` + миграция | 🔴 Средняя | -| 8 | Нет аудит-лога баланса | 3 | `db/models/balance_transactions.py` (новый) + миграция | 🔴 Средняя | -| 9 | `Bill` привязан к Pally | 3 | `db/models/bills.py`, `schemas/billing.py` | 🟡 Лёгкая | -| 10 | Деноминация не зафиксирована | 3 | `db/models/users.py`, `config.py` | 🟢 Мин | -| 11 | `APIDependenciesDTO` неполный | 4 | `schemas/di.py`, `api/core/deps.py` | 🟡 Лёгкая | -| 12 | Автопродление не реализовано | 5 | `services/renewal_service.py` (новый) | 🔴 Средняя | -| 13 | Реферал не начисляется при покупке | 6 | `bot/handlers/buy.py` | 🟡 Лёгкая | -| 14 | Нет синхронизации с Remnawave | 7 | `services/sync_service.py` (новый) | 🔴 Средняя | - -**Обозначения сложности:** -- 🟢 Минимальная — 1–10 строк, без новых зависимостей -- 🟡 Лёгкая — до 50 строк, возможно новый файл -- 🔴 Средняя — новые модели/миграции или существенный рефакторинг - ---- - -## Порядок реализации (рекомендуемый спринт-план) - -### Спринт 1 (Фазы 1–2): ~1–2 дня -Исправить все 6 критических багов. Минимальный риск регрессий, максимальный эффект. - -### Спринт 2 (Фаза 3): ~2–3 дня -Расширить модели данных. Написать и применить миграции. Обновить репозитории и сервисы. -Тщательно протестировать, что существующий флоу покупки не сломан. - -### Спринт 3 (Фаза 4): ~0.5 дня -Быстрый рефакторинг DI — небольшой объём работы, но необходим до реализации следующих фаз. - -### Спринт 4 (Фаза 5): ~2–3 дня -Реализовать автопродление. Тщательно тестировать на тестовых пользователях с ненастоящими -деньгами перед запуском в продакшен. - -### Спринт 5 (Фазы 6–7): ~1–2 дня -Реферальная система и синхронизация — завершающий этап. - ---- - -*Документ создан на основе анализа кодовой базы. Актуализировать при изменении архитектуры.* \ No newline at end of file diff --git a/plans/SUBSCRIPTION_FIX.md b/plans/SUBSCRIPTION_FIX.md deleted file mode 100644 index d568f85..0000000 --- a/plans/SUBSCRIPTION_FIX.md +++ /dev/null @@ -1,1183 +0,0 @@ -# 🔄 SUBSCRIPTION_FIX — Исправление логики смены и продления тарифного плана - -> **Связанный документ:** [`FINANCE_REFINEMENT.md`](./FINANCE_REFINEMENT.md) — -> Фазы 1 и 3 являются обязательными предварительными условиями для данного плана. - ---- - -## Содержание - -1. [Постановка проблемы](#1-постановка-проблемы) - - 1.1 [«То же продление» — ожидаемое поведение (корректно)](#11-то-же-продление--ожидаемое-поведение-корректно) - - 1.2 [«Апгрейд» — скрытая потеря выручки](#12-апгрейд--скрытая-потеря-выручки) - - 1.3 [«Даунгрейд» — пользователь теряет переплаченные дни](#13-даунгрейд--пользователь-теряет-переплаченные-дни) - - 1.4 [«Истёкшая → новая» — уже обрабатывается верно](#14-истёкшая--новая--уже-обрабатывается-верно) - - 1.5 [Конкретный числовой пример](#15-конкретный-числовой-пример) -2. [Выбранное решение: «Замена со зачётом»](#2-выбранное-решение-замена-со-зачётом) - - 2.1 [Алгоритм](#21-алгоритм) - - 2.2 [Числовой пример решения](#22-числовой-пример-решения) - - 2.3 [Обоснование выбора](#23-обоснование-выбора) - - 2.4 [Отклонённые альтернативы](#24-отклонённые-альтернативы) -3. [Предварительные условия (Prerequisites)](#3-предварительные-условия-prerequisites) -4. [Поэтапный план реализации](#4-поэтапный-план-реализации) - - [Фаза 0 — Завершение предварительных условий](#фаза-0--завершение-предварительных-условий) - - [Фаза 1 — Функция сравнения планов `is_same_plan`](#фаза-1--функция-сравнения-планов-is_same_plan) - - [Фаза 2 — Функция расчёта зачёта `calculate_remaining_credit`](#фаза-2--функция-расчёта-зачёта-calculate_remaining_credit) - - [Фаза 3 — Три ветки в `buy_subscription`](#фаза-3--три-ветки-в-buy_subscription) - - [Фаза 4 — UI: отображение зачёта пользователю](#фаза-4--ui-отображение-зачёта-пользователю) - - [Фаза 5 — Граничный случай: истёкшая подписка в Remnawave](#фаза-5--граничный-случай-истёкшая-подписка-в-remnawave) -5. [Изменения модели данных](#5-изменения-модели-данных) -6. [Граничные случаи и обработка ошибок](#6-граничные-случаи-и-обработка-ошибок) -7. [Сводная таблица задач](#7-сводная-таблица-задач) -8. [Рекомендуемый порядок реализации](#8-рекомендуемый-порядок-реализации) - ---- - -## 1. Постановка проблемы - -Текущий метод `UserService.buy_subscription` обрабатывает всех существующих пользователей -Remnawave единственным способом: **добавить дни к текущему сроку истечения**. Никакого -понятия «смена плана» не существует. Это порождает четыре принципиально разных сценария, -из которых только один обрабатывается правильно. - ---- - -### 1.1 «То же продление» — ожидаемое поведение (корректно) - -**Сценарий:** Пользователь купил подписку на 3 устройства на 1 месяц. Через 10 дней покупает -ещё раз **те же** 3 устройства на 1 месяц. - -**Текущее поведение (после исправлений Bug 1–3 из FINANCE_REFINEMENT Фаза 1):** -- `add_days(rw_user.uuid, 30)` — срок продлевается на 30 дней ✅ -- `set_hwid_limit(rw_user.uuid, 3)` — лимит не меняется (уже 3) ✅ -- `update_squads(...)` — squads не меняются ✅ - -**Вывод:** Этот сценарий работает корректно после применения Фазы 1 из FINANCE_REFINEMENT. -Дополнительных изменений не требует. **Настоящий документ его не трогает.** - ---- - -### 1.2 «Апгрейд» — скрытая потеря выручки - -**Сценарий:** Пользователь купил подписку на 3 устройства на 1 месяц (150₽). На 3-й день -покупает **6 устройств на 1 месяц** (300₽). - -**Текущее поведение (после Bug 1–3):** -1. `rw_user` найден → входим в ветку продления. -2. `add_days(rw_user.uuid, 30)` — добавляет 30 дней поверх оставшихся ~27 дней. -3. `set_hwid_limit(rw_user.uuid, 6)` — лимит устройств обновляется до 6. -4. Итог: пользователь получает **57 дней** подписки на 6 устройств за **450₽** суммарно. - -**Ожидаемое поведение:** -- 3 дня подписки на 3 устройства (уже использованы). -- 30 новых дней на 6 устройств (оплачены 300₽). -- Оставшиеся 27 дней прежнего плана должны либо сгореть, либо быть учтены как зачёт. - -**Потеря выручки:** -- Пользователь получает 27 дней «апгрейда» на 6 устройств **бесплатно**. -- Стоимость этих дней по тарифу 6 устройств: 27/30 × 300₽ = **270₽** — это - деньги, которые бизнес недополучает с каждой такой операции. - ---- - -### 1.3 «Даунгрейд» — пользователь теряет переплаченные дни - -**Сценарий:** Пользователь купил подписку на 6 устройств + whitelist на 3 месяца (2 100₽ -с учётом скидки). Через 2 недели переходит на 3 устройства без whitelist на 1 месяц (150₽). - -**Текущее поведение (после Bug 1–3):** -1. `rw_user` найден → ветка продления. -2. `add_days(rw_user.uuid, 30)` — добавляет 30 дней поверх оставшихся ~76 дней. -3. `set_hwid_limit(rw_user.uuid, 3)` — лимит устройств понижается до 3. -4. `update_squads(...)` — whitelist squad удаляется. -5. Итог: пользователь получает **106 дней** подписки на 3 устройства за 2 250₽ суммарно. - -**Проблемы:** -- За 76 оставшихся дней 6-устройственного плана пользователь заплатил пропорционально - ~1 680₽ — и эти дни не возвращаются и не компенсируются. -- Пользователь чувствует себя обманутым: переход на более дешёвый план не даёт ни возврата, - ни кредита. -- С точки зрения бизнеса: это честно (деньги уже получены), но **приводит к оттоку** - лояльных пользователей. - ---- - -### 1.4 «Истёкшая → новая» — уже обрабатывается верно - -**Сценарий:** Срок подписки истёк. Remnawave удаляет или деактивирует пользователя. -Пользователь покупает новый план. - -**Текущее поведение:** -- `get_user_by_telegram_id` возвращает `None` (пользователь удалён из RW). -- Выполняется ветка **создания** (`create_user`) с новыми параметрами. - -**Вывод:** Этот сценарий работает правильно **при условии**, что Remnawave действительно -удаляет истёкших пользователей. Если пользователь остаётся в RW со статусом `"EXPIRED"`, -нужна дополнительная обработка (см. [Фаза 5](#фаза-5--граничный-случай-истёкшая-подписка-в-remnawave)). - ---- - -### 1.5 Конкретный числовой пример - -| Момент | Действие | Стоимость | Состояние в RW | -|--------|----------|-----------|----------------| -| День 0 | Куплено: 3 устройства × 1 мес | 150₽ | hwid=3, expire=День 30 | -| День 2 | Куплено: 6 устройств × 1 мес | 300₽ | hwid=6, expire=День 60 (добавили 30) | -| Итог | — | Заплачено: 450₽ | **~58 дней** на 6 устройств | - -**Корректная стоимость 58 дней на 6 устройств** была бы: -- Справедливая пропорция: 2 дня × (150₽/30) + 28 дней × (300₽/30) + 30 дней × 300₽/30 = 10 + 280 + 300 = **590₽** -- Пользователь заплатил: **450₽** -- **Недополучено: 140₽** за каждую такую транзакцию. - ---- - -## 2. Выбранное решение: «Замена со зачётом» - -### 2.1 Алгоритм - -При наличии **активной** подписки (rw_user существует И `expire_at > now`): - -``` -Если новый план == текущий план: - → Чистое продление: add_days(duration.days) [текущее поведение, корректно] - -Если новый план != текущий план: - 1. Рассчитать remaining_days = (rw_user.expire_at - now).days - 2. Получить из БД: plan_price_paid и duration_days активной подписки - 3. credit = floor(plan_price_paid * remaining_days / duration_days) - 4. Зачислить credit на баланс пользователя (если credit > 0) - 5. Обновить Remnawave: - a. Установить новый expire_at = now + new_duration (через delta add_days) - b. set_hwid_limit(new_devices) - c. update_squads(new_squad_list) - 6. Обновить запись Subscription в БД с новыми параметрами плана - 7. Записать зачёт в лог (BalanceTransaction, если реализован из FINANCE_REFINEMENT Фаза 3) -``` - -При наличии **истёкшей** подписки (`rw_user` существует, но `expire_at <= now` или -`status in ("EXPIRED", "DISABLED")`): -- Кредит не начисляется (период уже закончился). -- Устанавливаем `expire_at = now + new_duration`, обновляем HWID и squads, включаем пользователя. - -При отсутствии `rw_user` (пользователь не найден в Remnawave): -- Свежее создание (текущая ветка `create_user`), без изменений. - ---- - -### 2.2 Числовой пример решения - -**Те же исходные данные: покупка 6 устройств на День 2 (28 оставшихся дней из 30)** - -``` -remaining_days = 28 -plan_price_paid = 150₽ -duration_days = 30 - -credit = floor(150 * 28 / 30) = floor(140.0) = 140₽ -``` - -| Статья | Сумма | -|--------|-------| -| Первая покупка (3 устр., 30 дней) | 150₽ | -| Зачёт за 28 неиспользованных дней | −140₽ | -| Вторая покупка (6 устр., 30 дней) | 300₽ | -| **Итого фактически уплачено** | **310₽** | -| **Справедливая стоимость (пропорция)** | **310₽** | - -> 2 дня на 3 устр.: 2 × 5₽/день = 10₽ -> 30 дней на 6 устр.: 30 × (300₽/30) = 300₽ -> **Итого: 310₽** — совпадает точно. - -**Состояние после замены:** -- Баланс пользователя: пополнен на 140₽ (кредит), затем списано 300₽ за новый план. -- Remnawave: hwid=6, expire=День 32 (сегодня + 30 дней), squads актуальны. -- Пользователь видит: «Тариф обновлён. На ваш баланс зачислено 140₽ за неиспользованные дни.» - ---- - -### 2.3 Обоснование выбора - -| Критерий | Replace-and-Credit | -|---|---| -| Финансовая честность для бизнеса | ✅ Нет «бесплатных апгрейдов» | -| Честность для пользователя | ✅ Нет потери переплаченных дней | -| Прозрачность | ✅ Пользователь видит начисленный зачёт | -| Сложность UX | ✅ Один экран, один клик — без доп. шагов | -| Сложность реализации | ✅ Умеренная (новый модуль + 3 ветки в сервисе) | -| Требует изменений модели БД | ⚠️ Да, но они уже запланированы в FINANCE_REFINEMENT | - -Ключевое свойство: **зачёт появляется на балансе до списания новой суммы**. Это означает, -что после зачисления пользователь может себе «позволить» новый план даже если у него было -мало денег — логика проверки баланса в хендлере должна это учитывать -(см. [Секцию 6](#6-граничные-случаи-и-обработка-ошибок)). - ---- - -### 2.4 Отклонённые альтернативы - -**❌ «Запретить смену плана до окончания периода»** -Самый простой в реализации подход: показывать пользователю сообщение «Смена тарифа -недоступна до X дата». Отклонён по причине плохого UX — пользователь, которому нужно -больше устройств прямо сейчас, уйдёт к конкуренту. - -**❌ «Списать полную стоимость, игнорируя остаток»** -Пользователь платит полную цену нового плана, оставшиеся дни сгорают без компенсации. -Это двойное снятие денег (за уже оплаченный период + новый период), что воспринимается -как мошенничество и является прямой причиной chargebacks. - -**❌ «Показать счёт на доплату (proratedinvoice)»** -Вычислить только разницу и попросить пользователя доплатить. Концептуально верно, но -требует отдельного экрана подтверждения, динамически рассчитываемой суммы в `InlineKeyboard`, -и повторного прохождения платёжного флоу. Значительно сложнее как в UX, так и в реализации. -Отклонён как избыточный — «Замена со зачётом» даёт тот же финансовый результат проще. - ---- - -## 3. Предварительные условия (Prerequisites) - -Следующие задачи из [`FINANCE_REFINEMENT.md`](./FINANCE_REFINEMENT.md) **должны быть -выполнены до начала работы по настоящему плану**. Они перечислены здесь для ясности — -реализация описана в том документе. - ---- - -### P1 — Bug 1: Исправить аргумент `rw.add_days` *(FINANCE_REFINEMENT, Фаза 1, §1.1)* - -```python -# БЫЛО (сломано): -await rw.add_days(rw_sdk, user.id, duration.days) # user.id = Telegram int ID - -# СТАЛО (правильно): -await rw.add_days(rw_sdk, rw_user.uuid, duration.days) # rw_user.uuid = UUID-строка -``` - -**Почему это prerequisite:** Весь текущий документ опирается на корректную работу -`add_days`. Без этого исправления Ветка B (чистое продление) не работает вовсе. - ---- - -### P2 — Gap 7: Расширить модель `Subscription` *(FINANCE_REFINEMENT, Фаза 3, §3.1)* - -FINANCE_REFINEMENT Gap 7 добавляет поля `devices`, `duration_months`, `plan_price_paid`, -`whitelists`, `rw_uuid` в таблицу `subscriptions`. **Настоящий документ требует -дополнительного поля `duration_days`**, которого нет в Gap 7 (там только `duration_months`). - -`duration_days` необходим для точного расчёта зачёта, потому что месяцы имеют разное -количество дней (28–31), а формула зачёта оперирует днями, а не месяцами. - -Подробности — в [Секции 5](#5-изменения-модели-данных). - -**Почему это prerequisite:** `calculate_remaining_credit()` читает `subscription.plan_price_paid` -и `subscription.duration_days`. Без этих полей зачёт невозможно рассчитать. - ---- - -### P3 — Bug 5: Атомарность `proceed_to_checkout` *(FINANCE_REFINEMENT, Фаза 2, §2.1)* - -```python -# ИСПРАВЛЕННЫЙ порядок: -# 1. Сначала buy_subscription (Remnawave + DB subscription) -# 2. Потом decrease_user_balance -# 3. При ошибке Remnawave — ни баланс, ни подписка не тронуты -``` - -**Почему это prerequisite:** В сценарии «Замена со зачётом» мы начисляем кредит -(`increase_user_balance`) внутри `buy_subscription` **до** того, как новая подписка -создана в Remnawave. Если Remnawave упадёт после начисления кредита — баланс окажется -некорректным. Атомарная структура из P3 позволяет корректно откатить состояние. - ---- - -## 4. Поэтапный план реализации - ---- - -### Фаза 0 — Завершение предварительных условий - -**Задача:** Выполнить P1, P2, P3 из Секции 3 (если они ещё не реализованы). - -**Проверка готовности перед Фазой 1:** -- [ ] `rw.add_days` принимает `rw_user.uuid`, не `user.id`. -- [ ] В модели `Subscription` присутствуют поля `devices`, `duration_days`, `plan_price_paid`, - `whitelists`, `rw_uuid`. -- [ ] Миграция Alembic применена. -- [ ] `subscription_repository.update_sub` и `create_subscription` принимают новые поля. -- [ ] `buy_subscription` принимает параметр `amount: int` и сохраняет поля плана в БД. -- [ ] Порядок в `proceed_to_checkout` исправлен (сначала RW, потом списание). - ---- - -### Фаза 1 — Функция сравнения планов `is_same_plan` - -**Файл:** `services/subscription_calculator.py` (новый файл) - -**Цель:** Определить, совпадает ли выбранный пользователем новый план с тем, что -уже активен в Remnawave, чтобы выбрать между «чистым продлением» (Ветка B) и -«заменой со зачётом» (Ветка C). - -**Сравнение производится по двум параметрам:** -1. `hwid_device_limit` в Remnawave vs `new_plan.devices`. -2. Наличие UUID whitelist-squad в `rw_user.active_squads` vs `new_plan.whitelists`. - -```python -# services/subscription_calculator.py - -from services.rw import RWUserInfo, InternalSquads -from schemas.subscriptions import SubscriptionPlan - - -def is_same_plan(rw_user: RWUserInfo, new_plan: SubscriptionPlan) -> bool: - """ - Возвращает True, если новый план идентичен текущему активному плану в Remnawave. - - Сравниваются только те параметры, которые отражаются в Remnawave: - - лимит устройств (hwid_device_limit) - - наличие whitelist squad - - Длительность (количество дней) НЕ является признаком «другого плана» — - продление на тот же тариф считается чистым extension независимо от срока. - """ - # 1. Сравниваем лимит HWID-устройств - if rw_user.hwid_device_limit != new_plan.devices: - return False - - # 2. Сравниваем наличие whitelist squad - active_squad_uuids = {squad["uuid"] for squad in rw_user.active_squads} - current_has_whitelist = InternalSquads.WHITELISTS in active_squad_uuids - if current_has_whitelist != new_plan.whitelists: - return False - - return True -``` - -**Конкретные шаги:** -1. Создать файл `services/subscription_calculator.py`. -2. Добавить функцию `is_same_plan` (код выше). -3. Убедиться, что `InternalSquads` экспортируется из `services/rw.py` или - `schemas/subscriptions.py` — если нет, вынести в `config.py` или `constants.py`. -4. Написать минимальные unit-тесты: - - Тест: `hwid=3` в RW, `new_plan.devices=3`, whitelist=False в обоих → `True`. - - Тест: `hwid=3` в RW, `new_plan.devices=6` → `False`. - - Тест: `hwid=6` в RW, whitelist squad присутствует, `new_plan.whitelists=False` → `False`. - - Тест: `hwid=6` в RW, `new_plan.devices=6`, whitelist одинаков → `True` (продление 6→6). - ---- - -### Фаза 2 — Функция расчёта зачёта `calculate_remaining_credit` - -**Файл:** `services/subscription_calculator.py` (расширить) - -**Цель:** Вычислить сумму в рублях, которую нужно вернуть пользователю за неиспользованные -дни текущего оплаченного периода. - -**Формула:** -``` -credit = floor(plan_price_paid × remaining_days / duration_days) - -где: - remaining_days = max(0, (subscription.end_date − now).days) - duration_days = subscription.duration_days (из БД) - plan_price_paid = subscription.plan_price_paid (из БД, в рублях) -``` - -**Свойства формулы:** -- Округление вниз (`floor`) — в пользу пользователя (нельзя вернуть дробные рубли). -- При `remaining_days = 0` (подписка только что истекла) → `credit = 0`. -- При `remaining_days > duration_days` (такого не должно быть в норме, но защита нужна) - → `remaining_days` ограничивается `duration_days`. - -```python -# services/subscription_calculator.py (продолжение) - -import math -from datetime import datetime, UTC - -from db.models.subscriptions import Subscription - - -def calculate_remaining_credit( - subscription: Subscription, - now: datetime | None = None, -) -> int: - """ - Рассчитывает зачёт в рублях за неиспользованные дни активной подписки. - - Возвращает 0, если: - - подписка уже истекла (end_date <= now) - - в модели нет данных о цене или длительности (plan_price_paid=0 или duration_days=0) - - Args: - subscription: Объект активной подписки из БД. Должен содержать поля - plan_price_paid (int, руб.) и duration_days (int, дней). - now: Текущий момент (UTC). По умолчанию datetime.now(UTC). - - Returns: - int: Сумма зачёта в рублях, >= 0. - """ - if now is None: - now = datetime.now(UTC) - - # Защита: нет данных о плане → нет зачёта - if not subscription.plan_price_paid or not subscription.duration_days: - return 0 - - # Подписка уже истекла - if subscription.end_date <= now: - return 0 - - remaining_days = (subscription.end_date - now).days - if remaining_days <= 0: - return 0 - - # Аномальная защита: не возвращать больше, чем оплаченный период - remaining_days = min(remaining_days, subscription.duration_days) - - credit = math.floor( - subscription.plan_price_paid * remaining_days / subscription.duration_days - ) - return max(0, credit) -``` - -**Конкретные шаги:** -1. Дополнить `services/subscription_calculator.py` функцией `calculate_remaining_credit`. -2. Добавить импорты: `math`, `datetime`, `UTC`, модель `Subscription`. -3. Написать unit-тесты: - - `plan_price_paid=150, duration_days=30, remaining_days=28` → `credit=140`. - - `plan_price_paid=150, duration_days=30, remaining_days=0` → `credit=0`. - - `plan_price_paid=0, duration_days=30, remaining_days=20` → `credit=0` (нет данных). - - `plan_price_paid=2100, duration_days=90, remaining_days=76` → `credit=floor(2100*76/90)=1773`. - - `plan_price_paid=150, duration_days=30, end_date=вчера` → `credit=0` (истекла). - ---- - -### Фаза 3 — Три ветки в `buy_subscription` - -**Файл:** `services/user_service.py` - -**Цель:** Переписать `buy_subscription` с явным разделением на три ветки: -- **Ветка A:** Пользователя нет в Remnawave → свежее создание. -- **Ветка B:** Активная подписка, тот же план → чистое продление (add days). -- **Ветка C:** Активная или истёкшая подписка, другой план → замена со зачётом (replace). - -**Возвращаемое значение:** Для поддержки UI в Фазе 4 функция возвращает именованный -кортеж / датакласс `BuyResult`, чтобы хендлер знал, был ли начислен кредит. - -```python -# services/user_service.py - -# Добавить импорты в начало файла: -import logging -import math -from dataclasses import dataclass -from datetime import datetime, UTC - -from services.subscription_calculator import is_same_plan, calculate_remaining_credit - -logger = logging.getLogger(__name__) - - -@dataclass -class BuyResult: - """Результат выполнения buy_subscription.""" - subscription: "Subscription" - credit_issued: int # 0 если зачёт не производился (продление / свежее создание) - is_renewal: bool # True если чистое продление того же плана - is_replace: bool # True если была замена плана (Ветка C) - - -class UserService: - # ... существующий код ... - - async def buy_subscription( - self, - session, - subscription: SubscriptionPlan, - rw_sdk, - user, - amount: int, # фактически рассчитанная и списанная сумма в рублях - ) -> BuyResult: - """ - Создаёт, продлевает или заменяет подписку пользователя. - - Три ветки: - A) rw_user не найден → create_user (свежая подписка) - B) rw_user активен, тот же план → add_days (чистое продление) - C) rw_user найден (активный или истёкший), другой план → replace + credit - """ - duration = convert_duration_to_timedelta(subscription.duration) - now = datetime.now(UTC) - - rw_user = await rw.get_user_by_telegram_id(rw_sdk, user.id) - - new_squads = [InternalSquads.DEFAULT] - if subscription.whitelists: - new_squads.append(InternalSquads.WHITELISTS) - - # ═══════════════════════════════════════════════════════════════════ - # ВЕТКА A — Пользователь не найден в Remnawave → свежее создание - # ═══════════════════════════════════════════════════════════════════ - if rw_user is None: - expire = now + duration - rw_user = await rw.create_user( - rw_sdk, - username=f"tg_{user.id}", - telegram_id=user.id, - expire_at=expire, - hwid_device_limit=subscription.devices, - squad_uuids=new_squads, - ) - if rw_user is None: - raise RuntimeError( - f"Remnawave create_user вернул None для tg_id={user.id}" - ) - - db_subscription = await self.subscription_repository.create_subscription( - session, - user_id=user.id, - end_date=expire, - autorenew=False, - status=SubscriptionStatus.ACTIVE, - devices=subscription.devices, - duration_days=duration.days, - plan_price_paid=amount, - whitelists=subscription.whitelists, - rw_uuid=rw_user.uuid, - ) - logger.info( - "Новая подписка создана: user_id=%s, devices=%s, days=%s, amount=%s₽", - user.id, subscription.devices, duration.days, amount, - ) - return BuyResult( - subscription=db_subscription, - credit_issued=0, - is_renewal=False, - is_replace=False, - ) - - # Определяем, активна ли подписка - is_expired = rw_user.expire_at <= now or rw_user.status in ("EXPIRED", "DISABLED") - - # ═══════════════════════════════════════════════════════════════════ - # ВЕТКА B — Активная подписка, ТОТ ЖЕ план → чистое продление - # ═══════════════════════════════════════════════════════════════════ - if not is_expired and is_same_plan(rw_user, subscription): - new_expire = rw_user.expire_at + duration - await rw.add_days(rw_sdk, rw_user.uuid, duration.days) - # Примечание: set_hwid_limit и update_squads не нужны — план не менялся. - # Они вызывались в Bug 2/3 фикс как «всегда обновлять», но здесь - # is_same_plan гарантирует, что значения в RW уже актуальны. - - db_subscription = await self.subscription_repository.update_sub( - session, - user_id=user.id, - end_date=new_expire, - autorenew=False, - status=SubscriptionStatus.ACTIVE, - # Обновляем финансовые поля для корректного расчёта следующего зачёта: - plan_price_paid=amount, - duration_days=duration.days, - ) - logger.info( - "Подписка продлена (тот же план): user_id=%s, new_expire=%s, amount=%s₽", - user.id, new_expire, amount, - ) - return BuyResult( - subscription=db_subscription, - credit_issued=0, - is_renewal=True, - is_replace=False, - ) - - # ═══════════════════════════════════════════════════════════════════ - # ВЕТКА C — Другой план (или истёкшая подписка) → замена со зачётом - # ═══════════════════════════════════════════════════════════════════ - - # Рассчитываем зачёт ТОЛЬКО для активных подписок с другим планом. - # Для истёкших — кредит = 0 (период уже закончился). - credit = 0 - if not is_expired: - db_sub = await self.subscription_repository.get_subscription_by_user_id( - session, user.id - ) - if db_sub: - credit = calculate_remaining_credit(db_sub, now) - - if credit > 0: - await self.user_repository.increase_user_balance(session, user.id, credit) - logger.info( - "Зачёт за смену плана: user_id=%s, credit=%s₽ | " - "старый план: devices=%s, whitelists=%s | " - "новый план: devices=%s, whitelists=%s", - user.id, credit, - db_sub.devices if db_sub else "?", - db_sub.whitelists if db_sub else "?", - subscription.devices, - subscription.whitelists, - ) - - # Целевой expire = сейчас + новая длительность (НЕ аддитивно к старому!) - new_expire = now + duration - - # Включаем пользователя, если он был отключён - if rw_user.status == "DISABLED": - await rw.enable_user(rw_sdk, rw_user.uuid) - - # Remnawave не имеет метода set_expire_at — используем add_days с дельтой. - # delta может быть отрицательным (даунгрейд + короткий период). - # Большинство реализаций RW SDK поддерживают отрицательное значение. - delta_days = (new_expire - rw_user.expire_at).days - if delta_days != 0: - await rw.add_days(rw_sdk, rw_user.uuid, delta_days) - - await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices) - await rw.update_squads(rw_sdk, rw_user.uuid, new_squads) - - db_subscription = await self.subscription_repository.update_sub( - session, - user_id=user.id, - end_date=new_expire, - autorenew=False, - status=SubscriptionStatus.ACTIVE, - devices=subscription.devices, - duration_days=duration.days, - plan_price_paid=amount, - whitelists=subscription.whitelists, - ) - logger.info( - "Подписка заменена: user_id=%s, devices=%s, days=%s, amount=%s₽, credit=%s₽", - user.id, subscription.devices, duration.days, amount, credit, - ) - return BuyResult( - subscription=db_subscription, - credit_issued=credit, - is_renewal=False, - is_replace=True, - ) -``` - -**Конкретные шаги:** -1. Открыть `services/user_service.py`. -2. Добавить `@dataclass BuyResult` (или вынести в `schemas/subscriptions.py`). -3. Добавить импорты: `is_same_plan`, `calculate_remaining_credit` из нового модуля. -4. Полностью переписать метод `buy_subscription` согласно коду выше. -5. Убедиться, что `subscription_repository` имеет метод `get_subscription_by_user_id`. - Если нет — добавить в `repositories/subscriptions.py`. -6. Убедиться, что `user_repository` имеет метод `increase_user_balance`. - Если нет — добавить в `repositories/users.py` (симметрично `decrease_user_balance`). -7. Обновить хендлер `proceed_to_checkout` в `bot/handlers/buy.py`: - - `buy_subscription` теперь возвращает `BuyResult`, не `Subscription`. - - Передавать `amount=amount` как аргумент. - - Передавать `result.credit_issued` в логику отображения сообщения (Фаза 4). - ---- - -### Фаза 4 — UI: отображение зачёта пользователю - -**Файл:** `bot/handlers/buy.py` и `bot/texts.py` (или аналогичный файл с текстами) - -**Цель:** Показывать разные сообщения в зависимости от результата операции: -- Чистое создание или продление → стандартный текст (уже есть). -- Замена плана с зачётом → новый текст с упоминанием начисленного зачёта. -- Замена плана без зачёта (истёкшая подписка) → текст про повторную активацию. - -**Новые текстовые константы:** - -```python -# bot/texts.py (или аналог) - -# Уже существующий: -SUCCESSFULLY_CREATED_SUBSCRIPTION = ( - "✅ Подписка активирована!\n\n" - "Используйте кнопку ниже для получения конфигурации." -) - -# Новый — для чистого продления: -SUCCESSFULLY_RENEWED_SUBSCRIPTION = ( - "✅ Подписка продлена!\n\n" - "Срок действия вашей текущей подписки увеличен." -) - -# Новый — для замены плана с зачётом: -SUBSCRIPTION_REPLACED_WITH_CREDIT = ( - "✅ Тариф обновлён!\n\n" - "На ваш баланс зачислено {credit}₽ за неиспользованные дни прежнего тарифа.\n" - "Новая подписка активна с сегодняшнего дня." -) - -# Новый — для замены плана без зачёта (истёкшая): -SUBSCRIPTION_REACTIVATED = ( - "✅ Подписка возобновлена!\n\n" - "Ваша подписка была неактивна. Новый тариф действует с сегодняшнего дня." -) -``` - -**Обновлённый хендлер `proceed_to_checkout`:** - -```python -# bot/handlers/buy.py - -@router.callback_query(SubscriptionStorage.duration, F.data == "confirm") -async def proceed_to_checkout( - cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO -): - # Немедленно блокируем повторные нажатия (FINANCE_REFINEMENT Bug 6) - await state.set_state(SubscriptionStorage.checkout) - - data = await state.get_data() - ctx: SubscriptionPlan | None = data.get("ctx") - if not ctx: - await state.clear() - await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu) - return - - amount = calculate_price(ctx) - user = await deps.user_repository.get_user_by_id(session, cb.from_user.id) - - # Проверка баланса ПЕРЕД операцией - # Примечание: после начисления кредита баланс вырастет, но мы не знаем credit заранее. - # Если баланс без кредита достаточен — пропускаем. Иначе — нужно отдельно обработать - # (см. Секцию 6 «Баланс после зачёта»). - if user.balance < amount: - await state.clear() - await cb.message.edit_text( - INSUFFICIENT_FUNDS.format(balance=user.balance, amount=amount), - reply_markup=deposit_kb, - ) - return - - # 1. Создать/продлить/заменить подписку (Remnawave + DB subscription + credit) - try: - result: BuyResult = await deps.user_service.buy_subscription( - session, ctx, deps.rw_sdk, cb.from_user, amount=amount - ) - except Exception: - logger.exception( - "buy_subscription упал: user_id=%s, amount=%s. Баланс не тронут.", - cb.from_user.id, - amount, - ) - await state.clear() - await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu) - return - - # 2. Списать баланс (после успешной операции) - await deps.user_repository.decrease_user_balance(session, user.id, amount) - - await state.clear() - - # 3. Показать соответствующее сообщение - if result.is_renewal: - text = SUCCESSFULLY_RENEWED_SUBSCRIPTION - elif result.is_replace and result.credit_issued > 0: - text = SUBSCRIPTION_REPLACED_WITH_CREDIT.format(credit=result.credit_issued) - elif result.is_replace and result.credit_issued == 0: - text = SUBSCRIPTION_REACTIVATED - else: - text = SUCCESSFULLY_CREATED_SUBSCRIPTION - - await cb.message.edit_text(text, reply_markup=return_to_menu) -``` - -**Конкретные шаги:** -1. Добавить три новых константы в файл с текстами. -2. Обновить `proceed_to_checkout` согласно коду выше. -3. Убедиться, что `BuyResult` импортирован из `services/user_service.py`. -4. Проверить, что `INSUFFICIENT_FUNDS` и `deposit_kb` импортированы. - ---- - -### Фаза 5 — Граничный случай: истёкшая подписка в Remnawave - -**Файл:** `services/user_service.py` - -**Цель:** Корректно обработать ситуацию, когда `get_user_by_telegram_id` возвращает -`RWUserInfo`, но пользователь уже неактивен (`status == "EXPIRED"` или -`expire_at <= now`). - -**Поведение (уже заложено в Ветку C Фазы 3):** -- `is_expired = rw_user.expire_at <= now or rw_user.status in ("EXPIRED", "DISABLED")` -- Если `is_expired=True` → кредит = 0, `new_expire = now + duration`. -- Если `status == "DISABLED"` → вызвать `enable_user` перед обновлением. -- `delta_days = (new_expire - rw_user.expire_at).days` корректно считается как положительное - число (например, если expire_at был 5 дней назад, а мы добавляем 30, delta=35). - -**Специальный случай: статус `"EXPIRED"` в Remnawave** - -Некоторые версии Remnawave при истечении подписки не удаляют пользователя, а меняют -его статус на `"EXPIRED"`. В этом случае: - -```python -# Явная проверка и включение пользователя если нужно -if rw_user.status in ("DISABLED", "EXPIRED"): - await rw.enable_user(rw_sdk, rw_user.uuid) - # enable_user должен переводить пользователя в ACTIVE; - # иначе нужно добавить хелпер set_expire_at или использовать update_user напрямую. -``` - -**Конкретные шаги:** -1. Убедиться, что Ветка C (Фаза 3) содержит проверку `rw_user.status in ("EXPIRED", "DISABLED")`. -2. Проверить, что `rw.enable_user` действительно реактивирует истёкшего пользователя - в используемой версии Remnawave SDK (зависит от конфигурации панели). -3. Если `enable_user` не переводит из `EXPIRED` в `ACTIVE` автоматически — добавить - хелпер `rw.reset_expire(sdk, uuid, new_expire: datetime)` поверх нужного API-эндпоинта RW. -4. Логировать факт реактивации отдельно от обычной замены плана: - ```python - logger.info( - "Реактивация истёкшей подписки: user_id=%s, old_expire=%s, new_expire=%s", - user.id, rw_user.expire_at, new_expire, - ) - ``` - ---- - -## 5. Изменения модели данных - -### 5.1 Дополнение к Gap 7 из FINANCE_REFINEMENT - -FINANCE_REFINEMENT Фаза 3 (Gap 7) добавляет в `Subscription` поля: -`devices`, `duration_months`, `plan_price_paid`, `whitelists`, `rw_uuid`. - -**Настоящий документ требует одно дополнительное поле:** `duration_days`. - -Обоснование: `duration_months` нужен для автопродления (повторить тот же срок). -`duration_days` нужен для расчёта зачёта (формула использует дни, а не месяцы, -потому что месяцы имеют разное количество дней и кредит должен быть точным). - -| Поле | Тип | NULL? | Назначение | -|------|-----|-------|-----------| -| `devices` | `int` | NOT NULL | Кол-во HWID-устройств в плане. Для автопродления и `is_same_plan`. | -| `duration_months` | `int` | NOT NULL | Срок в месяцах (1, 3, 6, 12). Для автопродления. | -| `duration_days` | `int` | NOT NULL | Фактические дни оплаченного периода (30, 90, 180, 365). Для `calculate_remaining_credit`. | -| `plan_price_paid` | `int` | NOT NULL | Списанная сумма в рублях. Для зачёта и аудита. | -| `whitelists` | `bool` | NOT NULL, default False | Whitelist addon в плане. Для `is_same_plan` и автопродления. | -| `rw_uuid` | `str` | NULL | UUID в Remnawave. Для прямых обращений без повторного GET. | - -### 5.2 Обновление модели SQLAlchemy - -```python -# db/models/subscriptions.py - -from sqlalchemy import Boolean, Integer, Text -from sqlalchemy.orm import Mapped, mapped_column - -class Subscription(Base): - __tablename__ = "subscriptions" - - user_id: Mapped[int] = mapped_column(primary_key=True) - end_date: Mapped[datetime] = mapped_column(nullable=False) - autorenew: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - status: Mapped[SubscriptionStatus] = mapped_column(nullable=False) - - # ── Поля плана (добавлены в FINANCE_REFINEMENT Gap 7 + настоящий документ) ── - devices: Mapped[int] = mapped_column(Integer, nullable=False, default=1) - duration_months: Mapped[int] = mapped_column(Integer, nullable=False, default=1) - duration_days: Mapped[int] = mapped_column(Integer, nullable=False, default=30) - plan_price_paid: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - whitelists: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - rw_uuid: Mapped[str | None] = mapped_column(Text, nullable=True, unique=True) -``` - -### 5.3 Alembic-миграция - -Если FINANCE_REFINEMENT Gap 7 уже применён (без `duration_days`), создать -отдельную миграцию: - -```bash -alembic revision --autogenerate -m "add_duration_days_to_subscriptions" -``` - -Проверить и при необходимости скорректировать автогенерированный файл: - -```python -# alembic/versions/xxxx_add_duration_days_to_subscriptions.py - -def upgrade() -> None: - op.add_column( - "subscriptions", - sa.Column("duration_days", sa.Integer(), nullable=False, server_default="30"), - ) - # Удалить server_default после заполнения существующих строк (опционально): - # op.alter_column("subscriptions", "duration_days", server_default=None) - -def downgrade() -> None: - op.drop_column("subscriptions", "duration_days") -``` - -Если Gap 7 ещё не применялся — добавить `duration_days` в ту же миграцию вместе -с остальными полями Gap 7. - -### 5.4 Обновление `subscription_repository` - -```python -# repositories/subscriptions.py - -async def create_subscription( - self, - session, - *, - user_id: int, - end_date: datetime, - autorenew: bool, - status: SubscriptionStatus, - devices: int, - duration_months: int, - duration_days: int, - plan_price_paid: int, - whitelists: bool, - rw_uuid: str | None = None, -) -> Subscription: - sub = Subscription( - user_id=user_id, - end_date=end_date, - autorenew=autorenew, - status=status, - devices=devices, - duration_months=duration_months, - duration_days=duration_days, - plan_price_paid=plan_price_paid, - whitelists=whitelists, - rw_uuid=rw_uuid, - ) - session.add(sub) - await session.flush() - return sub - - -async def update_sub( - self, - session, - *, - user_id: int, - end_date: datetime | None = None, - autorenew: bool | None = None, - status: SubscriptionStatus | None = None, - devices: int | None = None, - duration_months: int | None = None, - duration_days: int | None = None, - plan_price_paid: int | None = None, - whitelists: bool | None = None, -) -> Subscription | None: - sub = await self.get_subscription_by_user_id(session, user_id) - if sub is None: - return None - if end_date is not None: - sub.end_date = end_date - if autorenew is not None: - sub.autorenew = autorenew - if status is not None: - sub.status = status - if devices is not None: - sub.devices = devices - if duration_months is not None: - sub.duration_months = duration_months - if duration_days is not None: - sub.duration_days = duration_days - if plan_price_paid is not None: - sub.plan_price_paid = plan_price_paid - if whitelists is not None: - sub.whitelists = whitelists - await session.flush() - return sub -``` - ---- - -## 6. Граничные случаи и обработка ошибок - -### 6.1 Remnawave недоступен во время замены - -**Проблема:** `buy_subscription` уже начислил кредит (`increase_user_balance`), но -следующий вызов `rw.add_days` / `rw.set_hwid_limit` / `rw.update_squads` упал с -исключением. - -**Решение:** Порядок операций в Ветке C критически важен: - -``` -1. calculate_remaining_credit() ← только вычисление, без side effects -2. increase_user_balance(credit) ← DB операция -3. rw.enable_user() если нужно ← RW -4. rw.add_days(delta) ← RW ← если здесь падает: -5. rw.set_hwid_limit() ← RW кредит уже начислен! -6. rw.update_squads() ← RW -7. update_sub() в БД ← DB -``` - -Если шаги 4–6 падают, кредит уже на балансе, но подписка в RW не обновлена. -Пользователь не в убытке (деньги вернулись), а оператор получает ошибку в логах. - -**Рекомендация:** Обернуть шаги 4–6 в отдельный `try/except` с компенсирующей -транзакцией — снять начисленный кредит обратно при неудаче RW: - -```python -# Ветка C: обёртка для компенсации -try: - if rw_user.status == "DISABLED": - await rw.enable_user(rw_sdk, rw_user.uuid) - if delta_days != 0: - await rw.add_days(rw_sdk, rw_user.uuid, delta_days) - await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices) - await rw.update_squads(rw_sdk, rw_user.uuid, new_squads) -except Exception as rw_error: - # RW недоступен — откатываем кредит - if credit > 0: - await self.user_repository.decrease_user_balance(session, user.id, credit) - logger.warning( - "Кредит %s₽ отозван для user_id=%s: RW вызов упал (%s)", - credit, user.id, rw_error, - ) - raise # Передаём исключение в хендлер, который не будет списывать баланс -``` - -Это обеспечивает полный откат: кредит не начислен, баланс (для покупки) не списан, -подписка не изменена — состояние пользователя как до нажатия кнопки. - ---- - -### 6.2 Отрицательный `delta_days` (даунгрейд с одновременным сокращением срока) - -**Пример:** У пользователя 6 устройств, 60 дней остатка. Покупает 3 устройства на -30 дней. `new_expire = now + 30 дней`, `delta_days = (now+30) - (now+60) = -30`. - -Remnawave SDK принимает отрицательные значения `add_days` в большинстве реализаций -(внутри вызывается `expire_at += timedelta(days=days)`, что работает для отрицательных). - -**Действие:** Перед выпуском в продакшен проверить на тестовом Remnawave-инстансе, -что вызов `add_days(uuid, -30)` корректно уменьшает срок. Если нет — добавить хелпер: - -```python -# services/rw.py — добавить если нужно: -async def set_expire_at(sdk, user_uuid: str, expire_at: datetime) -> bool: - """ - Устанавливает точную дату истечения через прямой PATCH /users/{uuid}. - Использовать если add_days с отрицательным значением не поддерживается. - """ - try: - result = await sdk.users.update_user(uuid=user_uuid, expire_at=expire_at) - return result is not None - except Exception as e: - logger.warning("set_expire_at failed for %s: %s", user_uuid, e) - return False -``` - ---- - -### 6.3 Зачёт делает покупку «доступной» (баланс + кредит ≥ цены) - -**Сценарий:** У пользователя 50₽ на балансе. Хочет купить план за 300₽. Баланс проверяется -в хендлере: 50 < 300 → «Недостаточно средств». Но у пользователя есть активная подписка, -за которую при замене он получил бы 140₽ кредита. 50 + 140 = 190 < 300 → всё равно нет. - -Это не является багом — зачёт начисляется **только внутри `buy_subscription`**, поэтому -хендлер всегда проверяет баланс **без учёта будущего кредита**. Это консервативная, -безопасная проверка. - -Если требуется «провести» пользователя несмотря на недостаточный баланс при условии -достаточности (баланс + ожидаемый кредит ≥ цены), это потребует отдельного API-вызова -для предварительного расчёта кредита в хендлере до проверки баланса. **Не реализовывать -без явного требования от продукта** — усложняет код и может привести к race condition. - ---- - -### 6.4 Округление зачёта - -Формула использует `math.floor`, что всегда даёт значение ≤ «математически точного» кредита. -Это означает, что бизнес иногда получает на 1₽ больше, чем строго пропорционально. Это -намеренно и является стандартной практикой (дробных рублей не существует, округление в пользу -бизнеса корректно для платёжных систем). - -Пример: `plan_price_paid=150, remaining_days=29, duration_days=30` -→ `credit = floor(150 × 29 / 30) = floor(145.0) = 145₽` (не 145₽ 0 коп). - ---- - -### 6.5 Конкурентные покупки (двойное нажатие) - -Обрабатывается FSM-блокировкой из FINANCE_REFINEMENT Фаза 2 (Bug 6): состояние -переключается на `SubscriptionStorage.checkout` **первым действием** в хендлере, что -делает повторный callback необрабатываемым. - -Дополнительная защита на уровне БД: если два запроса всё же прошли одновременно, -`update_sub` работает по `user_id` (PK) — второй UPDATE применится поверх первого, -а не создаст дубль. Кредит может быть начислен дважды только при параллельном исполнении -обоих `buy_subscription` одновременно — FSM-lock предотвращает это. - ---- - -### 6.6 Пользователь существует в БД, но отсутствует в Remnawave - -**Сценарий:** Запись в `subscriptions` есть, но `get_user_by_telegram_id` вернул `None` -(пользователь удалён вручную из панели администратором). - -**Поведение текущего кода:** Выполняется ветка `create_user` — создаётся новый RW-пользователь. -Это корректно. Запись в `subscriptions` будет обновлена с новым `rw_uuid`. - -**Единственный риск:** Если у старого RW-пользователя было активное соединение, оно будет -потеряно. Пользователю нужно будет скачать новую конфигурацию. Это приемлемо как редкий -случай ручного вмешательства администратора. - ---- - -## 7. Сводная таблица задач - -| # | Задача | Фаза | Файлы | Зависимость | Сложность | -|---|--------|------|-------|-------------|-----------| -| SF-0 | Завершить P1, P2, P3 (FINANCE_REFINEMENT) | 0 | Различные | — | 🔴 Средняя | -| SF-1 | Создать `subscription_calculator.py` + `is_same_plan` | 1 | `services/subscription_calculator.py` | SF-0 | 🟡 Лёгкая | -| SF-2 | Добавить `calculate_remaining_credit` | 2 | `services/subscription_calculator.py` | SF-1 | 🟡 Лёгкая | -| SF-3 | Добавить `BuyResult` датакласс | 3 | `services/user_service.py` | SF-2 | 🟢 Мин | -| SF-4 | Переписать `buy_subscription` (3 ветки) | 3 | `services/user_service.py` | SF-3 | 🔴 Средняя | -| SF-5 | Добавить `increase_user_balance` в репозиторий | 3 | `repositories/users.py` | SF-0 | 🟢 Мин | -| SF-6 | Добавить `get_subscription_by_user_id` в репозиторий | 3 | `repositories/subscriptions.py` | SF-0 | 🟢 Мин | -| SF-7 | Обновить `proceed_to_checkout` (BuyResult + amount) | 4 | `bot/handlers/buy.py` | SF-4 | 🟡 Лёгкая | -| SF-8 | Добавить новые текстовые константы | 4 | `bot/texts.py` | SF-7 | 🟢 Мин | -| SF-9 | Обработка статусов EXPIRED/DISABLED в Ветке C | 5 | `services/user_service.py` | SF-4 | 🟢 Мин | -| SF-10 | Добавить `duration_days` в миграцию Alembic | 5 | `alembic/versions/` | SF-0 | 🟡 Лёгкая | -| SF-11 | Unit-тесты для `subscription_calculator.py` | — | `tests/` | SF-2 | 🟡 Лёгкая | -| SF-12 | Проверить `add_days(negative)` в RW на тест-инстансе | — | `services/rw.py` | SF-4 | 🟢 Мин | - -**Обозначения сложности:** -- 🟢 Минимальная — 1–15 строк, без новых зависимостей -- 🟡 Лёгкая — до 60 строк, возможно новый файл или небольшой рефакторинг -- 🔴 Средняя — существенная переработка существующей логики или новая миграция - ---- - -## 8. Рекомендуемый порядок реализации - -### Спринт 1 — Предварительные условия + новый модуль (~1–2 дня) - -Выполнить Фазу 0 (если FINANCE_REFINEMENT ещё не реализован) или убедиться, что -все три prerequisite выполнены. Затем: -- Создать `services/subscription_calculator.py` с `is_same_plan` и - `calculate_remaining_credit` (SF-1, SF-2). -- Написать unit-тесты для обеих функций (SF-11). -- Добавить `duration_days` в модель и миграцию (SF-10). - -### Спринт 2 — Переработка `buy_subscription` (~1–2 дня) - -- Добавить `BuyResult`, `increase_user_balance`, `get_subscription_by_user_id` (SF-3, SF-5, SF-6). -- Переписать `buy_subscription` с тремя ветками (SF-4). -- Добавить обработку EXPIRED/DISABLED (SF-9). -- Проверить `add_days(negative)` на тест-инстансе Remnawave (SF-12). - -### Спринт 3 — UI и финализация (~0.5 дня) - -- Обновить `proceed_to_checkout` для работы с `BuyResult` (SF-7). -- Добавить новые текстовые константы (SF-8). -- Сквозное тестирование всех трёх сценариев: создание, продление, замена. - ---- - -> **Итоговый инвариант системы после реализации:** -> -> - Пользователь **никогда не получает бесплатный апгрейд** за счёт бизнеса. -> - Пользователь **никогда не теряет оплаченные дни** без компенсации. -> - Срок подписки при смене плана всегда равен `now + new_duration` (не аддитивен к остатку). -> - Кредит отображается явно — прозрачно для пользователя. -> - При недоступности Remnawave — состояние пользователя остаётся неизменным. - ---- - -*Документ создан на основе анализа кодовой базы и описания проблемы. Актуализировать -при изменении API Remnawave или схемы тарификации.* \ No newline at end of file