diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 7373dde3..a5d01c0f 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -236,7 +236,7 @@ async def _sync_subscription_to_panel( """ try: from app.config import settings - from app.external.remnawave_api import TrafficLimitStrategy, UserStatus as PanelUserStatus + from app.external.remnawave_api import UserStatus as PanelUserStatus from app.services.remnawave_service import RemnaWaveService from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload @@ -323,7 +323,7 @@ async def _sync_subscription_to_panel( 'uuid': panel_uuid, 'status': panel_status, 'traffic_limit_bytes': traffic_limit_bytes, - 'traffic_limit_strategy': TrafficLimitStrategy.MONTH, + 'traffic_limit_strategy': get_traffic_reset_strategy(subscription.tariff), 'description': description, } if expire_at: @@ -358,7 +358,7 @@ async def _sync_subscription_to_panel( 'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)), 'status': panel_status, 'traffic_limit_bytes': traffic_limit_bytes, - 'traffic_limit_strategy': TrafficLimitStrategy.MONTH, + 'traffic_limit_strategy': get_traffic_reset_strategy(subscription.tariff), 'telegram_id': user.telegram_id, 'email': user.email, 'description': description, @@ -3118,8 +3118,9 @@ async def sync_user_to_panel( try: from app.config import settings - from app.external.remnawave_api import TrafficLimitStrategy, UserStatus as PanelUserStatus + from app.external.remnawave_api import UserStatus as PanelUserStatus from app.services.remnawave_service import RemnaWaveService + from app.services.subscription_service import get_traffic_reset_strategy from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload service = RemnaWaveService() @@ -3218,7 +3219,7 @@ async def sync_user_to_panel( if request.update_traffic_limit: update_kwargs['traffic_limit_bytes'] = traffic_limit_bytes - update_kwargs['traffic_limit_strategy'] = TrafficLimitStrategy.MONTH + update_kwargs['traffic_limit_strategy'] = get_traffic_reset_strategy(sub.tariff) changes['traffic_limit_gb'] = sub.traffic_limit_gb if request.update_squads and sub.connected_squads: @@ -3252,7 +3253,7 @@ async def sync_user_to_panel( 'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)), 'status': panel_status, 'traffic_limit_bytes': traffic_limit_bytes, - 'traffic_limit_strategy': TrafficLimitStrategy.MONTH, + 'traffic_limit_strategy': get_traffic_reset_strategy(sub.tariff), 'telegram_id': user.telegram_id, 'email': user.email, 'description': description, diff --git a/app/cabinet/routes/gift.py b/app/cabinet/routes/gift.py index 9bd18521..0dc44f46 100644 --- a/app/cabinet/routes/gift.py +++ b/app/cabinet/routes/gift.py @@ -425,8 +425,8 @@ async def create_gift_purchase( warning=recipient_warning, ) - # Balance mode - if user.balance_kopeks < price_kopeks: + # Balance mode (skip for 100% discount) + if price_kopeks > 0 and user.balance_kopeks < price_kopeks: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Insufficient balance', diff --git a/app/cabinet/routes/subscription_modules/devices.py b/app/cabinet/routes/subscription_modules/devices.py index 1f804e12..3465d8b9 100644 --- a/app/cabinet/routes/subscription_modules/devices.py +++ b/app/cabinet/routes/subscription_modules/devices.py @@ -134,8 +134,8 @@ async def purchase_devices_legacy( detail=f'Максимальное количество устройств: {max_device_limit}', ) - # Check balance - if user.balance_kopeks < total_price: + # Check balance (skip for 100% discount) + if total_price > 0 and user.balance_kopeks < total_price: missing = total_price - user.balance_kopeks # Сохраняем корзину для автопокупки после пополнения @@ -375,8 +375,8 @@ async def purchase_devices( if devices_discount_percent < 100: price_kopeks = max(100, price_kopeks) - # Check balance - if user.balance_kopeks < price_kopeks: + # Check balance (skip for 100% discount) + if price_kopeks > 0 and user.balance_kopeks < price_kopeks: missing = price_kopeks - user.balance_kopeks # Сохраняем корзину для автопокупки после пополнения diff --git a/app/cabinet/routes/subscription_modules/purchase.py b/app/cabinet/routes/subscription_modules/purchase.py index bfab98b8..640d2f6c 100644 --- a/app/cabinet/routes/subscription_modules/purchase.py +++ b/app/cabinet/routes/subscription_modules/purchase.py @@ -304,9 +304,7 @@ async def get_purchase_options( from app.database.crud.subscription import get_active_subscriptions_by_user_id active_subs = await get_active_subscriptions_by_user_id(db, user.id) - purchased_tariff_ids = { - s.tariff_id for s in active_subs if s.tariff_id and s.status in ('active', 'trial') - } + purchased_tariff_ids = {s.tariff_id for s in active_subs if s.tariff_id and not s.is_trial} if subscription_id: from app.database.crud.subscription import get_subscription_by_id_for_user @@ -686,7 +684,7 @@ async def purchase_tariff( ) # Check balance - if user.balance_kopeks < price_kopeks: + if price_kopeks > 0 and user.balance_kopeks < price_kopeks: missing = price_kopeks - user.balance_kopeks # Save cart for auto-purchase after balance top-up @@ -1158,7 +1156,7 @@ async def activate_trial( from app.database.crud.user import subtract_user_balance price_kopeks = settings.TRIAL_ACTIVATION_PRICE - if user.balance_kopeks < price_kopeks: + if price_kopeks > 0 and user.balance_kopeks < price_kopeks: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f'Insufficient balance. Need {price_kopeks / 100:.2f} RUB', diff --git a/app/cabinet/routes/subscription_modules/renewal.py b/app/cabinet/routes/subscription_modules/renewal.py index 07abae44..7c89934f 100644 --- a/app/cabinet/routes/subscription_modules/renewal.py +++ b/app/cabinet/routes/subscription_modules/renewal.py @@ -168,8 +168,8 @@ async def renew_subscription( tariff = subscription.tariff if subscription.tariff_id else None - # Check balance - if user.balance_kopeks < price_kopeks: + # Check balance (skip for 100% discount) + if price_kopeks > 0 and user.balance_kopeks < price_kopeks: missing = price_kopeks - user.balance_kopeks # Get tariff info for cart diff --git a/app/cabinet/routes/subscription_modules/traffic.py b/app/cabinet/routes/subscription_modules/traffic.py index 21d1f9e9..d3ed9dc4 100644 --- a/app/cabinet/routes/subscription_modules/traffic.py +++ b/app/cabinet/routes/subscription_modules/traffic.py @@ -254,7 +254,7 @@ async def purchase_traffic( final_price = max(100, final_price) # Проверяем баланс - if user.balance_kopeks < final_price: + if final_price > 0 and user.balance_kopeks < final_price: missing = final_price - user.balance_kopeks # Save cart for auto-purchase after balance top-up @@ -560,7 +560,7 @@ async def switch_traffic_package( # Prorated calculation final_price, days_charged = calculate_prorated_price(price_diff, subscription.end_date) - if user.balance_kopeks < final_price: + if final_price > 0 and user.balance_kopeks < final_price: raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB', diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index d28552f5..2f4497b9 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -2075,7 +2075,12 @@ async def toggle_daily_subscription_pause( async def get_active_subscriptions_by_user_id(db: AsyncSession, user_id: int) -> list[Subscription]: - """Get all active/trial subscriptions for a user.""" + """Get all active/trial/limited subscriptions for a user. + + Includes LIMITED status because those subscriptions still have time remaining + (just ran out of traffic) and should be treated as "alive" for renewal, + duplicate prevention, and display purposes. + """ result = await db.execute( select(Subscription) .options( @@ -2084,7 +2089,13 @@ async def get_active_subscriptions_by_user_id(db: AsyncSession, user_id: int) -> ) .where( Subscription.user_id == user_id, - Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]), + Subscription.status.in_( + [ + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.TRIAL.value, + SubscriptionStatus.LIMITED.value, + ] + ), ) .order_by(Subscription.created_at.desc()) ) @@ -2121,7 +2132,11 @@ async def get_subscription_by_id(db: AsyncSession, subscription_id: int) -> Subs async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, tariff_id: int) -> Subscription | None: - """Get active/trial subscription for a specific user+tariff combination.""" + """Get active/trial/limited subscription for a specific user+tariff combination. + + Includes LIMITED status because those subscriptions still have time remaining + (just ran out of traffic) and should be extended rather than duplicated. + """ result = await db.execute( select(Subscription) .options( @@ -2131,7 +2146,13 @@ async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, ta .where( Subscription.user_id == user_id, Subscription.tariff_id == tariff_id, - Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]), + Subscription.status.in_( + [ + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.TRIAL.value, + SubscriptionStatus.LIMITED.value, + ] + ), ) .order_by(Subscription.created_at.desc()) .limit(1) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 2592aa48..784e9fba 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -4162,14 +4162,16 @@ async def _update_user_traffic( ) or getattr(user, 'remnawave_uuid', None) if _uuid: try: - from app.external.remnawave_api import TrafficLimitStrategy + from app.services.subscription_service import get_traffic_reset_strategy remnawave_service = RemnaWaveService() async with remnawave_service.get_api_client() as api: await api.update_user( uuid=_uuid, traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0, - traffic_limit_strategy=TrafficLimitStrategy.MONTH, + traffic_limit_strategy=get_traffic_reset_strategy( + subscription.tariff if subscription else None + ), description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), @@ -4877,8 +4879,9 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user: ) try: - from app.external.remnawave_api import TrafficLimitStrategy, UserStatus + from app.external.remnawave_api import UserStatus from app.services.remnawave_service import RemnaWaveService + from app.services.subscription_service import get_traffic_reset_strategy remnawave_service = RemnaWaveService() @@ -4903,7 +4906,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user: traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, - traffic_limit_strategy=TrafficLimitStrategy.MONTH, + traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff), description=settings.format_remnawave_user_description( full_name=target_user.full_name, username=target_user.username, @@ -4939,7 +4942,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user: traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, - traffic_limit_strategy=TrafficLimitStrategy.MONTH, + traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff), telegram_id=target_user.telegram_id, email=target_user.email, description=settings.format_remnawave_user_description( diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 201f9a6f..ea0d4be6 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -441,7 +441,7 @@ async def handle_simple_subscription_pay_with_balance( # Проверяем баланс пользователя user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) - if user_balance_kopeks < total_required: + if total_required > 0 and user_balance_kopeks < total_required: await callback.answer('❌ Недостаточно средств на балансе для оплаты подписки', show_alert=True) return @@ -2181,7 +2181,7 @@ async def confirm_simple_subscription_purchase( # Проверяем баланс пользователя user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) - if user_balance_kopeks < total_required: + if total_required > 0 and user_balance_kopeks < total_required: await callback.answer('❌ Недостаточно средств на балансе для оплаты подписки', show_alert=True) return diff --git a/app/handlers/subscription/countries.py b/app/handlers/subscription/countries.py index 4814dd7a..40bb76f6 100644 --- a/app/handlers/subscription/countries.py +++ b/app/handlers/subscription/countries.py @@ -861,7 +861,7 @@ async def confirm_add_countries_to_subscription( if country['uuid'] in removed_countries: removed_countries_names.append(html.escape(country['name'])) - if new_countries and db_user.balance_kopeks < total_price: + if new_countries and total_price > 0 and db_user.balance_kopeks < total_price: missing_kopeks = total_price - db_user.balance_kopeks message_text = texts.t( 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index 3a61e49e..c8e534bb 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -1273,7 +1273,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: total_discount=total_discount / 100, ) - if db_user.balance_kopeks < price: + if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f'{texts.format_price(price)} (за {period_label})' message_text = texts.t( diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index ded07ac5..49ccf457 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1537,7 +1537,7 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext, total_price = prepared_cart_data.get('total_price', 0) - if db_user.balance_kopeks < total_price: + if total_price > 0 and db_user.balance_kopeks < total_price: missing_amount = total_price - db_user.balance_kopeks insufficient_keyboard = get_insufficient_balance_keyboard_with_cart( db_user.language, @@ -1635,7 +1635,7 @@ async def handle_extend_subscription( else: subscription = db_user.subscription - if not subscription or subscription.is_trial: + if not subscription: await callback.message.edit_text( '🎯 Пробный период заканчивается\n\nЧтобы продолжить пользоваться VPN, выберите подходящий тариф.', reply_markup=types.InlineKeyboardMarkup( @@ -1654,24 +1654,53 @@ async def handle_extend_subscription( await callback.answer() return - # В режиме тарифов проверяем наличие tariff_id - if settings.is_tariffs_mode(): - if subscription.tariff_id: - # Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume - from app.database.crud.tariff import get_tariff_by_id + # Триальная подписка с тарифом — направляем на покупку этого тарифа + if subscription.is_trial: + if subscription.tariff_id and settings.is_tariffs_mode(): + from .tariff_purchase import show_tariff_extend - tariff = await get_tariff_by_id(db, subscription.tariff_id) - if tariff and getattr(tariff, 'is_daily', False): - # Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить») - await show_subscription_info(callback, db_user, db) - return + await show_tariff_extend(callback, db_user, db) + return + # Триал без тарифа — предлагаем выбрать + await callback.message.edit_text( + '🎯 Пробный период заканчивается\n\nЧтобы продолжить пользоваться VPN, выберите подходящий тариф.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data='menu_buy')], + [ + types.InlineKeyboardButton( + text=texts.t('WEBHOOK_CLOSE_BUTTON', '✖️ Закрыть'), + callback_data='webhook:close', + ) + ], + ] + ), + parse_mode='HTML', + ) + await callback.answer() + return + # Подписка с тарифом — всегда используем тарифный flow, + # даже если бот в классическом режиме (подписка могла быть куплена через кабинет) + if subscription.tariff_id: + # Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume + from app.database.crud.tariff import get_tariff_by_id + + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and getattr(tariff, 'is_daily', False): + # Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить») + await show_subscription_info(callback, db_user, db) + return + + if tariff: # У подписки есть тариф - перенаправляем на продление по тарифу from .tariff_purchase import show_tariff_extend await show_tariff_extend(callback, db_user, db) return - # У подписки нет тарифа - предлагаем выбрать тариф + + if settings.is_tariffs_mode(): + # У подписки нет тарифа, но режим тарифов включён - предлагаем выбрать тариф await callback.message.edit_text( '📦 Выберите тариф для продления\n\n' 'Ваша текущая подписка была создана до введения тарифов.\n' @@ -1706,6 +1735,10 @@ async def handle_extend_subscription( # original = price before ALL discounts, final = price with all discounts total_original_price = pricing.original_total + # Пропускаем периоды с нулевой ценой — защита от бесплатного продления + if pricing.final_total <= 0 and pricing.base_price <= 0: + continue + renewal_prices[days] = { 'final': pricing.final_total, 'original': total_original_price, @@ -1899,7 +1932,7 @@ async def confirm_extend_subscription( await callback.answer('⚠ Ошибка расчета стоимости', show_alert=True) return - if db_user.balance_kopeks < price: + if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = texts.format_price(price) message_text = texts.t( @@ -2307,7 +2340,7 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ ) logger.info('ИТОГО: ₽', final_price=final_price / 100) - if db_user.balance_kopeks < final_price: + if final_price > 0 and db_user.balance_kopeks < final_price: missing_kopeks = final_price - db_user.balance_kopeks message_text = texts.t( 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', @@ -4415,8 +4448,8 @@ async def _extend_existing_subscription( device_limit=device_limit, ) - # Проверяем баланс пользователя - if db_user.balance_kopeks < price_kopeks: + # Проверяем баланс пользователя (при 100% скидке — пропускаем) + if price_kopeks > 0 and db_user.balance_kopeks < price_kopeks: missing_kopeks = price_kopeks - db_user.balance_kopeks message_text = texts.t( 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index fbcc3f7d..7909acd0 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -576,7 +576,7 @@ async def show_tariffs_list( from app.database.crud.subscription import get_active_subscriptions_by_user_id active_subs = await get_active_subscriptions_by_user_id(db, db_user.id) - purchased_tariff_ids = {s.tariff_id for s in active_subs if s.tariff_id and s.status in ('active', 'trial')} + purchased_tariff_ids = {s.tariff_id for s in active_subs if s.tariff_id and not s.is_trial} # Проверяем есть ли у пользователя скидки по периодам promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None @@ -619,7 +619,7 @@ async def select_tariff( from app.database.crud.subscription import get_active_subscriptions_by_user_id _active = await get_active_subscriptions_by_user_id(db, db_user.id) - _existing = next((s for s in _active if s.tariff_id == tariff_id and s.status in ('active', 'trial')), None) + _existing = next((s for s in _active if s.tariff_id == tariff_id and not s.is_trial), None) if _existing: days_left = max(0, (_existing.end_date - datetime.now(UTC)).days) if _existing.end_date else 0 await callback.answer( @@ -933,9 +933,9 @@ async def handle_custom_confirm( await callback.answer('Выбранный период недоступен для этого тарифа', show_alert=True) return - # Проверяем баланс (user already locked, balance is fresh) + # Проверяем баланс (при 100% скидке — пропускаем) user_balance = db_user.balance_kopeks or 0 - if user_balance < total_price: + if total_price > 0 and user_balance < total_price: await callback.answer('Недостаточно средств на балансе', show_alert=True) return @@ -1353,7 +1353,7 @@ async def confirm_tariff_purchase( # Проверяем баланс (user already locked, balance is fresh) user_balance = db_user.balance_kopeks or 0 - if user_balance < final_price: + if final_price > 0 and user_balance < final_price: await callback.answer('Недостаточно средств на балансе', show_alert=True) return @@ -1694,7 +1694,7 @@ async def confirm_daily_tariff_purchase( # Проверяем баланс (user already locked, balance is fresh) user_balance = db_user.balance_kopeks or 0 - if user_balance < final_daily_price: + if final_daily_price > 0 and user_balance < final_daily_price: await callback.answer('Недостаточно средств на балансе', show_alert=True) return @@ -2013,8 +2013,6 @@ async def show_tariff_extend( # Show subscription picker for extending keyboard = [] for sub in sorted(active_subs, key=lambda s: s.id): - if sub.is_trial: - continue tariff_name = '' if sub.tariff_id: _t = await get_tariff_by_id(db, sub.tariff_id) @@ -2246,7 +2244,7 @@ async def confirm_tariff_extend( # Проверяем баланс user_balance = db_user.balance_kopeks or 0 - if user_balance < final_price: + if final_price > 0 and user_balance < final_price: await callback.answer('Недостаточно средств на балансе', show_alert=True) return @@ -2266,11 +2264,17 @@ async def confirm_tariff_extend( await callback.answer('Ошибка списания баланса', show_alert=True) return - # Продлеваем подписку (параметры тарифа не меняются, только добавляется время) + # Запоминаем, был ли триал ДО продления + was_trial = subscription.is_trial + + # Продлеваем подписку; для триала передаём tariff_id чтобы сбросить is_trial subscription = await extend_subscription( db, subscription, days=period, + tariff_id=tariff.id if was_trial else None, + traffic_limit_gb=tariff.traffic_limit_gb if was_trial else None, + device_limit=actual_device_limit if was_trial else None, ) # Обновляем пользователя в Remnawave @@ -2279,8 +2283,8 @@ async def confirm_tariff_extend( await subscription_service.create_remnawave_user( db, subscription, - reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason='продление тарифа', + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial, + reset_reason='конвертация триала' if was_trial else 'продление тарифа', ) except Exception as e: logger.error('Ошибка обновления Remnawave', error=e) @@ -2303,7 +2307,7 @@ async def confirm_tariff_extend( subscription, None, # Транзакция отсутствует, оплата с баланса period, - was_trial_conversion=False, + was_trial_conversion=was_trial, amount_kopeks=final_price, purchase_type='renewal', ) @@ -2836,7 +2840,7 @@ async def confirm_tariff_switch( # Проверяем баланс user_balance = db_user.balance_kopeks or 0 - if user_balance < final_price: + if final_price > 0 and user_balance < final_price: await callback.answer('Недостаточно средств на балансе', show_alert=True) return @@ -3042,7 +3046,7 @@ async def confirm_daily_tariff_switch( # Проверяем баланс (user already locked, balance is fresh) user_balance = db_user.balance_kopeks or 0 - if user_balance < final_daily_price: + if final_daily_price > 0 and user_balance < final_daily_price: await callback.answer('Недостаточно средств на балансе', show_alert=True) return @@ -3946,8 +3950,8 @@ async def return_to_saved_tariff_cart( user_balance = db_user.balance_kopeks or 0 traffic = format_traffic(tariff.traffic_limit_gb) - # Проверяем баланс - if user_balance < total_price: + # Проверяем баланс (при 100% скидке — пропускаем) + if total_price > 0 and user_balance < total_price: missing = total_price - user_balance if cart_mode == 'daily_tariff_purchase': diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index e4b7b45d..f6ee44e6 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -332,7 +332,7 @@ async def confirm_reset_traffic( reset_price = _calculate_traffic_reset_price(subscription) - if db_user.balance_kopeks < reset_price: + if reset_price > 0 and db_user.balance_kopeks < reset_price: missing_kopeks = reset_price - db_user.balance_kopeks message_text = texts.t( 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', @@ -574,7 +574,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes total_discount_value = int(discount_per_month * charged_days / 30) - if db_user.balance_kopeks < price: + if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks # Save cart for auto-purchase after balance top-up @@ -830,7 +830,7 @@ async def confirm_switch_traffic( total_price_difference = int(price_difference_per_month * days_remaining / 30) total_price_difference = max(100, total_price_difference) - if db_user.balance_kopeks < total_price_difference: + if total_price_difference > 0 and db_user.balance_kopeks < total_price_difference: missing_kopeks = total_price_difference - db_user.balance_kopeks message_text = texts.t( 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py index 4ac850e8..5cda9e0c 100644 --- a/app/services/daily_subscription_service.py +++ b/app/services/daily_subscription_service.py @@ -141,8 +141,8 @@ class DailySubscriptionService: PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price ) - # Проверяем баланс - if user.balance_kopeks < daily_price: + # Проверяем баланс (при 100% скидке — пропускаем) + if daily_price > 0 and user.balance_kopeks < daily_price: # Недостаточно средств - приостанавливаем подписку await suspend_daily_subscription_insufficient_balance(db, subscription) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index f5e59c47..f7117318 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -49,7 +49,6 @@ from app.database.models import ( from app.external.remnawave_api import ( RemnaWaveAPIError, RemnaWaveUser, - TrafficLimitStrategy, UserStatus as RemnaWaveUserStatus, ) from app.localization.texts import get_texts @@ -58,7 +57,7 @@ from app.services.notification_delivery_service import ( ) from app.services.notification_settings_service import NotificationSettingsService from app.services.promo_offer_service import promo_offer_service -from app.services.subscription_service import SubscriptionService +from app.services.subscription_service import SubscriptionService, get_traffic_reset_strategy from app.utils.cache import cache from app.utils.message_patch import caption_exceeds_telegram_limit from app.utils.miniapp_buttons import build_miniapp_or_callback_button @@ -464,7 +463,7 @@ class MonitoringService: if is_active else max(subscription.end_date, current_time + timedelta(minutes=1)), traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), - traffic_limit_strategy=TrafficLimitStrategy.MONTH, + traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff), description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 349c8969..7d6bb1a9 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -31,9 +31,9 @@ from app.database.models import ( from app.external.remnawave_api import ( RemnaWaveAPI, RemnaWaveAPIError, - TrafficLimitStrategy, UserStatus, ) +from app.services.subscription_service import get_traffic_reset_strategy from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) @@ -2240,7 +2240,7 @@ class RemnaWaveService: traffic_limit_bytes=sub.traffic_limit_gb * (1024**3) if sub.traffic_limit_gb > 0 else 0, - traffic_limit_strategy=TrafficLimitStrategy.MONTH, + traffic_limit_strategy=get_traffic_reset_strategy(sub.tariff), telegram_id=user.telegram_id, email=user.email, description=settings.format_remnawave_user_description( @@ -2325,7 +2325,7 @@ class RemnaWaveService: status=status, expire_at=expire_at, traffic_limit_bytes=create_kwargs['traffic_limit_bytes'], - traffic_limit_strategy=TrafficLimitStrategy.MONTH, + traffic_limit_strategy=get_traffic_reset_strategy(sub.tariff), email=user.email, description=create_kwargs['description'], active_internal_squads=sub.connected_squads, diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 9380c07e..15ea3f6a 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -316,7 +316,7 @@ async def _prepare_auto_extend_context( ) return None - if price_kopeks <= 0: + if price_kopeks <= 0 and pricing.base_price <= 0: logger.warning( '🔁 Автопокупка: некорректная цена продления у пользователя', price_kopeks=price_kopeks, @@ -424,7 +424,7 @@ async def _auto_extend_subscription( if prepared is None: return False - if user.balance_kopeks < prepared.price_kopeks: + if prepared.price_kopeks > 0 and user.balance_kopeks < prepared.price_kopeks: logger.info( '🔁 Автопокупка: у пользователя недостаточно средств для продления (<)', format_user_id=_format_user_id(user), @@ -801,7 +801,7 @@ async def _auto_purchase_tariff( final_price = result.final_total consume_promo = result.promo_offer_discount > 0 - if user.balance_kopeks < final_price: + if final_price > 0 and user.balance_kopeks < final_price: logger.info( '🔁 Автопокупка тарифа: у пользователя недостаточно средств (<)', format_user_id=_format_user_id(user), @@ -1131,7 +1131,7 @@ async def _auto_purchase_daily_tariff( final_price, _, _ = PricingEngine.apply_stacked_discounts(daily_price, group_pct, offer_pct) consume_promo = offer_pct > 0 - if user.balance_kopeks < final_price: + if final_price > 0 and user.balance_kopeks < final_price: logger.info( '🔁 Автопокупка суточного тарифа: у пользователя недостаточно средств (<)', format_user_id=_format_user_id(user), @@ -1532,8 +1532,8 @@ async def _auto_add_devices( days_left=days_left, ) - # Проверяем баланс (с актуальной ценой) - if user.balance_kopeks < price_kopeks: + # Проверяем баланс (при 100% скидке — пропускаем) + if price_kopeks > 0 and user.balance_kopeks < price_kopeks: logger.info( '🔁 Автопокупка устройств: у пользователя недостаточно средств (<)', format_user_id=_format_user_id(user), @@ -1883,8 +1883,8 @@ async def _auto_add_traffic( period_hint_days=period_hint_days, ) - # Verify balance (with fresh price) - if user.balance_kopeks < price_kopeks: + # Verify balance (при 100% скидке — пропускаем) + if price_kopeks > 0 and user.balance_kopeks < price_kopeks: logger.info( '🔁 Автопокупка трафика: у пользователя недостаточно средств (<)', format_user_id=_format_user_id(user), @@ -2172,7 +2172,7 @@ async def try_auto_extend_expired_after_topup( breakdown=pricing.breakdown, ) - if renewal_cost <= 0: + if renewal_cost <= 0 and pricing.base_price <= 0: logger.warning( '❌ Автопродление expired: некорректная стоимость', format_user_id=_format_user_id(user), @@ -2180,8 +2180,8 @@ async def try_auto_extend_expired_after_topup( ) return False - # Check balance - if user.balance_kopeks < renewal_cost: + # Check balance (skip for 100% discount) + if renewal_cost > 0 and user.balance_kopeks < renewal_cost: logger.info( '🔄 Автопродление expired: недостаточно средств', format_user_id=_format_user_id(user), @@ -2523,8 +2523,8 @@ async def try_resume_disabled_daily_after_topup( PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price ) - # Check balance (uses locked user's balance_kopeks — safe from concurrent reads) - if user.balance_kopeks < daily_price: + # Check balance (при 100% скидке — пропускаем) + if daily_price > 0 and user.balance_kopeks < daily_price: logger.info( '🔄 Авто-возобновление daily: недостаточно средств', format_user_id=_format_user_id(user), @@ -3039,7 +3039,7 @@ async def _process_legacy_generic_cart( pricing = prepared.pricing selection = prepared.selection - if pricing.final_total <= 0: + if pricing.final_total <= 0 and pricing.base_original_total <= 0: logger.warning( 'Автопокупка: итоговая сумма для пользователя некорректна', format_user_id=_format_user_id(user), @@ -3047,7 +3047,7 @@ async def _process_legacy_generic_cart( ) return False - if user.balance_kopeks < pricing.final_total: + if pricing.final_total > 0 and user.balance_kopeks < pricing.final_total: logger.info( 'Автопокупка: у пользователя недостаточно средств', format_user_id=_format_user_id(user), diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index 38255246..6b5b70d0 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -989,10 +989,12 @@ class MiniAppSubscriptionPurchaseService: user = context.user texts = get_texts(getattr(user, 'language', None)) - if pricing.final_total <= 0: + # Block only if pricing is genuinely invalid (no base price configured). + # final_total == 0 with base_original_total > 0 means a valid 100% discount. + if pricing.final_total <= 0 and pricing.base_original_total <= 0: raise PurchaseValidationError('Invalid total amount', code='calculation_error') - if user.balance_kopeks < pricing.final_total: + if pricing.final_total > 0 and user.balance_kopeks < pricing.final_total: raise PurchaseBalanceError( texts.t( 'MINIAPP_PURCHASE_STATUS_INSUFFICIENT', diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index f5689a61..b4ce6749 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -6563,8 +6563,8 @@ async def purchase_tariff_endpoint( group_pcts = bd.get('group_discount_pct', {}) discount_percent = group_pcts.get('period', 0) - # Проверяем баланс - if user.balance_kopeks < price_kopeks: + # Проверяем баланс (при 100% скидке — пропускаем) + if price_kopeks > 0 and user.balance_kopeks < price_kopeks: missing = price_kopeks - user.balance_kopeks raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, @@ -7194,8 +7194,8 @@ async def purchase_traffic_topup_endpoint( subscription.end_date, ) - # Проверяем баланс - if user.balance_kopeks < final_price: + # Проверяем баланс (при 100% скидке — пропускаем) + if final_price > 0 and user.balance_kopeks < final_price: raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ diff --git a/migrations/alembic/versions/0053_include_limited_in_unique_active_index.py b/migrations/alembic/versions/0053_include_limited_in_unique_active_index.py new file mode 100644 index 00000000..0006c4fd --- /dev/null +++ b/migrations/alembic/versions/0053_include_limited_in_unique_active_index.py @@ -0,0 +1,48 @@ +"""include limited status in partial unique index for subscriptions + +Revision ID: 0053 +Revises: 0052 +Create Date: 2026-04-03 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0053' +down_revision: Union[str, None] = '0052' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop old partial unique index that only covered active/trial + op.execute(sa.text('DROP INDEX IF EXISTS uq_subscriptions_user_tariff_active')) + + # Recreate with limited status included — a limited subscription (traffic + # exhausted but time remaining) is still "alive" and should prevent + # duplicate subscriptions for the same user+tariff combination. + op.execute( + sa.text( + """ + CREATE UNIQUE INDEX uq_subscriptions_user_tariff_active + ON subscriptions (user_id, tariff_id) + WHERE tariff_id IS NOT NULL AND status IN ('active', 'trial', 'limited') + """ + ) + ) + + +def downgrade() -> None: + op.execute(sa.text('DROP INDEX IF EXISTS uq_subscriptions_user_tariff_active')) + op.execute( + sa.text( + """ + CREATE UNIQUE INDEX uq_subscriptions_user_tariff_active + ON subscriptions (user_id, tariff_id) + WHERE tariff_id IS NOT NULL AND status IN ('active', 'trial') + """ + ) + )