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