diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py
index b3d16228..17378071 100644
--- a/app/cabinet/routes/subscription.py
+++ b/app/cabinet/routes/subscription.py
@@ -4454,19 +4454,27 @@ async def toggle_subscription_pause(
detail='Pause is only available for daily tariffs',
)
- # Toggle pause state
- is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
- new_paused_state = not is_currently_paused
- user.subscription.is_daily_paused = new_paused_state
-
- # Сохраняем статус ДО изменения для проверки RemnaWave
+ # Determine current state
from app.database.models import SubscriptionStatus
- was_disabled = user.subscription.status == SubscriptionStatus.DISABLED.value
+ is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
+ was_disabled = user.subscription.status in (
+ SubscriptionStatus.DISABLED.value,
+ SubscriptionStatus.EXPIRED.value,
+ )
- # If resuming, check balance
+ # System-DISABLED subs (insufficient balance) should always be treated as needing resume,
+ # even if is_daily_paused is False (it's set by the system, not the user)
+ if was_disabled and not is_currently_paused:
+ new_paused_state = False # Force resume path
+ else:
+ new_paused_state = not is_currently_paused
+ user.subscription.is_daily_paused = new_paused_state
+
+ daily_price = getattr(tariff, 'daily_price_kopeks', 0)
+
+ # If resuming, check balance and charge
if not new_paused_state:
- daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and user.balance_kopeks < daily_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -4478,8 +4486,44 @@ async def toggle_subscription_pause(
},
)
- # Restore ACTIVE status if was DISABLED
+ # Charge daily fee FIRST, then restore ACTIVE status
if was_disabled:
+ if daily_price > 0:
+ from app.database.crud.user import subtract_user_balance
+
+ deducted = await subtract_user_balance(
+ db,
+ user,
+ daily_price,
+ f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
+ mark_as_paid_subscription=True,
+ )
+ if not deducted:
+ raise HTTPException(
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
+ detail={
+ 'code': 'insufficient_balance',
+ 'message': 'Balance deduction failed',
+ 'required': daily_price,
+ 'balance': user.balance_kopeks,
+ },
+ )
+
+ from app.database.crud.transaction import create_transaction
+ from app.database.models import TransactionType
+
+ try:
+ await create_transaction(
+ db=db,
+ user_id=user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=daily_price,
+ description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
+ )
+ except Exception as exc:
+ logger.warning('Failed to create resume transaction', error=exc)
+
+ # Balance deducted successfully — now activate
user.subscription.status = SubscriptionStatus.ACTIVE.value
user.subscription.last_daily_charge_at = datetime.now(UTC)
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
@@ -4489,14 +4533,17 @@ async def toggle_subscription_pause(
await db.refresh(user)
# Sync with RemnaWave only when resuming from DISABLED state
- # При паузе НЕ отключаем - пользователь может пользоваться до конца оплаченного периода
- # При возобновлении включаем только если подписка была отключена (DISABLED)
- if not new_paused_state and user.remnawave_uuid and was_disabled:
+ if not new_paused_state and was_disabled:
try:
subscription_service = SubscriptionService()
- await subscription_service.enable_remnawave_user(user.remnawave_uuid)
+ await subscription_service.create_remnawave_user(
+ db,
+ user.subscription,
+ reset_traffic=False,
+ reset_reason=None,
+ )
except Exception as e:
- logger.error('Error enabling RemnaWave user on resume', error=e)
+ logger.error('Error syncing RemnaWave user on resume', error=e)
if new_paused_state:
message = 'Daily subscription paused'
diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py
index a38c70a4..d50eddec 100644
--- a/app/database/crud/subscription.py
+++ b/app/database/crud/subscription.py
@@ -2094,6 +2094,9 @@ async def get_disabled_daily_subscriptions_for_resume(
Subscription.status == SubscriptionStatus.DISABLED.value,
User.status == UserStatus.ACTIVE.value,
Subscription.is_trial.is_(False),
+ # Не возобновляем подписки, приостановленные пользователем вручную
+ # is_(False) не ловит NULL, поэтому добавляем OR is_(None)
+ (Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
# Баланс пользователя >= суточной цены тарифа
User.balance_kopeks >= Tariff.daily_price_kopeks,
)
@@ -2135,7 +2138,8 @@ async def get_expired_daily_subscriptions_for_recovery(db: AsyncSession) -> list
Tariff.is_active.is_(True),
Subscription.status == SubscriptionStatus.EXPIRED.value,
User.status == UserStatus.ACTIVE.value,
- Subscription.is_daily_paused.is_(False),
+ # is_(False) не ловит NULL, поэтому добавляем OR is_(None)
+ (Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
Subscription.is_trial.is_(False),
# Только недавно экспайренные
Subscription.updated_at >= recovery_threshold,
diff --git a/app/database/crud/user.py b/app/database/crud/user.py
index 4f4be174..5919a44c 100644
--- a/app/database/crud/user.py
+++ b/app/database/crud/user.py
@@ -449,40 +449,10 @@ async def add_user_balance(
amount_kopeks=amount_kopeks,
)
- # Автоматическое возобновление приостановленной суточной подписки
- try:
- from app.database.crud.subscription import get_subscription_by_user_id, resume_daily_subscription
- from app.database.crud.tariff import get_tariff_by_id
- from app.database.models import SubscriptionStatus
-
- # Загружаем подписку явно, чтобы избежать lazy loading
- subscription = await get_subscription_by_user_id(db, user.id)
- if subscription and subscription.status == SubscriptionStatus.DISABLED.value:
- # Проверяем что это суточный тариф
- is_daily = getattr(subscription, 'is_daily_tariff', False)
- if is_daily and subscription.tariff_id:
- # Загружаем тариф явно
- tariff = await get_tariff_by_id(db, subscription.tariff_id)
- if tariff:
- daily_price = getattr(tariff, 'daily_price_kopeks', 0)
- # Если баланс достаточный для суточной оплаты - возобновляем
- if daily_price > 0 and user.balance_kopeks >= daily_price:
- await resume_daily_subscription(db, subscription)
- logger.info(
- '✅ Автоматически возобновлена суточная подписка после пополнения баланса (user_id=)',
- subscription_id=subscription.id,
- user_id=user.id,
- )
- # Синхронизируем с RemnaWave
- try:
- from app.services.subscription_service import SubscriptionService
-
- subscription_service = SubscriptionService()
- await subscription_service.update_remnawave_user(db, subscription)
- except Exception as sync_err:
- logger.warning('Не удалось синхронизировать с RemnaWave', sync_err=sync_err)
- except Exception as resume_err:
- logger.warning('Ошибка при попытке возобновить суточную подписку', resume_err=resume_err)
+ # Авто-возобновление суточной подписки НЕ делаем здесь —
+ # это обязанность try_resume_disabled_daily_after_topup (через send_cart_notification_after_topup)
+ # и DailySubscriptionService.process_auto_resume (30-минутный цикл).
+ # Они корректно списывают суточную плату при возобновлении.
return True
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
index 88aa0565..48d11002 100644
--- a/app/handlers/subscription/purchase.py
+++ b/app/handlers/subscription/purchase.py
@@ -208,7 +208,11 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
current_time = datetime.now(UTC)
- if subscription.status == 'expired' or subscription.end_date <= current_time:
+ if subscription.status == 'disabled':
+ actual_status = 'disabled'
+ status_display = texts.t('SUBSCRIPTION_STATUS_DISABLED', 'Приостановлена')
+ status_emoji = '⏸️'
+ elif subscription.status == 'expired' or subscription.end_date <= current_time:
actual_status = 'expired'
status_display = texts.t('SUBSCRIPTION_STATUS_EXPIRED', 'Истекла')
status_emoji = '🔴'
@@ -3222,6 +3226,42 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
return
if needs_resume:
+ # Списываем суточную оплату ДО активации (чтобы не было бесплатного дня)
+ daily_price = getattr(tariff, 'daily_price_kopeks', 0)
+ if daily_price > 0 and is_inactive:
+ from app.database.crud.user import subtract_user_balance
+
+ deducted = await subtract_user_balance(
+ db,
+ db_user,
+ daily_price,
+ f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
+ mark_as_paid_subscription=True,
+ )
+ if not deducted:
+ await callback.answer(
+ texts.t(
+ 'INSUFFICIENT_BALANCE_FOR_RESUME',
+ f'❌ Недостаточно средств для возобновления. Требуется: {settings.format_price(daily_price)}',
+ ),
+ show_alert=True,
+ )
+ return
+
+ from app.database.crud.transaction import create_transaction
+ from app.database.models import TransactionType
+
+ try:
+ await create_transaction(
+ db=db,
+ user_id=db_user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=daily_price,
+ description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
+ )
+ except Exception as tx_error:
+ logger.warning('Не удалось создать транзакцию при возобновлении', error=tx_error)
+
# Принудительный resume: снимаем паузу + восстанавливаем статус ACTIVE
from app.database.crud.subscription import resume_daily_subscription
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index ed6038a7..4845ed78 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -1704,6 +1704,7 @@
"DAILY_SWITCH_WARNING": "⚠️ Warning! You have {days} days left.\nThey will be lost when switching to daily tariff!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!",
+ "DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ Subscription resumed!\n\nYour daily plan «{tariff_name}» has been resumed after balance top-up.\n\n💳 Charged: {amount}\n💰 Remaining: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ Subscription expired\n\nYour subscription has ended. Renew to restore VPN access.",
"WEBHOOK_SUB_DISABLED": "🚫 Subscription disabled\n\nYour subscription has been disabled by the administrator.",
diff --git a/app/localization/locales/fa.json b/app/localization/locales/fa.json
index df5dfcd2..209f5387 100644
--- a/app/localization/locales/fa.json
+++ b/app/localization/locales/fa.json
@@ -1723,6 +1723,7 @@
"DAILY_SWITCH_WARNING": "⚠️ توجه! {days} روز اشتراک باقی مانده.\nبا تغییر به تعرفه روزانه از دست میروند!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ اشتراک متوقف شد",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ اشتراک از سر گرفته شد!",
+ "DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ اشتراک از سر گرفته شد!\n\nتعرفه روزانه «{tariff_name}» پس از شارژ موجودی از سر گرفته شد.\n\n💳 کسر شده: {amount}\n💰 باقیمانده: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ اشتراک منقضی شد\n\nاشتراک شما به پایان رسیده است. برای بازیابی دسترسی VPN تمدید کنید.",
"WEBHOOK_SUB_DISABLED": "🚫 اشتراک غیرفعال شد\n\nاشتراک شما توسط مدیر غیرفعال شده است.",
"WEBHOOK_SUB_ENABLED": "✅ اشتراک فعال شد\n\nاشتراک شما دوباره فعال است. از استفاده لذت ببرید!",
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index 7ed56bd2..a917e50b 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -1725,6 +1725,7 @@
"DAILY_SWITCH_WARNING": "⚠️ Внимание! У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!",
+ "DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ Подписка возобновлена!\n\nВаш суточный тариф «{tariff_name}» возобновлён после пополнения баланса.\n\n💳 Списано: {amount}\n💰 Остаток: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ Подписка истекла\n\nВаша подписка завершена. Продлите подписку, чтобы восстановить доступ к VPN.",
"WEBHOOK_SUB_DISABLED": "🚫 Подписка отключена\n\nВаша подписка была отключена администратором.",
diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json
index cbe0b608..33b79d3c 100644
--- a/app/localization/locales/ua.json
+++ b/app/localization/locales/ua.json
@@ -1592,6 +1592,9 @@
"MODEM_PRICE_WITH_DISCOUNT": "Вартість: {base_price} {final_price} (за {months} міс)\n🎁 Знижка {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Вартість: {price} (за {months} міс)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 Підтвердження підключення модема\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?",
+ "DAILY_SUBSCRIPTION_PAUSED": "⏸️ Підписка призупинена",
+ "DAILY_SUBSCRIPTION_RESUMED": "▶️ Підписка відновлена!",
+ "DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ Підписка відновлена!\n\nВаш добовий тариф «{tariff_name}» відновлено після поповнення балансу.\n\n💳 Списано: {amount}\n💰 Залишок: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ Підписка закінчилась\n\nВаша підписка завершена. Продовжте підписку, щоб відновити доступ до VPN.",
"WEBHOOK_SUB_DISABLED": "🚫 Підписку вимкнено\n\nВашу підписку було вимкнено адміністратором.",
"WEBHOOK_SUB_ENABLED": "✅ Підписку активовано\n\nВаша підписка знову активна. Приємного використання!",
diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json
index 83a23ddc..6fb547d1 100644
--- a/app/localization/locales/zh.json
+++ b/app/localization/locales/zh.json
@@ -1588,6 +1588,9 @@
"MODEM_PRICE_WITH_DISCOUNT": "费用:{base_price} {final_price}({months}个月)\n🎁 折扣{discount}%:-{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "费用:{price}({months}个月)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 确认连接调制解调器\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?",
+"DAILY_SUBSCRIPTION_PAUSED": "⏸️ 订阅已暂停",
+"DAILY_SUBSCRIPTION_RESUMED": "▶️ 订阅已恢复!",
+"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ 订阅已恢复!\n\n您的日套餐「{tariff_name}」已在充值后恢复。\n\n💳 扣费:{amount}\n💰 余额:{balance}",
"WEBHOOK_SUB_EXPIRED": "❌ 订阅已过期\n\n您的订阅已结束。请续订以恢复VPN访问。",
"WEBHOOK_SUB_DISABLED": "🚫 订阅已禁用\n\n您的订阅已被管理员禁用。",
"WEBHOOK_SUB_ENABLED": "✅ 订阅已激活\n\n您的订阅已重新激活。祝使用愉快!",
diff --git a/app/services/payment/common.py b/app/services/payment/common.py
index 3f00ad4c..45f94d3d 100644
--- a/app/services/payment/common.py
+++ b/app/services/payment/common.py
@@ -306,7 +306,37 @@ async def send_cart_notification_after_topup(
from aiogram import types
from app.database.crud.user import get_user_by_id
- from app.services.subscription_auto_purchase_service import auto_purchase_saved_cart_after_topup
+ from app.services.subscription_auto_purchase_service import (
+ auto_purchase_saved_cart_after_topup,
+ try_auto_extend_expired_after_topup,
+ try_resume_disabled_daily_after_topup,
+ )
+
+ # Try to resume DISABLED daily subscription immediately (highest priority)
+ try:
+ daily_resumed = await try_resume_disabled_daily_after_topup(db, user, bot=bot)
+ if daily_resumed:
+ return False
+ except Exception as daily_error:
+ logger.error(
+ 'Ошибка авто-возобновления суточной подписки после пополнения',
+ user_id=user.id,
+ error=daily_error,
+ exc_info=True,
+ )
+
+ # Try to auto-extend expired subscription (works without cart)
+ try:
+ auto_extended = await try_auto_extend_expired_after_topup(db, user, bot=bot)
+ if auto_extended:
+ return False
+ except Exception as extend_error:
+ logger.error(
+ 'Ошибка автопродления истёкшей подписки после пополнения',
+ user_id=user.id,
+ error=extend_error,
+ exc_info=True,
+ )
cart_data = await user_cart_service.get_user_cart(user.id)
if not cart_data:
diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py
index 56a9e748..59bedd01 100644
--- a/app/services/subscription_auto_purchase_service.py
+++ b/app/services/subscription_auto_purchase_service.py
@@ -15,7 +15,7 @@ from app.config import settings
from app.database.crud.subscription import extend_subscription
from app.database.crud.transaction import create_transaction
from app.database.crud.user import get_user_by_id, subtract_user_balance
-from app.database.models import Subscription, TransactionType, User
+from app.database.models import Subscription, SubscriptionStatus, TransactionType, User
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.subscription_checkout_service import clear_subscription_checkout_draft
@@ -1641,6 +1641,574 @@ async def _auto_add_traffic(
return True
+async def try_auto_extend_expired_after_topup(
+ db: AsyncSession,
+ user: User,
+ *,
+ bot: Bot | None = None,
+) -> bool:
+ """Try to auto-extend an expired subscription after balance top-up.
+
+ Unlike cart-based auto-purchase, this works without a saved cart.
+ It finds the user's expired subscription and attempts to extend it
+ with the shortest available period if the balance is sufficient.
+
+ Returns True if the subscription was successfully extended.
+ """
+ from app.cabinet.routes.websocket import notify_user_subscription_renewed
+ from app.database.crud.subscription import get_subscription_by_user_id
+ from app.database.crud.transaction import get_user_transactions
+
+ if not user or not getattr(user, 'id', None):
+ return False
+
+ subscription = await get_subscription_by_user_id(db, user.id)
+ if subscription is None:
+ logger.debug(
+ '🔄 Автопродление expired: у пользователя нет подписки',
+ format_user_id=_format_user_id(user),
+ )
+ return False
+
+ # Only process expired subscriptions (not trial, not disabled)
+ if subscription.status != SubscriptionStatus.EXPIRED.value:
+ return False
+ if subscription.is_trial:
+ return False
+
+ # Only process subscriptions expired within the last 30 days
+ if subscription.end_date is None:
+ return False
+ expired_delta = datetime.now(UTC) - subscription.end_date
+ if expired_delta.days > 30:
+ logger.info(
+ '🔄 Автопродление expired: подписка истекла более 30 дней назад',
+ format_user_id=_format_user_id(user),
+ expired_days=expired_delta.days,
+ )
+ return False
+
+ # Determine renewal period from tariff or default to 30 days
+ tariff = getattr(subscription, 'tariff', None)
+ if tariff:
+ period_days = tariff.get_shortest_period() or 30
+ else:
+ period_days = 30
+
+ # Calculate renewal price
+ subscription_service = SubscriptionService()
+ try:
+ renewal_cost = await subscription_service.calculate_renewal_price(
+ subscription, period_days, db, user=user,
+ )
+ except Exception as error:
+ logger.error(
+ '❌ Автопродление expired: ошибка расчёта стоимости',
+ format_user_id=_format_user_id(user),
+ error=error,
+ exc_info=True,
+ )
+ return False
+
+ if renewal_cost <= 0:
+ logger.warning(
+ '❌ Автопродление expired: некорректная стоимость',
+ format_user_id=_format_user_id(user),
+ renewal_cost=renewal_cost,
+ )
+ return False
+
+ # Check balance
+ if user.balance_kopeks < renewal_cost:
+ logger.info(
+ '🔄 Автопродление expired: недостаточно средств',
+ format_user_id=_format_user_id(user),
+ balance_kopeks=user.balance_kopeks,
+ renewal_cost=renewal_cost,
+ )
+ return False
+
+ # Race condition guard: skip if a subscription payment was made in the last 60 seconds
+ try:
+ recent_transactions = await get_user_transactions(db, user.id, limit=1)
+ if recent_transactions:
+ last_tx = recent_transactions[0]
+ if (
+ last_tx.type == TransactionType.SUBSCRIPTION_PAYMENT
+ and last_tx.created_at
+ and (datetime.now(UTC) - last_tx.created_at) < timedelta(seconds=60)
+ ):
+ logger.info(
+ '🔄 Автопродление expired: пропуск — подписка оплачена секунд назад',
+ format_user_id=_format_user_id(user),
+ total_seconds=(datetime.now(UTC) - last_tx.created_at).total_seconds(),
+ )
+ return False
+ except Exception as check_error:
+ logger.warning(
+ '🔄 Автопродление expired: ошибка проверки последней транзакции',
+ format_user_id=_format_user_id(user),
+ check_error=check_error,
+ )
+
+ # Determine if promo offer discount was applied (for consume flag)
+ from app.utils.promo_offer import get_user_active_promo_discount_percent
+
+ consume_promo_offer = get_user_active_promo_discount_percent(user) > 0
+
+ # Deduct balance
+ description = f'Автопродление истёкшей подписки на {period_days} дней'
+ try:
+ deducted = await subtract_user_balance(
+ db,
+ user,
+ renewal_cost,
+ description,
+ consume_promo_offer=consume_promo_offer,
+ mark_as_paid_subscription=True,
+ )
+ except Exception as error:
+ logger.error(
+ '❌ Автопродление expired: ошибка списания средств',
+ format_user_id=_format_user_id(user),
+ error=error,
+ exc_info=True,
+ )
+ return False
+
+ if not deducted:
+ logger.warning(
+ '❌ Автопродление expired: списание средств не выполнено',
+ format_user_id=_format_user_id(user),
+ )
+ return False
+
+ old_end_date = subscription.end_date
+ was_trial = subscription.is_trial
+
+ # Extend subscription
+ try:
+ updated_subscription = await extend_subscription(db, subscription, period_days)
+
+ # Convert trial to paid if needed
+ if was_trial and subscription.is_trial:
+ subscription.is_trial = False
+ subscription.status = 'active'
+ await db.commit()
+ logger.info(
+ '✅ Триал конвертирован в платную подписку (автопродление expired)',
+ subscription_id=subscription.id,
+ format_user_id=_format_user_id(user),
+ )
+ except Exception as error:
+ logger.error(
+ '❌ Автопродление expired: не удалось продлить подписку',
+ format_user_id=_format_user_id(user),
+ error=error,
+ exc_info=True,
+ )
+ await db.rollback()
+ return False
+
+ # Create transaction record
+ transaction = None
+ try:
+ transaction = await create_transaction(
+ db=db,
+ user_id=user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=renewal_cost,
+ description=description,
+ )
+ except Exception as error:
+ logger.error(
+ '⚠️ Автопродление expired: не удалось зафиксировать транзакцию',
+ format_user_id=_format_user_id(user),
+ error=error,
+ exc_info=True,
+ )
+
+ # Update RemnaWave
+ try:
+ await subscription_service.update_remnawave_user(
+ db,
+ updated_subscription,
+ reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
+ reset_reason='автопродление истёкшей подписки',
+ )
+ except Exception as error:
+ logger.error(
+ '⚠️ Автопродление expired: не удалось обновить RemnaWave',
+ format_user_id=_format_user_id(user),
+ error=error,
+ )
+
+ texts = get_texts(getattr(user, 'language', 'ru'))
+ period_label = format_period_description(period_days, getattr(user, 'language', 'ru'))
+ new_end_date = updated_subscription.end_date
+ end_date_label = format_local_datetime(new_end_date, '%d.%m.%Y %H:%M')
+
+ # Admin notification
+ try:
+ from app.services.subscription_renewal_service import with_admin_notification_service
+
+ await with_admin_notification_service(
+ lambda svc: svc.send_subscription_extension_notification(
+ db,
+ user,
+ updated_subscription,
+ transaction,
+ period_days,
+ old_end_date,
+ new_end_date=new_end_date,
+ balance_after=user.balance_kopeks,
+ )
+ )
+ except Exception as error:
+ logger.error(
+ '⚠️ Автопродление expired: не удалось уведомить администраторов',
+ format_user_id=_format_user_id(user),
+ error=error,
+ )
+
+ # Send user notification (only for Telegram users)
+ if bot and user.telegram_id:
+ try:
+ auto_message = texts.t(
+ 'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED',
+ '✅ Subscription automatically extended for {period}.',
+ ).format(period=period_label)
+ details_message = texts.t(
+ 'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS',
+ 'New expiration date: {date}.',
+ ).format(date=end_date_label)
+ hint_message = texts.t(
+ 'AUTO_PURCHASE_SUBSCRIPTION_HINT',
+ "Open the 'My subscription' section to access your link.",
+ )
+
+ full_message = '\n\n'.join(
+ part.strip() for part in [auto_message, details_message, hint_message] if part and part.strip()
+ )
+
+ keyboard = InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 My subscription'),
+ callback_data='menu_subscription',
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Main menu'),
+ callback_data='back_to_menu',
+ )
+ ],
+ ]
+ )
+
+ await bot.send_message(
+ chat_id=user.telegram_id,
+ text=full_message,
+ reply_markup=keyboard,
+ parse_mode='HTML',
+ )
+ except Exception as error:
+ logger.error(
+ '⚠️ Автопродление expired: не удалось уведомить пользователя',
+ telegram_id=user.telegram_id or user.id,
+ error=error,
+ )
+
+ logger.info(
+ '✅ Автопродление expired: подписка продлена для пользователя',
+ period_days=period_days,
+ renewal_cost=renewal_cost,
+ format_user_id=_format_user_id(user),
+ )
+
+ # Send WebSocket notification
+ try:
+ await notify_user_subscription_renewed(
+ user_id=user.id,
+ new_expires_at=new_end_date.isoformat() if new_end_date else '',
+ amount_kopeks=renewal_cost,
+ )
+ except Exception as ws_error:
+ logger.warning(
+ '⚠️ Автопродление expired: не удалось отправить WS уведомление',
+ format_user_id=_format_user_id(user),
+ ws_error=ws_error,
+ )
+
+ return True
+
+
+async def try_resume_disabled_daily_after_topup(
+ db: AsyncSession,
+ user: User,
+ *,
+ bot: Bot | None = None,
+) -> bool:
+ """Resume a DISABLED daily subscription immediately after balance top-up.
+
+ Daily subscriptions get DISABLED when balance is insufficient.
+ The DailySubscriptionService loop picks them up every 30 minutes,
+ but this function provides instant resumption right when the user tops up.
+
+ Returns True if the subscription was successfully resumed and charged.
+ """
+ from app.cabinet.routes.websocket import notify_user_subscription_renewed
+ from app.database.crud.subscription import get_subscription_by_user_id, update_daily_charge_time
+
+ if not user or not getattr(user, 'id', None):
+ return False
+
+ subscription = await get_subscription_by_user_id(db, user.id)
+ if subscription is None:
+ return False
+
+ # Only handle DISABLED (or EXPIRED) daily tariff subscriptions
+ if subscription.status not in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
+ return False
+ if not getattr(subscription, 'is_daily_tariff', False):
+ return False
+ if subscription.is_trial:
+ return False
+ # Skip user-paused subscriptions — they chose to pause, don't auto-resume
+ if getattr(subscription, 'is_daily_paused', False):
+ return False
+
+ tariff = getattr(subscription, 'tariff', None)
+ if not tariff:
+ return False
+
+ daily_price = getattr(tariff, 'daily_price_kopeks', 0)
+ if daily_price <= 0:
+ return False
+
+ # Check balance
+ if user.balance_kopeks < daily_price:
+ logger.info(
+ '🔄 Авто-возобновление daily: недостаточно средств',
+ format_user_id=_format_user_id(user),
+ balance_kopeks=user.balance_kopeks,
+ daily_price=daily_price,
+ )
+ return False
+
+ # Race condition guard: skip if a subscription payment was made in the last 60 seconds
+ from app.database.crud.transaction import get_user_transactions
+
+ try:
+ recent_transactions = await get_user_transactions(db, user.id, limit=1)
+ if recent_transactions:
+ last_tx = recent_transactions[0]
+ if (
+ last_tx.type == TransactionType.SUBSCRIPTION_PAYMENT
+ and last_tx.created_at
+ and (datetime.now(UTC) - last_tx.created_at) < timedelta(seconds=60)
+ ):
+ logger.info(
+ '🔄 Авто-возобновление daily: пропуск — оплата секунд назад',
+ format_user_id=_format_user_id(user),
+ )
+ return False
+ except Exception as check_error:
+ logger.warning(
+ '🔄 Авто-возобновление daily: ошибка проверки последней транзакции',
+ format_user_id=_format_user_id(user),
+ check_error=check_error,
+ )
+
+ # Deduct daily price FIRST (before changing status to avoid free-access window)
+ previous_status = subscription.status
+ description = f'Суточная оплата тарифа «{tariff.name}» (авто-возобновление)'
+ try:
+ deducted = await subtract_user_balance(
+ db,
+ user,
+ daily_price,
+ description,
+ mark_as_paid_subscription=True,
+ )
+ except Exception as error:
+ logger.error(
+ '❌ Авто-возобновление daily: ошибка списания средств',
+ format_user_id=_format_user_id(user),
+ error=error,
+ exc_info=True,
+ )
+ return False
+
+ if not deducted:
+ logger.warning(
+ '❌ Авто-возобновление daily: списание не выполнено',
+ format_user_id=_format_user_id(user),
+ )
+ return False
+
+ # Activate the subscription (balance already deducted)
+ subscription.status = SubscriptionStatus.ACTIVE.value
+ try:
+ await db.commit()
+ await db.refresh(subscription)
+ except Exception as error:
+ logger.error(
+ '❌ Авто-возобновление daily: ошибка активации подписки',
+ format_user_id=_format_user_id(user),
+ error=error,
+ exc_info=True,
+ )
+ await db.rollback()
+ return False
+
+ logger.info(
+ '✅ Авто-возобновление daily: подписка → ACTIVE после пополнения',
+ format_user_id=_format_user_id(user),
+ previous_status=previous_status,
+ subscription_id=subscription.id,
+ )
+
+ # Create transaction
+ transaction = None
+ try:
+ transaction = await create_transaction(
+ db=db,
+ user_id=user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=daily_price,
+ description=description,
+ )
+ except Exception as error:
+ logger.error(
+ '⚠️ Авто-возобновление daily: не удалось создать транзакцию',
+ format_user_id=_format_user_id(user),
+ error=error,
+ )
+
+ # Update charge time and end_date (+24h)
+ old_end_date = subscription.end_date
+ try:
+ subscription = await update_daily_charge_time(db, subscription)
+ except Exception as error:
+ logger.error(
+ '⚠️ Авто-возобновление daily: не удалось обновить время списания',
+ format_user_id=_format_user_id(user),
+ error=error,
+ )
+
+ # Sync with RemnaWave
+ try:
+ subscription_service = SubscriptionService()
+ await subscription_service.create_remnawave_user(
+ db,
+ subscription,
+ reset_traffic=False,
+ reset_reason=None,
+ )
+ except Exception as error:
+ logger.error(
+ '⚠️ Авто-возобновление daily: не удалось обновить RemnaWave',
+ format_user_id=_format_user_id(user),
+ error=error,
+ )
+
+ # Admin notification
+ try:
+ from app.services.subscription_renewal_service import with_admin_notification_service
+
+ await with_admin_notification_service(
+ lambda svc: svc.send_subscription_extension_notification(
+ db,
+ user,
+ subscription,
+ transaction,
+ 1,
+ old_end_date,
+ new_end_date=subscription.end_date,
+ balance_after=user.balance_kopeks,
+ )
+ )
+ except Exception as error:
+ logger.error(
+ '⚠️ Авто-возобновление daily: не удалось уведомить администраторов',
+ format_user_id=_format_user_id(user),
+ error=error,
+ )
+
+ # User notification
+ if bot and user.telegram_id:
+ try:
+ texts = get_texts(getattr(user, 'language', 'ru'))
+
+ message = texts.t(
+ 'DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP',
+ '✅ Подписка возобновлена!\n\n'
+ 'Ваш суточный тариф «{tariff_name}» возобновлён после пополнения баланса.\n\n'
+ '💳 Списано: {amount}\n'
+ '💰 Остаток: {balance}',
+ ).format(
+ tariff_name=tariff.name,
+ amount=settings.format_price(daily_price),
+ balance=settings.format_price(user.balance_kopeks),
+ )
+
+ keyboard = InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 My subscription'),
+ callback_data='menu_subscription',
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Main menu'),
+ callback_data='back_to_menu',
+ )
+ ],
+ ]
+ )
+
+ await bot.send_message(
+ chat_id=user.telegram_id,
+ text=message,
+ reply_markup=keyboard,
+ parse_mode='HTML',
+ )
+ except Exception as error:
+ logger.error(
+ '⚠️ Авто-возобновление daily: не удалось уведомить пользователя',
+ telegram_id=user.telegram_id or user.id,
+ error=error,
+ )
+
+ logger.info(
+ '✅ Авто-возобновление daily: подписка возобновлена для пользователя',
+ format_user_id=_format_user_id(user),
+ daily_price=daily_price,
+ tariff_name=tariff.name,
+ )
+
+ # WebSocket notification
+ try:
+ await notify_user_subscription_renewed(
+ user_id=user.id,
+ new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
+ amount_kopeks=daily_price,
+ )
+ except Exception as ws_error:
+ logger.warning(
+ '⚠️ Авто-возобновление daily: не удалось отправить WS уведомление',
+ format_user_id=_format_user_id(user),
+ ws_error=ws_error,
+ )
+
+ return True
+
+
async def auto_purchase_saved_cart_after_topup(
db: AsyncSession,
user: User,
diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py
index d1118bb0..17bafe01 100644
--- a/app/webapi/routes/miniapp.py
+++ b/app/webapi/routes/miniapp.py
@@ -7337,14 +7337,26 @@ async def toggle_daily_subscription_pause_endpoint(
detail={'code': 'not_daily_tariff', 'message': 'Subscription is not on a daily tariff'},
)
- # Переключаем состояние паузы
+ # Определяем состояние
+ from app.database.models import SubscriptionStatus
+
is_currently_paused = getattr(subscription, 'is_daily_paused', False)
- new_paused_state = not is_currently_paused
+ was_disabled = subscription.status in (
+ SubscriptionStatus.DISABLED.value,
+ SubscriptionStatus.EXPIRED.value,
+ )
+
+ # System-DISABLED subs (is_daily_paused=False) должны идти по пути resume
+ if was_disabled and not is_currently_paused:
+ new_paused_state = False # Force resume path
+ else:
+ new_paused_state = not is_currently_paused
subscription.is_daily_paused = new_paused_state
- # Если снимаем с паузы, нужно проверить баланс для активации
+ daily_price = getattr(tariff, 'daily_price_kopeks', 0)
+
+ # Если снимаем с паузы, проверяем баланс и списываем оплату
if not new_paused_state:
- daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and user.balance_kopeks < daily_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -7356,29 +7368,68 @@ async def toggle_daily_subscription_pause_endpoint(
},
)
- # Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств)
- from app.database.models import SubscriptionStatus
+ # Списываем суточную оплату ПЕРЕД активацией
+ if was_disabled:
+ if daily_price > 0:
+ from app.database.crud.user import subtract_user_balance
- if subscription.status == SubscriptionStatus.DISABLED.value:
+ deducted = await subtract_user_balance(
+ db,
+ user,
+ daily_price,
+ f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
+ mark_as_paid_subscription=True,
+ )
+ if not deducted:
+ raise HTTPException(
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
+ detail={
+ 'code': 'insufficient_balance',
+ 'message': 'Balance deduction failed',
+ 'required': daily_price,
+ 'balance': user.balance_kopeks,
+ },
+ )
+
+ from app.database.crud.transaction import create_transaction
+ from app.database.models import TransactionType
+
+ try:
+ await create_transaction(
+ db=db,
+ user_id=user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=daily_price,
+ description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
+ )
+ except Exception as exc:
+ logger.warning('Failed to create resume transaction in miniapp', error=exc)
+
+ # Баланс списан — теперь активируем
subscription.status = SubscriptionStatus.ACTIVE.value
- # Обновляем время последнего списания для корректного расчёта следующего
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
- logger.info('✅ Суточная подписка восстановлена из DISABLED в ACTIVE', subscription_id=subscription.id)
+
+ logger.info(
+ '✅ Суточная подписка восстановлена в ACTIVE (miniapp)',
+ subscription_id=subscription.id,
+ previous_status='disabled/expired',
+ )
await db.commit()
await db.refresh(subscription)
await db.refresh(user)
- # Синхронизация с RemnaWave
- # При паузе VPN продолжает работать до конца оплаченного времени,
- # поэтому НЕ отключаем пользователя в RemnaWave
- # При возобновлении включаем если был отключен (например, из-за истечения срока)
- if not new_paused_state:
+ # Синхронизация с RemnaWave только при возобновлении из DISABLED/EXPIRED
+ if not new_paused_state and was_disabled:
try:
service = SubscriptionService()
- if user.remnawave_uuid:
- await service.enable_remnawave_user(user.remnawave_uuid)
+ await service.create_remnawave_user(
+ db,
+ subscription,
+ reset_traffic=False,
+ reset_reason=None,
+ )
except Exception as e:
logger.error('Ошибка синхронизации с RemnaWave при возобновлении', error=e)