feat: auto-resume disabled daily subscriptions on balance topup
- Add try_resume_disabled_daily_after_topup() for instant resume when balance is topped up - Fix all 5 resume paths to charge daily fee BEFORE activating subscription - Remove unsafe inline auto-resume from add_user_balance() that bypassed fee charging - Add NULL-safe is_daily_paused filter in subscription queries - Use create_remnawave_user() instead of enable_remnawave_user() for full VPN panel sync - Add DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP localization key (ru, en, fa, zh, ua)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1704,6 +1704,7 @@
|
||||
"DAILY_SWITCH_WARNING": "⚠️ <b>Warning!</b> 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": "✅ <b>Subscription resumed!</b>\n\nYour daily plan «{tariff_name}» has been resumed after balance top-up.\n\n💳 Charged: {amount}\n💰 Remaining: {balance}",
|
||||
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew to restore VPN access.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Subscription disabled</b>\n\nYour subscription has been disabled by the administrator.",
|
||||
|
||||
@@ -1723,6 +1723,7 @@
|
||||
"DAILY_SWITCH_WARNING": "⚠️ <b>توجه!</b> {days} روز اشتراک باقی مانده.\nبا تغییر به تعرفه روزانه از دست میروند!",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ اشتراک متوقف شد",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ اشتراک از سر گرفته شد!",
|
||||
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>اشتراک از سر گرفته شد!</b>\n\nتعرفه روزانه «{tariff_name}» پس از شارژ موجودی از سر گرفته شد.\n\n💳 کسر شده: {amount}\n💰 باقیمانده: {balance}",
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>اشتراک منقضی شد</b>\n\nاشتراک شما به پایان رسیده است. برای بازیابی دسترسی VPN تمدید کنید.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>اشتراک غیرفعال شد</b>\n\nاشتراک شما توسط مدیر غیرفعال شده است.",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>اشتراک فعال شد</b>\n\nاشتراک شما دوباره فعال است. از استفاده لذت ببرید!",
|
||||
|
||||
@@ -1725,6 +1725,7 @@
|
||||
"DAILY_SWITCH_WARNING": "⚠️ <b>Внимание!</b> У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!",
|
||||
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Подписка возобновлена!</b>\n\nВаш суточный тариф «{tariff_name}» возобновлён после пополнения баланса.\n\n💳 Списано: {amount}\n💰 Остаток: {balance}",
|
||||
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Подписка истекла</b>\n\nВаша подписка завершена. Продлите подписку, чтобы восстановить доступ к VPN.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Подписка отключена</b>\n\nВаша подписка была отключена администратором.",
|
||||
|
||||
@@ -1592,6 +1592,9 @@
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "Вартість: <s>{base_price}</s> <b>{final_price}</b> (за {months} міс)\n🎁 Знижка {discount}%: -{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "Вартість: {price} (за {months} міс)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Підписка призупинена",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Підписка відновлена!",
|
||||
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Підписка відновлена!</b>\n\nВаш добовий тариф «{tariff_name}» відновлено після поповнення балансу.\n\n💳 Списано: {amount}\n💰 Залишок: {balance}",
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Підписка закінчилась</b>\n\nВаша підписка завершена. Продовжте підписку, щоб відновити доступ до VPN.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Підписку вимкнено</b>\n\nВашу підписку було вимкнено адміністратором.",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>Підписку активовано</b>\n\nВаша підписка знову активна. Приємного використання!",
|
||||
|
||||
@@ -1588,6 +1588,9 @@
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "费用:<s>{base_price}</s> <b>{final_price}</b>({months}个月)\n🎁 折扣{discount}%:-{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "费用:{price}({months}个月)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ 订阅已暂停",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ 订阅已恢复!",
|
||||
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>订阅已恢复!</b>\n\n您的日套餐「{tariff_name}」已在充值后恢复。\n\n💳 扣费:{amount}\n💰 余额:{balance}",
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>订阅已过期</b>\n\n您的订阅已结束。请续订以恢复VPN访问。",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>订阅已禁用</b>\n\n您的订阅已被管理员禁用。",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>订阅已激活</b>\n\n您的订阅已重新激活。祝使用愉快!",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
'✅ <b>Подписка возобновлена!</b>\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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user