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)