diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 1a048d82..1a72121a 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -13,6 +13,7 @@ from app.config import settings from app.database.crud.user import get_user_by_id from app.database.models import PaymentMethod, Transaction, User from app.external.cryptobot import CryptoBotService +from app.services.payment_method_config_service import get_enabled_methods_for_user from app.services.payment_service import PaymentService from app.services.payment_verification_service import ( SUPPORTED_MANUAL_CHECK_METHODS, @@ -128,185 +129,83 @@ async def get_transactions( @router.get('/payment-methods', response_model=list[PaymentMethodResponse]) -async def get_payment_methods(): - """Get available payment methods.""" +async def get_payment_methods( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get available payment methods for the current user. + + Uses PaymentMethodConfig from database for: + - Sort order (sort_order) + - Enabled/disabled status (is_enabled) + - Display names (display_name with fallback to env) + - Min/max amounts (with fallback to env defaults) + - Sub-options filtering (sub_options) + - User filters (user_type_filter, first_topup_filter, promo_group_filter) + """ + # Check if this is user's first topup + from sqlalchemy import exists + + has_completed_topup = await db.execute( + select( + exists().where( + Transaction.user_id == user.id, + Transaction.type == 'deposit', + Transaction.is_completed == True, + ) + ) + ) + is_first_topup = not has_completed_topup.scalar() + + # Get enabled methods from database config + enabled_methods = await get_enabled_methods_for_user(db, user=user, is_first_topup=is_first_topup) + + # Build response with additional options formatting methods = [] + for method_data in enabled_methods: + method_id = method_data['id'] - # YooKassa - with card and SBP options - if settings.is_yookassa_enabled(): - methods.append( - PaymentMethodResponse( - id='yookassa', - name=settings.get_yookassa_display_name(), - description='Pay via YooKassa', - min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, - is_available=True, - options=[ - {'id': 'card', 'name': 'πŸ’³ ΠšΠ°Ρ€Ρ‚Π°', 'description': 'Банковская ΠΊΠ°Ρ€Ρ‚Π°'}, - {'id': 'sbp', 'name': '🏦 Π‘Π‘ΠŸ', 'description': 'БистСма быстрых ΠΏΠ»Π°Ρ‚Π΅ΠΆΠ΅ΠΉ (QR)'}, - ], - ) - ) + # Format options with descriptions for specific methods + options = method_data.get('options') + if options: + formatted_options = [] + for opt in options: + opt_id = opt['id'] + opt_name = opt.get('name', opt_id) + description = '' - # CryptoBot - if settings.is_cryptobot_enabled(): - methods.append( - PaymentMethodResponse( - id='cryptobot', - name=settings.get_cryptobot_display_name(), - description='Pay with cryptocurrency via CryptoBot', - min_amount_kopeks=1000, - max_amount_kopeks=10000000, - is_available=True, - ) - ) + # Add descriptions based on method and option + if method_id in ('yookassa', 'pal24', 'cloudpayments', 'freekassa'): + if opt_id == 'card': + opt_name = f'πŸ’³ {opt_name}' + description = 'Банковская ΠΊΠ°Ρ€Ρ‚Π°' + elif opt_id == 'sbp': + opt_name = f'🏦 {opt_name}' + description = 'БистСма быстрых ΠΏΠ»Π°Ρ‚Π΅ΠΆΠ΅ΠΉ' + elif method_id == 'platega': + # Platega options already have descriptions from config + definitions = settings.get_platega_method_definitions() + info = definitions.get(int(opt_id), {}) if opt_id.isdigit() else {} + description = info.get('description') or info.get('name') or '' - # Telegram Stars - if settings.TELEGRAM_STARS_ENABLED: - methods.append( - PaymentMethodResponse( - id='telegram_stars', - name=settings.get_telegram_stars_display_name(), - description='Pay with Telegram Stars', - min_amount_kopeks=100, - max_amount_kopeks=1000000, - is_available=True, - ) - ) - - # Heleket - if settings.is_heleket_enabled(): - methods.append( - PaymentMethodResponse( - id='heleket', - name=settings.get_heleket_display_name(), - description='Pay with cryptocurrency via Heleket', - min_amount_kopeks=1000, - max_amount_kopeks=10000000, - is_available=True, - ) - ) - - # MulenPay - if settings.is_mulenpay_enabled(): - methods.append( - PaymentMethodResponse( - id='mulenpay', - name=settings.get_mulenpay_display_name(), - description='MulenPay payment', - min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS, - is_available=True, - ) - ) - - # PAL24 - add options for card/sbp - if settings.is_pal24_enabled(): - methods.append( - PaymentMethodResponse( - id='pal24', - name=settings.get_pal24_display_name(), - description='Pay via PAL24', - min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS, - is_available=True, - options=[ - {'id': 'sbp', 'name': '🏦 Π‘Π‘ΠŸ', 'description': 'БистСма быстрых ΠΏΠ»Π°Ρ‚Π΅ΠΆΠ΅ΠΉ'}, - {'id': 'card', 'name': 'πŸ’³ ΠšΠ°Ρ€Ρ‚Π°', 'description': 'Банковская ΠΊΠ°Ρ€Ρ‚Π°'}, - ], - ) - ) - - # Platega - add options for different payment methods - if settings.is_platega_enabled(): - platega_methods = settings.get_platega_active_methods() - definitions = settings.get_platega_method_definitions() - platega_options = [] - for method_code in platega_methods: - info = definitions.get(method_code, {}) - platega_options.append( - { - 'id': str(method_code), - 'name': info.get('title') or info.get('name') or f'Platega {method_code}', - 'description': info.get('description') or info.get('name') or '', - } - ) + formatted_options.append( + { + 'id': opt_id, + 'name': opt_name, + 'description': description, + } + ) + options = formatted_options if formatted_options else None methods.append( PaymentMethodResponse( - id='platega', - name=settings.get_platega_display_name(), - description='Pay via Platega', - min_amount_kopeks=settings.PLATEGA_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.PLATEGA_MAX_AMOUNT_KOPEKS, - is_available=True, - options=platega_options if platega_options else None, - ) - ) - - # Wata - if settings.is_wata_enabled(): - methods.append( - PaymentMethodResponse( - id='wata', - name=settings.get_wata_display_name(), - description='Pay via Wata', - min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS, - is_available=True, - ) - ) - - # CloudPayments - if settings.is_cloudpayments_enabled(): - methods.append( - PaymentMethodResponse( - id='cloudpayments', - name=settings.get_cloudpayments_display_name(), - description='Pay with bank card via CloudPayments', - min_amount_kopeks=settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS, - is_available=True, - ) - ) - - # FreeKassa - if settings.is_freekassa_enabled(): - methods.append( - PaymentMethodResponse( - id='freekassa', - name=settings.get_freekassa_display_name(), - description='Pay via FreeKassa', - min_amount_kopeks=settings.FREEKASSA_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.FREEKASSA_MAX_AMOUNT_KOPEKS, - is_available=True, - ) - ) - - # KassaAI - if settings.is_kassa_ai_enabled(): - methods.append( - PaymentMethodResponse( - id='kassa_ai', - name=settings.get_kassa_ai_display_name(), - description='Pay via KassaAI', - min_amount_kopeks=settings.KASSA_AI_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.KASSA_AI_MAX_AMOUNT_KOPEKS, - is_available=True, - ) - ) - - # Tribute - if settings.TRIBUTE_ENABLED and settings.TRIBUTE_DONATE_LINK: - methods.append( - PaymentMethodResponse( - id='tribute', - name='Tribute', - description='Pay with bank card via Tribute', - min_amount_kopeks=10000, - max_amount_kopeks=10000000, + id=method_id, + name=method_data['name'], + description=None, + min_amount_kopeks=method_data['min_amount_kopeks'], + max_amount_kopeks=method_data['max_amount_kopeks'], is_available=True, + options=options, ) ) @@ -414,7 +313,7 @@ async def create_topup( ): """Create payment for balance top-up.""" # Validate payment method - methods = await get_payment_methods() + methods = await get_payment_methods(user=user, db=db) method = next((m for m in methods if m.id == request.payment_method), None) if not method or not method.is_available: diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 11b9a769..2ccbf4a6 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -1667,8 +1667,6 @@ async def purchase_tariff( # Save cart for auto-renewal (not for daily tariffs - they have their own charging) if not is_daily_tariff: try: - from app.services.user_cart_service import user_cart_service - cart_data = { 'cart_mode': 'extend', 'subscription_id': subscription.id, diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 9a6af9e1..4781f595 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -1193,57 +1193,7 @@ class MonitoringService: try: get_texts(user.language) - # РассчитываСм ΠΌΠΈΠ½ΠΈΠΌΠ°Π»ΡŒΠ½ΡƒΡŽ Ρ†Π΅Π½Ρƒ Π·Π° подписку с минимальной ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠ΅ΠΉ - from app.config import PERIOD_PRICES, settings - from app.utils.pricing_utils import apply_percentage_discount - - # Базовая Ρ†Π΅Π½Π° Π·Π° 30 Π΄Π½Π΅ΠΉ - base_price_original = PERIOD_PRICES.get(30, settings.PRICE_30_DAYS) - - # ΠŸΡ€ΠΈΠΌΠ΅Π½ΡΠ΅ΠΌ скидку ΠΏΡ€ΠΎΠΌΠΎΠ³Ρ€ΡƒΠΏΠΏΡ‹ для ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ "period" - promo_group_discount = user.get_promo_discount('period', 30) if user else 0 - # ΠŸΡ€ΠΈΠΌΠ΅Π½ΡΠ΅ΠΌ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΡƒΡŽ ΠΏΡ€ΠΎΠΌΠΎ-скидку (Ссли Π΅ΡΡ‚ΡŒ) - user_discount_percent = self._get_user_promo_offer_discount_percent(user) - - # ΠžΠ±Ρ‰Π°Ρ скидка - максимальная ΠΈΠ· ΠΏΡ€ΠΎΠΌΠΎΠ³Ρ€ΡƒΠΏΠΏΡ‹ ΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠΎΠΉ - total_discount_percent = max(promo_group_discount, user_discount_percent) - - base_price, _ = apply_percentage_discount(base_price_original, total_discount_percent) - - # ДобавляСм Ρ†Π΅Π½Ρƒ Π·Π° Ρ‚Ρ€Π°Ρ„ΠΈΠΊ (Ссли фиксированный Ρ‚Ρ€Π°Ρ„ΠΈΠΊ Π²ΠΊΠ»ΡŽΡ‡Ρ‘Π½) - if settings.is_traffic_fixed(): - traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit()) - # ΠŸΡ€ΠΈΠΌΠ΅Π½ΡΠ΅ΠΌ скидки Π½Π° Ρ‚Ρ€Π°Ρ„ΠΈΠΊ - traffic_discount = user.get_promo_discount('traffic', 30) if user else 0 - traffic_price, _ = apply_percentage_discount(traffic_price, traffic_discount) - else: - traffic_price = 0 # Π’Ρ€Π°Ρ„ΠΈΠΊ Π½Π΅ фиксирован, Ρ†Π΅Π½Π° Π²ΠΊΠ»ΡŽΡ‡Π΅Π½Π° Π² Π±Π°Π·ΠΎΠ²ΡƒΡŽ - - # ДобавляСм Ρ†Π΅Π½Ρƒ Π·Π° сСрвСры (ΠΏΡ€Π΅Π΄ΠΏΠΎΠ»Π°Π³Π°Π΅ΠΌ ΠΌΠΈΠ½ΠΈΠΌΡƒΠΌ 1 сСрвСр ΠΏΠΎ минимальной Ρ†Π΅Π½Π΅) - # ВмСсто слоТного запроса ΠΊ Π‘Π”, ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ настройки - # Для минимальной ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΈ - ΠΎΠ΄ΠΈΠ½ сСрвСр с минимальной Ρ†Π΅Π½ΠΎΠΉ - min_server_price = getattr(settings, 'MIN_SERVER_PRICE', 0) or 0 - if min_server_price == 0: - # Если Π½Π΅Ρ‚ явной минимальной Ρ†Π΅Π½Ρ‹, ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ Π±Π°Π·ΠΎΠ²ΡƒΡŽ Ρ†Π΅Π½Ρƒ - # Π’ Ρ€Π΅Π°Π»ΡŒΠ½Ρ‹Ρ… условиях Ρ†Π΅Π½Π° сСрвСра Π±ΡƒΠ΄Π΅Ρ‚ ΠΎΠΏΡ€Π΅Π΄Π΅Π»ΡΡ‚ΡŒΡΡ Π² Ρ…ΠΎΠ΄Π΅ оформлСния подписки - min_server_price = 0 - - # ДобавляСм Ρ†Π΅Π½Ρƒ Π·Π° устройства (Ссли большС Π±Π°Π·ΠΎΠ²ΠΎΠ³ΠΎ Π»ΠΈΠΌΠΈΡ‚Π°) - # Π’ минимальной ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΈ - Π±Π°Π·ΠΎΠ²Ρ‹ΠΉ Π»ΠΈΠΌΠΈΡ‚, Π±Π΅Π· Π΄ΠΎΠΏ. устройств - device_limit = settings.DEFAULT_DEVICE_LIMIT - additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT) - additional_devices * settings.PRICE_PER_DEVICE - - # Для простоты ΠΈ ΠΏΡ€Π°Π²ΠΈΠ»ΡŒΠ½ΠΎΠΉ Ρ€Π°Π±ΠΎΡ‚Ρ‹ Π±Π΅Π· обращСния ΠΊ Π‘Π”, рассчитываСм ΠΌΠΈΠ½ΠΈΠΌΠ°Π»ΡŒΠ½ΡƒΡŽ Ρ†Π΅Π½Ρƒ ΠΊΠ°ΠΊ: - # базовая Ρ†Π΅Π½Π° + минимальная Ρ†Π΅Π½Π° Π·Π° Ρ‚Ρ€Π°Ρ„ΠΈΠΊ (Ссли Π΅ΡΡ‚ΡŒ фиксированный) - min_server_price = 0 # для минимальной ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΈ с 1 сСрвСром ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ 0 ΠΈΠ»ΠΈ ΠΌΠΈΠ½ΠΈΠΌΠ°Π»ΡŒΠ½ΡƒΡŽ ΠΈΠ·Π²Π΅ΡΡ‚Π½ΡƒΡŽ - - # ΠŸΠΎΠΏΡ€ΠΎΠ±ΡƒΠ΅ΠΌ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ ΠΌΠΈΠ½ΠΈΠΌΠ°Π»ΡŒΠ½ΡƒΡŽ Ρ†Π΅Π½Ρƒ сСрвСра ΠΈΠ· настроСк ΠΈΠ»ΠΈ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ подходящСС Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ - # Находим ΠΌΠΈΠ½ΠΈΠΌΠ°Π»ΡŒΠ½ΡƒΡŽ Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΡƒΡŽ Ρ†Π΅Π½Ρƒ ΠΈΠ· Π²ΠΎΠ·ΠΌΠΎΠΆΠ½Ρ‹Ρ… Ρ†Π΅Π½ сСрвСров - # Π’ ΡƒΠΏΡ€ΠΎΡ‰Π΅Π½Π½ΠΎΠΌ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚Π΅ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ Π±Π°Π·ΠΎΠ²ΡƒΡŽ ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΡŽ: базовая Ρ†Π΅Π½Π° + Ρ‚Ρ€Π°Ρ„ΠΈΠΊ - min_total_price = base_price + traffic_price - - message = f""" + message = """ 🎁 ВСстовая подписка скоро закончится! Π’Π°ΡˆΠ° тСстовая подписка истСкаСт Ρ‡Π΅Ρ€Π΅Π· 2 часа. @@ -1251,12 +1201,6 @@ class MonitoringService: πŸ’Ž НС Ρ…ΠΎΡ‚ΠΈΡ‚Π΅ ΠΎΡΡ‚Π°Ρ‚ΡŒΡΡ Π±Π΅Π· VPN? ΠŸΠ΅Ρ€Π΅Ρ…ΠΎΠ΄ΠΈΡ‚Π΅ Π½Π° ΠΏΠΎΠ»Π½ΡƒΡŽ подписку! -πŸ”₯ Π‘ΠΏΠ΅Ρ†ΠΈΠ°Π»ΡŒΠ½ΠΎΠ΅ ΠΏΡ€Π΅Π΄Π»ΠΎΠΆΠ΅Π½ΠΈΠ΅: -β€’ 30 Π΄Π½Π΅ΠΉ всСго Π·Π° {settings.format_price(min_total_price)} -β€’ Π‘Π΅Π·Π»ΠΈΠΌΠΈΡ‚Π½Ρ‹ΠΉ Ρ‚Ρ€Π°Ρ„ΠΈΠΊ -β€’ ВсС сСрвСры доступны -β€’ Π‘ΠΊΠΎΡ€ΠΎΡΡ‚ΡŒ Π΄ΠΎ 1Π“Π‘ΠΈΡ‚/сСк - ⚑️ УспСйтС ΠΎΡ„ΠΎΡ€ΠΌΠΈΡ‚ΡŒ Π΄ΠΎ окончания тСстового ΠΏΠ΅Ρ€ΠΈΠΎΠ΄Π°! """ diff --git a/app/services/payment/common.py b/app/services/payment/common.py index ce819f62..a4a2d3d7 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -132,6 +132,28 @@ class PaymentCommonMixin: payment_method_title: str | None = None, ) -> None: """ΠžΡ‚ΠΏΡ€Π°Π²Π»ΡΠ΅Ρ‚ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŽ ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ ΠΎΠ± ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎΠΌ ΠΏΠ»Π°Ρ‚Π΅ΠΆΠ΅.""" + # Lazy import to avoid circular dependency + from app.cabinet.routes.websocket import notify_user_balance_topup + + # Send WebSocket notification to cabinet frontend (works for both Telegram and email-only users) + user_id = getattr(user, 'id', None) if user else None + if user_id: + try: + # Get new balance from user + new_balance = getattr(user, 'balance_kopeks', 0) + await notify_user_balance_topup( + user_id=user_id, + amount_kopeks=amount_kopeks, + new_balance_kopeks=new_balance, + description=payment_method_title or '', + ) + except Exception as ws_error: + logger.warning( + 'НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ WS ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ ΠΎ ΠΏΠΎΠΏΠΎΠ»Π½Π΅Π½ΠΈΠΈ баланса для user_id=%s: %s', + user_id, + ws_error, + ) + if not getattr(self, 'bot', None): # Если Π±ΠΎΡ‚ Π½Π΅ ΠΏΠ΅Ρ€Π΅Π΄Π°Π½ (Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€, Π²Π½ΡƒΡ‚Ρ€ΠΈ Ρ„ΠΎΠ½ΠΎΠ²Ρ‹Ρ… Π·Π°Π΄Π°Ρ‡), ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ пропускаСм. return diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index 9149d0c6..310e75f2 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -12,6 +12,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType +from app.services.subscription_auto_purchase_service import ( + auto_activate_subscription_after_topup, + auto_purchase_saved_cart_after_topup, +) from app.utils.payment_logger import payment_logger as logger from app.utils.user_utils import format_referrer_info @@ -424,6 +428,54 @@ class HeleketPaymentMixin: else: logger.info(f'ΠŸΡ€ΠΎΠΏΡƒΡΠΊ Telegram-увСдомлСния Heleket для email-ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ {user.id}') + # Автопокупка ΠΈΠ· сохранённой ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹ ΠΈ умная автоактивация + try: + from app.services.user_cart_service import user_cart_service + + has_saved_cart = await user_cart_service.has_user_cart(user.id) + auto_purchase_success = False + if has_saved_cart: + try: + auto_purchase_success = await auto_purchase_saved_cart_after_topup( + db, + user, + bot=getattr(self, 'bot', None), + ) + except Exception as auto_error: + logger.error( + 'Ошибка автоматичСской ΠΏΠΎΠΊΡƒΠΏΠΊΠΈ подписки для ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ %s: %s', + user.id, + auto_error, + exc_info=True, + ) + + if auto_purchase_success: + has_saved_cart = False + + # Умная автоактивация Ссли Π°Π²Ρ‚ΠΎΠΏΠΎΠΊΡƒΠΏΠΊΠ° Π½Π΅ сработала + if not auto_purchase_success: + try: + await auto_activate_subscription_after_topup( + db, + user, + bot=getattr(self, 'bot', None), + topup_amount=amount_kopeks, + ) + except Exception as auto_activate_error: + logger.error( + 'Ошибка ΡƒΠΌΠ½ΠΎΠΉ Π°Π²Ρ‚ΠΎΠ°ΠΊΡ‚ΠΈΠ²Π°Ρ†ΠΈΠΈ для ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ %s: %s', + user.id, + auto_activate_error, + exc_info=True, + ) + except Exception as error: + logger.error( + 'Ошибка ΠΏΡ€ΠΈ Ρ€Π°Π±ΠΎΡ‚Π΅ с Π°Π²Ρ‚ΠΎΠ°ΠΊΡ‚ΠΈΠ²Π°Ρ†ΠΈΠ΅ΠΉ для ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ %s: %s', + user.id, + error, + exc_info=True, + ) + return updated_payment async def process_heleket_webhook( diff --git a/app/services/payment_method_config_service.py b/app/services/payment_method_config_service.py index 34d183f5..f06f637e 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -280,3 +280,108 @@ async def get_all_promo_groups(db: AsyncSession) -> list[PromoGroup]: """Get all promo groups for the filter selector.""" result = await db.execute(select(PromoGroup).order_by(PromoGroup.priority.desc(), PromoGroup.name)) return list(result.scalars().all()) + + +# ============ User-facing methods ============ + + +async def get_enabled_methods_for_user( + db: AsyncSession, + user: 'User | None' = None, + is_first_topup: bool | None = None, +) -> list[dict]: + """Get payment methods available for a specific user. + + Applies all filters from PaymentMethodConfig: + - is_enabled + - is_provider_configured (from env) + - user_type_filter + - first_topup_filter + - promo_group_filter + + Returns list of dicts with method info ready for API response. + """ + from app.database.models import UserPromoGroup + + configs = await get_all_configs(db) + defaults = _get_method_defaults() + + result = [] + + for config in configs: + method_id = config.method_id + method_def = defaults.get(method_id, {}) + + # Skip if not enabled in admin panel + if not config.is_enabled: + continue + + # Skip if provider not configured in env + if not method_def.get('is_configured', False): + continue + + # Apply user_type_filter + if user and config.user_type_filter != 'all': + if config.user_type_filter == 'telegram' and not user.telegram_id: + continue + if config.user_type_filter == 'email' and not getattr(user, 'email', None): + continue + + # Apply first_topup_filter + if config.first_topup_filter != 'any' and is_first_topup is not None: + if config.first_topup_filter == 'yes' and not is_first_topup: + continue + if config.first_topup_filter == 'no' and is_first_topup: + continue + + # Apply promo_group_filter + if config.promo_group_filter_mode == 'selected' and user: + allowed_group_ids = {pg.id for pg in config.allowed_promo_groups} + if allowed_group_ids: + # Get user's promo groups + user_groups_result = await db.execute( + select(UserPromoGroup.promo_group_id).where(UserPromoGroup.user_id == user.id) + ) + user_group_ids = set(user_groups_result.scalars().all()) + + # Check if user has at least one allowed group + if not user_group_ids.intersection(allowed_group_ids): + continue + + # Build display name + display_name = config.display_name or method_def.get('default_display_name', method_id) + + # Build min/max amounts (DB overrides env defaults) + min_amount = ( + config.min_amount_kopeks if config.min_amount_kopeks is not None else method_def.get('default_min', 1000) + ) + max_amount = ( + config.max_amount_kopeks + if config.max_amount_kopeks is not None + else method_def.get('default_max', 10000000) + ) + + # Build options (filter by sub_options config) + options = None + available_sub_options = method_def.get('available_sub_options') + if available_sub_options and config.sub_options: + enabled_options = [] + for opt in available_sub_options: + opt_id = opt['id'] + if config.sub_options.get(opt_id, True): + enabled_options.append(opt) + if enabled_options: + options = enabled_options + + result.append( + { + 'id': method_id, + 'name': display_name, + 'min_amount_kopeks': min_amount, + 'max_amount_kopeks': max_amount, + 'options': options, + 'sort_order': config.sort_order, + } + ) + + return result diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index aa5e1ed2..64b5994c 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -347,6 +347,9 @@ async def _auto_extend_subscription( *, bot: Bot | None = None, ) -> bool: + # Lazy import to avoid circular dependency + from app.cabinet.routes.websocket import notify_user_subscription_renewed + try: prepared = await _prepare_auto_extend_context(db, user, cart_data) except Exception as error: # pragma: no cover - defensive logging @@ -559,6 +562,20 @@ async def _auto_extend_subscription( _format_user_id(user), ) + # Send WebSocket notification to cabinet frontend + try: + await notify_user_subscription_renewed( + user_id=user.id, + new_expires_at=new_end_date.isoformat() if new_end_date else '', + amount_kopeks=prepared.price_kopeks, + ) + except Exception as ws_error: + logger.warning( + '⚠️ Автопокупка: Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ WS ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ ΠΎ ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ для %s: %s', + _format_user_id(user), + ws_error, + ) + return True @@ -570,6 +587,11 @@ async def _auto_purchase_tariff( bot: Bot | None = None, ) -> bool: """АвтоматичСская ΠΏΠΎΠΊΡƒΠΏΠΊΠ° ΠΏΠ΅Ρ€ΠΈΠΎΠ΄Π½ΠΎΠ³ΠΎ Ρ‚Π°Ρ€ΠΈΡ„Π° ΠΈΠ· сохранённой ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹.""" + # Lazy imports to avoid circular dependency + from app.cabinet.routes.websocket import ( + notify_user_subscription_activated, + notify_user_subscription_renewed, + ) from app.database.crud.server_squad import get_all_server_squads from app.database.crud.subscription import ( create_paid_subscription, @@ -814,6 +836,29 @@ async def _auto_purchase_tariff( _format_user_id(user), ) + # Send WebSocket notification to cabinet frontend + try: + if existing_subscription: + # Renewal of existing subscription + await notify_user_subscription_renewed( + user_id=user.id, + new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '', + amount_kopeks=final_price, + ) + else: + # New subscription activation + await notify_user_subscription_activated( + user_id=user.id, + expires_at=subscription.end_date.isoformat() if subscription.end_date else '', + tariff_name=tariff.name, + ) + except Exception as ws_error: + logger.warning( + '⚠️ Автопокупка Ρ‚Π°Ρ€ΠΈΡ„Π°: Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ WS ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ для %s: %s', + _format_user_id(user), + ws_error, + ) + return True @@ -827,6 +872,11 @@ async def _auto_purchase_daily_tariff( """АвтоматичСская ΠΏΠΎΠΊΡƒΠΏΠΊΠ° суточного Ρ‚Π°Ρ€ΠΈΡ„Π° ΠΈΠ· сохранённой ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹.""" from datetime import datetime, timedelta + # Lazy imports to avoid circular dependency + from app.cabinet.routes.websocket import ( + notify_user_subscription_activated, + notify_user_subscription_renewed, + ) from app.database.crud.server_squad import get_all_server_squads from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id from app.database.crud.tariff import get_tariff_by_id @@ -1051,6 +1101,29 @@ async def _auto_purchase_daily_tariff( _format_user_id(user), ) + # Send WebSocket notification to cabinet frontend + try: + if existing_subscription: + # Renewal/upgrade of existing subscription + 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, + ) + else: + # New subscription activation + await notify_user_subscription_activated( + user_id=user.id, + expires_at=subscription.end_date.isoformat() if subscription.end_date else '', + tariff_name=tariff.name, + ) + except Exception as ws_error: + logger.warning( + '⚠️ Автопокупка суточного Ρ‚Π°Ρ€ΠΈΡ„Π°: Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ WS ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ для %s: %s', + _format_user_id(user), + ws_error, + ) + return True @@ -1061,6 +1134,11 @@ async def auto_purchase_saved_cart_after_topup( bot: Bot | None = None, ) -> bool: """Attempts to automatically purchase a subscription from a saved cart.""" + # Lazy imports to avoid circular dependency + from app.cabinet.routes.websocket import ( + notify_user_subscription_activated, + notify_user_subscription_renewed, + ) if not settings.is_auto_purchase_after_topup_enabled(): return False @@ -1243,6 +1321,29 @@ async def auto_purchase_saved_cart_after_topup( _format_user_id(user), ) + # Send WebSocket notification to cabinet frontend + try: + if was_trial_conversion: + # Trial conversion = activation + await notify_user_subscription_activated( + user_id=user.id, + expires_at=subscription.end_date.isoformat() if subscription and subscription.end_date else '', + tariff_name='', + ) + else: + # Regular purchase = renewal or new activation + await notify_user_subscription_renewed( + user_id=user.id, + new_expires_at=subscription.end_date.isoformat() if subscription and subscription.end_date else '', + amount_kopeks=pricing.final_total, + ) + except Exception as ws_error: + logger.warning( + '⚠️ Автопокупка: Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ WS ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ для %s: %s', + _format_user_id(user), + ws_error, + ) + return True @@ -1273,6 +1374,11 @@ async def auto_activate_subscription_after_topup( """ from datetime import datetime + # Lazy imports to avoid circular dependency + from app.cabinet.routes.websocket import ( + notify_user_subscription_activated, + notify_user_subscription_renewed, + ) from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id from app.database.crud.transaction import create_transaction @@ -1397,6 +1503,20 @@ async def auto_activate_subscription_after_topup( best_price, ) + # Send WebSocket notification to cabinet frontend + try: + await notify_user_subscription_renewed( + user_id=user.id, + new_expires_at=result.subscription.end_date.isoformat() if result.subscription.end_date else '', + amount_kopeks=best_price, + ) + except Exception as ws_error: + logger.warning( + '⚠️ Автоактивация: Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ WS ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ ΠΎ ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ для %s: %s', + _format_user_id(user), + ws_error, + ) + # Π£Π²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŽ (Ρ‚ΠΎΠ»ΡŒΠΊΠΎ для Telegram-ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ) if bot and user.telegram_id: try: @@ -1475,6 +1595,20 @@ async def auto_activate_subscription_after_topup( best_price, ) + # Send WebSocket notification to cabinet frontend + try: + await notify_user_subscription_activated( + user_id=user.id, + expires_at=new_subscription.end_date.isoformat() if new_subscription.end_date else '', + tariff_name='', + ) + except Exception as ws_error: + logger.warning( + '⚠️ Автоактивация: Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ WS ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ ΠΎΠ± Π°ΠΊΡ‚ΠΈΠ²Π°Ρ†ΠΈΠΈ для %s: %s', + _format_user_id(user), + ws_error, + ) + # Π£Π²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŽ (Ρ‚ΠΎΠ»ΡŒΠΊΠΎ для Telegram-ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ) if bot and user.telegram_id: try: @@ -1542,9 +1676,11 @@ async def auto_activate_subscription_after_topup( e, exc_info=True, ) + try: + await db.rollback() + except Exception: + pass return (False, False) - await db.rollback() - return False __all__ = ['auto_activate_subscription_after_topup', 'auto_purchase_saved_cart_after_topup'] diff --git a/app/services/user_cart_service.py b/app/services/user_cart_service.py index f7901be4..f9522d28 100644 --- a/app/services/user_cart_service.py +++ b/app/services/user_cart_service.py @@ -52,6 +52,7 @@ class UserCartService: """ client = self._get_redis_client() if client is None: + logger.warning(f'πŸ›’ Redis нСдоступСн, ΠΊΠΎΡ€Π·ΠΈΠ½Π° ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ {user_id} НЕ сохранСна') return False try: @@ -59,10 +60,11 @@ class UserCartService: json_data = json.dumps(cart_data, ensure_ascii=False) effective_ttl = ttl if ttl is not None else settings.CART_TTL_SECONDS await client.setex(key, effective_ttl, json_data) - logger.debug(f'ΠšΠΎΡ€Π·ΠΈΠ½Π° ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ {user_id} сохранСна Π² Redis') + cart_mode = cart_data.get('cart_mode', 'unknown') + logger.info(f'πŸ›’ ΠšΠΎΡ€Π·ΠΈΠ½Π° ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ {user_id} сохранСна Π² Redis (mode={cart_mode}, ttl={effective_ttl}s)') return True except Exception as e: - logger.error(f'Ошибка сохранСния ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ {user_id}: {e}') + logger.error(f'πŸ›’ Ошибка сохранСния ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ {user_id}: {e}') return False async def get_user_cart(self, user_id: int) -> dict[str, Any] | None: @@ -127,14 +129,17 @@ class UserCartService: """ client = self._get_redis_client() if client is None: + logger.warning(f'πŸ›’ Redis нСдоступСн, ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ {user_id} Π½Π΅Π²ΠΎΠ·ΠΌΠΎΠΆΠ½Π°') return False try: key = f'user_cart:{user_id}' exists = await client.exists(key) - return bool(exists) + result = bool(exists) + logger.info(f'πŸ›’ ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ {user_id}: {"Π½Π°ΠΉΠ΄Π΅Π½Π°" if result else "Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π°"}') + return result except Exception as e: - logger.error(f'Ошибка ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ наличия ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ {user_id}: {e}') + logger.error(f'πŸ›’ Ошибка ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ наличия ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ {user_id}: {e}') return False diff --git a/app/services/user_service.py b/app/services/user_service.py index 849799bd..c811400f 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -49,6 +49,7 @@ from app.database.models import ( User, UserMessage, UserStatus, + WataPayment, WelcomeText, YooKassaPayment, ) @@ -1055,6 +1056,17 @@ class UserService: except Exception as e: logger.error(f'❌ Ошибка удалСния подписки: {e}') + try: + wata_payments_result = await db.execute(select(WataPayment).where(WataPayment.user_id == user_id)) + wata_payments = wata_payments_result.scalars().all() + + if wata_payments: + logger.info(f'πŸ”„ УдаляСм {len(wata_payments)} Wata ΠΏΠ»Π°Ρ‚Π΅ΠΆΠ΅ΠΉ') + await db.execute(delete(WataPayment).where(WataPayment.user_id == user_id)) + await db.flush() + except Exception as e: + logger.error(f'❌ Ошибка удалСния Wata ΠΏΠ»Π°Ρ‚Π΅ΠΆΠ΅ΠΉ: {e}') + try: await db.execute(delete(User).where(User.id == user_id)) await db.commit()