From aa270c9ab48242ec49ce33ea15e9925c920a99c5 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 16:58:12 +0300 Subject: [PATCH 01/11] Update subscription_auto_purchase_service.py --- app/services/subscription_auto_purchase_service.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index aa5e1ed2..573e5a74 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -1542,9 +1542,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'] From fa5c217dd0f72bbb5a1249470dca4a780375e772 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 16:58:40 +0300 Subject: [PATCH 02/11] Update heleket.py --- app/services/payment/heleket.py | 52 +++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) 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( From 59494605727f311fa2237990f27a27a6b6f09aa3 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 17:46:55 +0300 Subject: [PATCH 03/11] Update user_cart_service.py --- app/services/user_cart_service.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 From cc47cea268dbf0078abb91d481a8498fb6d69a85 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 18:16:49 +0300 Subject: [PATCH 04/11] Update subscription.py --- app/cabinet/routes/subscription.py | 2 -- 1 file changed, 2 deletions(-) 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, From 86350424d50f899b2ea7762cb8533fbe9f51863e Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 30 Jan 2026 19:04:44 +0300 Subject: [PATCH 05/11] feat(websocket): add real-time notifications for subscription and balance events - Import and call notify_user_subscription_renewed in auto-extend flows - Import and call notify_user_subscription_activated for new subscriptions - Add WebSocket notifications to _auto_purchase_tariff and _auto_purchase_daily_tariff - Add WebSocket notifications to auto_activate_subscription_after_topup - Add notify_user_balance_topup call in payment common mixin --- app/services/payment/common.py | 21 ++++ .../subscription_auto_purchase_service.py | 116 ++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/app/services/payment/common.py b/app/services/payment/common.py index ce819f62..53fb61c9 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -14,6 +14,8 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.exc import MissingGreenlet from sqlalchemy.ext.asyncio import AsyncSession +# WebSocket notifications for cabinet +from app.cabinet.routes.websocket import notify_user_balance_topup from app.config import settings from app.database.crud.user import get_user_by_telegram_id from app.database.database import get_db @@ -132,6 +134,25 @@ class PaymentCommonMixin: payment_method_title: str | None = None, ) -> None: """Отправляет пользователю уведомление об успешном платеже.""" + # 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/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 573e5a74..1e6662b1 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -9,6 +9,11 @@ from aiogram import Bot from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession +# WebSocket notifications for cabinet +from app.cabinet.routes.websocket import ( + notify_user_subscription_activated, + notify_user_subscription_renewed, +) from app.config import settings from app.database.crud.subscription import extend_subscription from app.database.crud.transaction import create_transaction @@ -559,6 +564,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 @@ -814,6 +833,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 @@ -1051,6 +1093,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 @@ -1243,6 +1308,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 @@ -1397,6 +1485,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 +1577,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: From 32636067028bd6760ca43db2c7e1a584d147c4b2 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 30 Jan 2026 19:17:25 +0300 Subject: [PATCH 06/11] fix: resolve circular import with lazy websocket imports Move websocket notification imports inside functions to avoid circular dependency when module is loaded. --- app/services/payment/common.py | 5 ++-- .../subscription_auto_purchase_service.py | 28 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/app/services/payment/common.py b/app/services/payment/common.py index 53fb61c9..a4a2d3d7 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -14,8 +14,6 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.exc import MissingGreenlet from sqlalchemy.ext.asyncio import AsyncSession -# WebSocket notifications for cabinet -from app.cabinet.routes.websocket import notify_user_balance_topup from app.config import settings from app.database.crud.user import get_user_by_telegram_id from app.database.database import get_db @@ -134,6 +132,9 @@ 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: diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 1e6662b1..64b5994c 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -9,11 +9,6 @@ from aiogram import Bot from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession -# WebSocket notifications for cabinet -from app.cabinet.routes.websocket import ( - notify_user_subscription_activated, - notify_user_subscription_renewed, -) from app.config import settings from app.database.crud.subscription import extend_subscription from app.database.crud.transaction import create_transaction @@ -352,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 @@ -589,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, @@ -869,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 @@ -1126,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 @@ -1361,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 From 8a9994e5398f2731f77eb26ea4b7d13c2d1a7f34 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 20:41:42 +0300 Subject: [PATCH 07/11] Update user_service.py --- app/services/user_service.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/services/user_service.py b/app/services/user_service.py index 878bf702..2068be14 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, ) @@ -989,6 +990,19 @@ 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() From 55d817bcad815ddc0a151d10ab20d16633cabc24 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 20:43:45 +0300 Subject: [PATCH 08/11] Update user_service.py --- app/services/user_service.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/services/user_service.py b/app/services/user_service.py index 2068be14..bce7ac74 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -991,9 +991,7 @@ class UserService: logger.error(f'❌ Ошибка удаления подписки: {e}') try: - wata_payments_result = await db.execute( - select(WataPayment).where(WataPayment.user_id == user_id) - ) + wata_payments_result = await db.execute(select(WataPayment).where(WataPayment.user_id == user_id)) wata_payments = wata_payments_result.scalars().all() if wata_payments: From e3901c8d39052b4303cf75f3894f36b36e42d5a3 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 21:04:10 +0300 Subject: [PATCH 09/11] Add files via upload --- app/services/payment_method_config_service.py | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) 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 From 7000cd5bc29959500ff1d629854c1c9ae1676e15 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 21:04:46 +0300 Subject: [PATCH 10/11] Update balance.py --- app/cabinet/routes/balance.py | 243 ++++++++++------------------------ 1 file changed, 71 insertions(+), 172 deletions(-) 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: From f688c74aeed28d1fd697afa6f22915884de5f5b2 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 23:02:00 +0300 Subject: [PATCH 11/11] Update monitoring_service.py --- app/services/monitoring_service.py | 58 +----------------------------- 1 file changed, 1 insertion(+), 57 deletions(-) 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ГБит/сек - ⚡️ Успейте оформить до окончания тестового периода! """