- 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.
64 KiB
🔄 SUBSCRIPTION_FIX — Исправление логики смены и продления тарифного плана
Связанный документ:
FINANCE_REFINEMENT.md— Фазы 1 и 3 являются обязательными предварительными условиями для данного плана.
Содержание
- Постановка проблемы
- Выбранное решение: «Замена со зачётом»
- 2.1 Алгоритм
- 2.2 Числовой пример решения
- 2.3 Обоснование выбора
- 2.4 Отклонённые альтернативы
- Предварительные условия (Prerequisites)
- Поэтапный план реализации
- Изменения модели данных
- Граничные случаи и обработка ошибок
- Сводная таблица задач
- Рекомендуемый порядок реализации
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):
rw_userнайден → входим в ветку продления.add_days(rw_user.uuid, 30)— добавляет 30 дней поверх оставшихся ~27 дней.set_hwid_limit(rw_user.uuid, 6)— лимит устройств обновляется до 6.- Итог: пользователь получает 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):
rw_userнайден → ветка продления.add_days(rw_user.uuid, 30)— добавляет 30 дней поверх оставшихся ~76 дней.set_hwid_limit(rw_user.uuid, 3)— лимит устройств понижается до 3.update_squads(...)— whitelist squad удаляется.- Итог: пользователь получает 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 необходим для точного расчёта зачёта, потому что месяцы имеют разное
количество дней (28–31), а формула зачёта оперирует днями, а не месяцами.
Подробности — в Секции 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).
Сравнение производится по двум параметрам:
hwid_device_limitв Remnawave vsnew_plan.devices.- Наличие UUID whitelist-squad в
rw_user.active_squadsvsnew_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
Конкретные шаги:
- Создать файл
services/subscription_calculator.py. - Добавить функцию
is_same_plan(код выше). - Убедиться, что
InternalSquadsэкспортируется изservices/rw.pyилиschemas/subscriptions.py— если нет, вынести вconfig.pyилиconstants.py. - Написать минимальные 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.
# 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)
Конкретные шаги:
- Дополнить
services/subscription_calculator.pyфункциейcalculate_remaining_credit. - Добавить импорты:
math,datetime,UTC, модельSubscription. - Написать 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, чтобы хендлер знал, был ли начислен кредит.
# 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,
)
Конкретные шаги:
- Открыть
services/user_service.py. - Добавить
@dataclass BuyResult(или вынести вschemas/subscriptions.py). - Добавить импорты:
is_same_plan,calculate_remaining_creditиз нового модуля. - Полностью переписать метод
buy_subscriptionсогласно коду выше. - Убедиться, что
subscription_repositoryимеет методget_subscription_by_user_id. Если нет — добавить вrepositories/subscriptions.py. - Убедиться, что
user_repositoryимеет методincrease_user_balance. Если нет — добавить вrepositories/users.py(симметричноdecrease_user_balance). - Обновить хендлер
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)
Конкретные шаги:
- Добавить три новых константы в файл с текстами.
- Обновить
proceed_to_checkoutсогласно коду выше. - Убедиться, что
BuyResultимпортирован изservices/user_service.py. - Проверить, что
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 напрямую.
Конкретные шаги:
- Убедиться, что Ветка C (Фаза 3) содержит проверку
rw_user.status in ("EXPIRED", "DISABLED"). - Проверить, что
rw.enable_userдействительно реактивирует истёкшего пользователя в используемой версии Remnawave SDK (зависит от конфигурации панели). - Если
enable_userне переводит изEXPIREDвACTIVEавтоматически — добавить хелперrw.reset_expire(sdk, uuid, new_expire: datetime)поверх нужного API-эндпоинта RW. - Логировать факт реактивации отдельно от обычной замены плана:
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
Если шаги 4–6 падают, кредит уже на балансе, но подписка в RW не обновлена. Пользователь не в убытке (деньги вернулись), а оператор получает ошибку в логах.
Рекомендация: Обернуть шаги 4–6 в отдельный 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=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 или схемы тарификации.