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

64 KiB
Raw Blame History

🔄 SUBSCRIPTION_FIX — Исправление логики смены и продления тарифного плана

Связанный документ: FINANCE_REFINEMENT.md — Фазы 1 и 3 являются обязательными предварительными условиями для данного плана.


Содержание

  1. Постановка проблемы
  2. Выбранное решение: «Замена со зачётом»
  3. Предварительные условия (Prerequisites)
  4. Поэтапный план реализации
  5. Изменения модели данных
  6. Граничные случаи и обработка ошибок
  7. Сводная таблица задач
  8. Рекомендуемый порядок реализации

1. Постановка проблемы

Текущий метод UserService.buy_subscription обрабатывает всех существующих пользователей Remnawave единственным способом: добавить дни к текущему сроку истечения. Никакого понятия «смена плана» не существует. Это порождает четыре принципиально разных сценария, из которых только один обрабатывается правильно.


1.1 «То же продление» — ожидаемое поведение (корректно)

Сценарий: Пользователь купил подписку на 3 устройства на 1 месяц. Через 10 дней покупает ещё раз те же 3 устройства на 1 месяц.

Текущее поведение (после исправлений Bug 13 из 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 13):

  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 13):

  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).


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).


2.4 Отклонённые альтернативы

«Запретить смену плана до окончания периода» Самый простой в реализации подход: показывать пользователю сообщение «Смена тарифа недоступна до X дата». Отклонён по причине плохого UX — пользователь, которому нужно больше устройств прямо сейчас, уйдёт к конкуренту.

«Списать полную стоимость, игнорируя остаток» Пользователь платит полную цену нового плана, оставшиеся дни сгорают без компенсации. Это двойное снятие денег (за уже оплаченный период + новый период), что воспринимается как мошенничество и является прямой причиной chargebacks.

«Показать счёт на доплату (proratedinvoice)» Вычислить только разницу и попросить пользователя доплатить. Концептуально верно, но требует отдельного экрана подтверждения, динамически рассчитываемой суммы в InlineKeyboard, и повторного прохождения платёжного флоу. Значительно сложнее как в UX, так и в реализации. Отклонён как избыточный — «Замена со зачётом» даёт тот же финансовый результат проще.


3. Предварительные условия (Prerequisites)

Следующие задачи из FINANCE_REFINEMENT.md должны быть выполнены до начала работы по настоящему плану. Они перечислены здесь для ясности — реализация описана в том документе.


P1 — Bug 1: Исправить аргумент rw.add_days (FINANCE_REFINEMENT, Фаза 1, §1.1)

# БЫЛО (сломано):
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 необходим для точного расчёта зачёта, потому что месяцы имеют разное количество дней (2831), а формула зачёта оперирует днями, а не месяцами.

Подробности — в Секции 5.

Почему это prerequisite: calculate_remaining_credit() читает subscription.plan_price_paid и subscription.duration_days. Без этих полей зачёт невозможно рассчитать.


P3 — Bug 5: Атомарность proceed_to_checkout (FINANCE_REFINEMENT, Фаза 2, §2.1)

# ИСПРАВЛЕННЫЙ порядок:
# 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.
# 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=6False.
    • Тест: hwid=6 в RW, whitelist squad присутствует, new_plan.whitelists=FalseFalse.
    • Тест: 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.
# 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=28credit=140.
    • plan_price_paid=150, duration_days=30, remaining_days=0credit=0.
    • plan_price_paid=0, duration_days=30, remaining_days=20credit=0 (нет данных).
    • plan_price_paid=2100, duration_days=90, remaining_days=76credit=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, чтобы хендлер знал, был ли начислен кредит.

# 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 (или аналогичный файл с текстами)

Цель: Показывать разные сообщения в зависимости от результата операции:

  • Чистое создание или продление → стандартный текст (уже есть).
  • Замена плана с зачётом → новый текст с упоминанием начисленного зачёта.
  • Замена плана без зачёта (истёкшая подписка) → текст про повторную активацию.

Новые текстовые константы:

# bot/texts.py (или аналог)

# Уже существующий:
SUCCESSFULLY_CREATED_SUBSCRIPTION = (
    "✅ <b>Подписка активирована!</b>\n\n"
    "Используйте кнопку ниже для получения конфигурации."
)

# Новый — для чистого продления:
SUCCESSFULLY_RENEWED_SUBSCRIPTION = (
    "✅ <b>Подписка продлена!</b>\n\n"
    "Срок действия вашей текущей подписки увеличен."
)

# Новый — для замены плана с зачётом:
SUBSCRIPTION_REPLACED_WITH_CREDIT = (
    "✅ <b>Тариф обновлён!</b>\n\n"
    "На ваш баланс зачислено <b>{credit}₽</b> за неиспользованные дни прежнего тарифа.\n"
    "Новая подписка активна с сегодняшнего дня."
)

# Новый — для замены плана без зачёта (истёкшая):
SUBSCRIPTION_REACTIVATED = (
    "✅ <b>Подписка возобновлена!</b>\n\n"
    "Ваша подписка была неактивна. Новый тариф действует с сегодняшнего дня."
)

Обновлённый хендлер proceed_to_checkout:

# 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". В этом случае:

# Явная проверка и включение пользователя если нужно
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. Логировать факт реактивации отдельно от обычной замены плана:
    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

# 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), создать отдельную миграцию:

alembic revision --autogenerate -m "add_duration_days_to_subscriptions"

Проверить и при необходимости скорректировать автогенерированный файл:

# 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

# 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

Если шаги 46 падают, кредит уже на балансе, но подписка в RW не обновлена. Пользователь не в убытке (деньги вернулись), а оператор получает ошибку в логах.

Рекомендация: Обернуть шаги 46 в отдельный try/except с компенсирующей транзакцией — снять начисленный кредит обратно при неудаче RW:

# Ветка 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) корректно уменьшает срок. Если нет — добавить хелпер:

# 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=30credit = 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 🟢 Мин

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

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

8. Рекомендуемый порядок реализации

Спринт 1 — Предварительные условия + новый модуль (~12 дня)

Выполнить Фазу 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 (~12 дня)

  • Добавить 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 или схемы тарификации.