From 86350424d50f899b2ea7762cb8533fbe9f51863e Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 30 Jan 2026 19:04:44 +0300 Subject: [PATCH 1/2] 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 2/2] 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