From 9ba61a08796fbc06e0dea2ee9cb02edc4126b335 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Mar 2026 20:33:43 +0300 Subject: [PATCH 1/8] feat: add telegram gift notification with inline activation button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New gift_activation handler for gift_activate:{id} callback buttons - Send Telegram notification to gift recipients with activate button - Add skip_notification param to activate_purchase to prevent duplicates - Fix telegram username regex minimum length (4→5 chars) in landing routes - Add BOT_TOKEN guard in telegram gift notification sender - Pre-resolve notification params before commit to avoid DetachedInstanceError --- app/bot.py | 2 + app/cabinet/routes/landing.py | 16 +- app/database/crud/user.py | 24 +++ app/handlers/gift_activation.py | 95 ++++++++++ app/handlers/start.py | 232 ++++++++++++++++++++----- app/services/guest_purchase_service.py | 192 +++++++++++++++++--- app/services/subscription_service.py | 6 +- 7 files changed, 501 insertions(+), 66 deletions(-) create mode 100644 app/handlers/gift_activation.py diff --git a/app/bot.py b/app/bot.py index c1df99e8..14d961e5 100644 --- a/app/bot.py +++ b/app/bot.py @@ -60,6 +60,7 @@ from app.handlers.admin import ( welcome_text as admin_welcome_text, ) from app.handlers.channel_member import register_handlers as register_channel_member_handlers +from app.handlers.gift_activation import register_handlers as register_gift_activation_handlers from app.handlers.stars_payments import register_stars_handlers from app.middlewares.auth import AuthMiddleware from app.middlewares.blacklist import BlacklistMiddleware @@ -199,6 +200,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_blocked_users.register_handlers(dp) admin_required_channels.register_handlers(dp) register_channel_member_handlers(dp) + register_gift_activation_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) user_contests.register_handlers(dp) diff --git a/app/cabinet/routes/landing.py b/app/cabinet/routes/landing.py index 71bb5201..353aa655 100644 --- a/app/cabinet/routes/landing.py +++ b/app/cabinet/routes/landing.py @@ -102,7 +102,7 @@ class LandingConfigResponse(BaseModel): _EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$') -_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{3,31}$') +_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$') def _validate_contact(contact_type: str, contact_value: str) -> None: @@ -153,6 +153,8 @@ class PurchaseStatusResponse(BaseModel): cabinet_email: str | None = None cabinet_password: str | None = None auto_login_token: str | None = None + recipient_in_bot: bool | None = None + bot_link: str | None = None # ============ Helpers ============ @@ -221,6 +223,16 @@ def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusRe cabinet_password = purchase.cabinet_password auto_login_token = purchase.auto_login_token + # For telegram gifts: indicate whether recipient is known to the bot + recipient_in_bot: bool | None = None + bot_link: str | None = None + if purchase.is_gift and effective_contact_type == 'telegram': + recipient_in_bot = purchase.user is not None and purchase.user.telegram_id is not None + if not recipient_in_bot: + bot_username = settings.get_bot_username() + if bot_username: + bot_link = f'https://t.me/{bot_username}' + return PurchaseStatusResponse( status=purchase.status, subscription_url=subscription_url, @@ -235,6 +247,8 @@ def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusRe cabinet_email=cabinet_email, cabinet_password=cabinet_password, auto_login_token=auto_login_token, + recipient_in_bot=recipient_in_bot, + bot_link=bot_link, ) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 9c8fd0fb..4f4be174 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -122,6 +122,30 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> User | return user +async def find_phantom_user_by_username(db: AsyncSession, username: str) -> User | None: + """Find a phantom user created by guest purchase (no telegram_id, auth_type=telegram). + + Used during /start to reconcile phantom users with real Telegram accounts. + """ + if not username: + return None + + normalized = username.lower() + result = await db.execute( + select(User) + .options( + selectinload(User.subscription).selectinload(Subscription.tariff), + ) + .where( + User.telegram_id.is_(None), + User.auth_type == 'telegram', + func.lower(User.username) == normalized, + ) + .with_for_update() + ) + return result.scalars().first() + + async def get_user_by_username(db: AsyncSession, username: str) -> User | None: if not username: return None diff --git a/app/handlers/gift_activation.py b/app/handlers/gift_activation.py new file mode 100644 index 00000000..0216a510 --- /dev/null +++ b/app/handlers/gift_activation.py @@ -0,0 +1,95 @@ +"""Handler for gift subscription activation via inline callback button.""" + +import html as html_mod + +import structlog +from aiogram import Dispatcher, F, types +from aiogram.types import InaccessibleMessage +from sqlalchemy import select + +from app.database.database import AsyncSessionLocal +from app.database.models import GuestPurchase +from app.services.guest_purchase_service import GuestPurchaseError, activate_purchase + + +logger = structlog.get_logger(__name__) + +_GIFT_NOT_FOUND = 'Подарок не найден или недоступен.' + + +async def handle_gift_activate(callback: types.CallbackQuery) -> None: + """Handle gift_activate:{purchase_id} callback from Telegram notification.""" + if isinstance(callback.message, InaccessibleMessage): + await callback.answer('Сообщение устарело. Попробуйте /start.', show_alert=True) + return + + if not callback.data: + return + + parts = callback.data.split(':', 1) + if len(parts) != 2: + await callback.answer(_GIFT_NOT_FOUND, show_alert=True) + return + + try: + purchase_id = int(parts[1]) + except ValueError: + await callback.answer(_GIFT_NOT_FOUND, show_alert=True) + return + + await callback.answer() + await callback.message.edit_text('⏳ Активируем подарок...', parse_mode=None) + + async with AsyncSessionLocal() as db: + result = await db.execute(select(GuestPurchase).where(GuestPurchase.id == purchase_id)) + purchase = result.scalars().first() + + if not purchase or purchase.user_id is None or purchase.user is None: + await callback.message.edit_text(_GIFT_NOT_FOUND, parse_mode=None) + return + + # Verify the callback sender is the actual recipient + if purchase.user.telegram_id != callback.from_user.id: + await callback.message.edit_text(_GIFT_NOT_FOUND, parse_mode=None) + return + + # Resolve tariff info inside session (selectin-loaded relationships) + tariff_name = html_mod.escape(purchase.tariff.name) if purchase.tariff and purchase.tariff.name else '' + period_days = purchase.period_days + + try: + await activate_purchase(db, purchase.token, skip_notification=True) + except GuestPurchaseError as exc: + logger.warning( + 'Gift activation via callback failed', + purchase_id=purchase_id, + telegram_id=callback.from_user.id, + error=exc.message, + ) + if exc.status_code >= 500: + await callback.message.edit_text('Произошла ошибка при активации. Попробуйте позже.', parse_mode=None) + else: + await callback.message.edit_text( + f'Не удалось активировать подарок: {html_mod.escape(exc.message)}', + parse_mode=None, + ) + return + except Exception: + logger.exception( + 'Unexpected error during gift activation via callback', + purchase_id=purchase_id, + telegram_id=callback.from_user.id, + ) + await callback.message.edit_text('Произошла ошибка при активации. Попробуйте позже.', parse_mode=None) + return + + period_text = f'{period_days} дн.' if period_days else '' + tariff_text = f'{tariff_name} — {period_text}' if tariff_name else period_text + + await callback.message.edit_text( + f'✅ Подарок активирован!\n{tariff_text}\n\nВаша подписка обновлена.', + ) + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register(handle_gift_activate, F.data.startswith('gift_activate:')) diff --git a/app/handlers/start.py b/app/handlers/start.py index fb00bef1..241468d8 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -5,6 +5,7 @@ from aiogram import Bot, Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.filters import Command, StateFilter from aiogram.fsm.context import FSMContext +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -15,6 +16,7 @@ from app.database.crud.campaign import ( from app.database.crud.subscription import decrement_subscription_server_counts from app.database.crud.user import ( create_user, + find_phantom_user_by_username, get_user_by_referral_code, get_user_by_telegram_id, ) @@ -58,6 +60,73 @@ from app.utils.user_utils import generate_unique_referral_code logger = structlog.get_logger(__name__) +async def _claim_phantom_user( + db: AsyncSession, + phantom: 'User', + *, + telegram_id: int, + username: str | None, + first_name: str | None, + last_name: str | None, + language: str, + referrer_id: int | None, +) -> tuple[bool, 'User | None']: + """Claim a phantom user by backfilling Telegram profile data. + + Returns (success, user). On IntegrityError falls back to existing user lookup. + + Note: Phantom users created when Bot.get_chat() fails at purchase time are matched + by username only. Since Telegram usernames are changeable and reassignable, this is + inherently vulnerable to username change attacks. When Bot.get_chat() succeeds at + purchase time, telegram_id is stored on the user and the phantom path is not used. + """ + from app.utils.validators import sanitize_telegram_name + + phantom.telegram_id = telegram_id + phantom.username = username + phantom.first_name = sanitize_telegram_name(first_name) + phantom.last_name = sanitize_telegram_name(last_name) + phantom.language = language + phantom.status = UserStatus.ACTIVE.value + if referrer_id and referrer_id != phantom.id: + phantom.referred_by_id = referrer_id + if not phantom.referral_code: + phantom.referral_code = await generate_unique_referral_code(db, telegram_id) + phantom.updated_at = datetime.now(UTC) + phantom.last_activity = datetime.now(UTC) + try: + await db.commit() + except IntegrityError: + await db.rollback() + logger.warning( + 'IntegrityError claiming phantom user, falling back to existing user lookup', + phantom_user_id=phantom.id, + telegram_id=telegram_id, + ) + existing = await get_user_by_telegram_id(db, telegram_id) + return False, existing + await db.refresh(phantom, ['subscription']) + logger.info( + 'Claimed phantom user from guest purchase', + phantom_user_id=phantom.id, + telegram_id=telegram_id, + ) + + # Sync Remnawave panel with updated user data (telegram_id, username, etc.) + if phantom.subscription: + try: + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, phantom.subscription) + except Exception as exc: + logger.warning( + 'Failed to update Remnawave panel after phantom claim', + phantom_user_id=phantom.id, + error=str(exc), + ) + + return True, phantom + + def _calculate_subscription_flags(subscription): if not subscription: return False, False @@ -1199,21 +1268,50 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta logger.info('✅ Пользователь восстановлен', from_user_id=callback.from_user.id) elif not existing_user: - logger.info('🆕 Создаем нового пользователя', from_user_id=callback.from_user.id) - - referral_code = await generate_unique_referral_code(db, callback.from_user.id) - - user = await create_user( - db=db, - telegram_id=callback.from_user.id, - username=callback.from_user.username, - first_name=callback.from_user.first_name, - last_name=callback.from_user.last_name, - language=language, - referred_by_id=referrer_id, - referral_code=referral_code, + # Check for phantom user created by guest purchase (gift by @username) + phantom = ( + await find_phantom_user_by_username(db, callback.from_user.username) + if callback.from_user.username + else None ) - await db.refresh(user, ['subscription']) + if phantom: + claimed, user = await _claim_phantom_user( + db, + phantom, + telegram_id=callback.from_user.id, + username=callback.from_user.username, + first_name=callback.from_user.first_name, + last_name=callback.from_user.last_name, + language=language, + referrer_id=referrer_id, + ) + if not claimed and user: + # IntegrityError fallback — use existing user + await db.refresh(user, ['subscription']) + elif not claimed: + logger.critical( + 'Phantom claim failed with no fallback user, proceeding to normal registration', + telegram_id=callback.from_user.id, + phantom_user_id=phantom.id, + ) + phantom = None + + if not phantom: + logger.info('🆕 Создаем нового пользователя', from_user_id=callback.from_user.id) + + referral_code = await generate_unique_referral_code(db, callback.from_user.id) + + user = await create_user( + db=db, + telegram_id=callback.from_user.id, + username=callback.from_user.username, + first_name=callback.from_user.first_name, + last_name=callback.from_user.last_name, + language=language, + referred_by_id=referrer_id, + referral_code=referral_code, + ) + await db.refresh(user, ['subscription']) else: logger.info('🔄 Обновляем существующего пользователя', from_user_id=callback.from_user.id) existing_user.status = UserStatus.ACTIVE.value @@ -1450,21 +1548,47 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A logger.info('✅ Пользователь восстановлен', from_user_id=message.from_user.id) elif not existing_user: - logger.info('🆕 Создаем нового пользователя', from_user_id=message.from_user.id) - - referral_code = await generate_unique_referral_code(db, message.from_user.id) - - user = await create_user( - db=db, - telegram_id=message.from_user.id, - username=message.from_user.username, - first_name=message.from_user.first_name, - last_name=message.from_user.last_name, - language=language, - referred_by_id=referrer_id, - referral_code=referral_code, + # Check for phantom user created by guest purchase (gift by @username) + phantom = ( + await find_phantom_user_by_username(db, message.from_user.username) if message.from_user.username else None ) - await db.refresh(user, ['subscription']) + if phantom: + claimed, user = await _claim_phantom_user( + db, + phantom, + telegram_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name, + language=language, + referrer_id=referrer_id, + ) + if not claimed and user: + await db.refresh(user, ['subscription']) + elif not claimed: + logger.critical( + 'Phantom claim failed with no fallback user, proceeding to normal registration', + telegram_id=message.from_user.id, + phantom_user_id=phantom.id, + ) + phantom = None + + if not phantom: + logger.info('🆕 Создаем нового пользователя', from_user_id=message.from_user.id) + + referral_code = await generate_unique_referral_code(db, message.from_user.id) + + user = await create_user( + db=db, + telegram_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name, + language=language, + referred_by_id=referrer_id, + referral_code=referral_code, + ) + await db.refresh(user, ['subscription']) else: logger.info('🔄 Обновляем существующего пользователя', from_user_id=message.from_user.id) existing_user.status = UserStatus.ACTIVE.value @@ -2014,19 +2138,47 @@ async def required_sub_channel_check( referrer_id = referrer.id logger.info('✅ CHANNEL CHECK: Реферер найден из ссылки', referrer_id=referrer.id) - referral_code = await generate_unique_referral_code(db, query.from_user.id) - - user = await create_user( - db=db, - telegram_id=query.from_user.id, - username=query.from_user.username, - first_name=query.from_user.first_name, - last_name=query.from_user.last_name, - language=language, - referral_code=referral_code, - referred_by_id=referrer_id, + # Check for phantom user created by guest purchase (gift by @username) + phantom = ( + await find_phantom_user_by_username(db, query.from_user.username) + if query.from_user.username + else None ) - await db.refresh(user, ['subscription']) + if phantom: + claimed, user = await _claim_phantom_user( + db, + phantom, + telegram_id=query.from_user.id, + username=query.from_user.username, + first_name=query.from_user.first_name, + last_name=query.from_user.last_name, + language=language, + referrer_id=referrer_id, + ) + if not claimed and user: + await db.refresh(user, ['subscription']) + elif not claimed: + logger.critical( + 'Phantom claim failed with no fallback user, proceeding to normal registration', + telegram_id=query.from_user.id, + phantom_user_id=phantom.id, + ) + phantom = None + + if not phantom: + referral_code = await generate_unique_referral_code(db, query.from_user.id) + + user = await create_user( + db=db, + telegram_id=query.from_user.id, + username=query.from_user.username, + first_name=query.from_user.first_name, + last_name=query.from_user.last_name, + language=language, + referral_code=referral_code, + referred_by_id=referrer_id, + ) + await db.refresh(user, ['subscription']) # ИСПРАВЛЕНИЕ БАГА: Очищаем pending_start_payload из state после создания пользователя state_data.pop('pending_start_payload', None) diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py index 879a3391..2604feaa 100644 --- a/app/services/guest_purchase_service.py +++ b/app/services/guest_purchase_service.py @@ -1,12 +1,13 @@ """Service for guest (unauthenticated) purchases via landing pages.""" import asyncio +import re import secrets from datetime import UTC, datetime from typing import Literal import structlog -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -22,6 +23,8 @@ from app.services.subscription_service import SubscriptionService logger = structlog.get_logger(__name__) +_TELEGRAM_USERNAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_]{4,31}$') + async def _send_admin_notification( purchase: GuestPurchase, @@ -429,30 +432,86 @@ async def _find_or_create_user( if contact_type != 'telegram': raise GuestPurchaseError(f'Unsupported contact type: {contact_type}', status_code=500) - username = contact_value.lstrip('@').lower() - result = await db.execute( - select(User).where(User.username == username), - ) - user = result.scalars().first() + username = contact_value.lstrip('@') + if not _TELEGRAM_USERNAME_RE.match(username): + raise GuestPurchaseError('Invalid Telegram username format', status_code=400) + normalized = username.lower() + + # Try to resolve telegram_id via Bot API (works if user has interacted with the bot) + resolved_telegram_id: int | None = None + try: + from aiogram import Bot + + async with Bot(token=settings.BOT_TOKEN) as bot: + chat = await asyncio.wait_for( + bot.get_chat(chat_id=f'@{username}'), + timeout=5.0, + ) + resolved_telegram_id = chat.id + # Use the canonical username from Telegram if available + if chat.username: + username = chat.username + normalized = username.lower() + except Exception as exc: + logger.debug('Could not resolve telegram_id for username', username=username, error=str(exc)) + + # Search by telegram_id first (most reliable), then by username (case-insensitive) + user = None + if resolved_telegram_id: + result = await db.execute( + select(User).where(User.telegram_id == resolved_telegram_id), + ) + user = result.scalars().first() + + if not user: + result = await db.execute( + select(User).where(func.lower(User.username) == normalized), + ) + user = result.scalars().first() + if user: + # Backfill telegram_id if we resolved it and user doesn't have it yet + if resolved_telegram_id and not user.telegram_id: + try: + async with db.begin_nested(): + user.telegram_id = resolved_telegram_id + await db.flush() + except IntegrityError: + logger.warning( + 'Could not backfill telegram_id (unique constraint)', + user_id=user.id, + resolved_telegram_id=resolved_telegram_id, + ) + await db.refresh(user) return user, False - # Create new telegram user (without telegram_id — will be linked later) + # Create new telegram user user = User( auth_type='telegram', username=username, + telegram_id=resolved_telegram_id, ) try: async with db.begin_nested(): db.add(user) await db.flush() except IntegrityError: - result = await db.execute(select(User).where(User.username == username)) + if resolved_telegram_id: + result = await db.execute(select(User).where(User.telegram_id == resolved_telegram_id)) + user = result.scalars().first() + if user: + return user, False + result = await db.execute(select(User).where(func.lower(User.username) == normalized)) user = result.scalars().first() if user: return user, False raise - logger.info('Created new telegram user for guest purchase', user_id=user.id, username=username) + logger.info( + 'Created new telegram user for guest purchase', + user_id=user.id, + username=username, + has_telegram_id=resolved_telegram_id is not None, + ) return user, False @@ -463,6 +522,82 @@ def _get_recipient_contact(purchase: GuestPurchase) -> tuple[str, str]: return purchase.contact_type, purchase.contact_value +async def _send_telegram_gift_notification( + purchase: GuestPurchase, + *, + is_pending_activation: bool = False, + tariff_name: str = '', +) -> None: + """Send Telegram bot message to gift recipient if they have a telegram_id.""" + if not settings.BOT_TOKEN: + return + user = purchase.user + if not user or not user.telegram_id: + return + + try: + import html as html_mod + + from aiogram import Bot + from aiogram.client.default import DefaultBotProperties + from aiogram.enums import ParseMode + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + gift_from = '' + if purchase.contact_value: + safe_name = html_mod.escape(purchase.contact_value) + gift_from = f'\nОт: {safe_name}' + + gift_msg = '' + if purchase.gift_message: + safe_msg = html_mod.escape(purchase.gift_message) + gift_msg = f'\n\n"{safe_msg}"' + + safe_tariff = html_mod.escape(tariff_name) if tariff_name else '' + period_text = f'{purchase.period_days} дн.' if purchase.period_days else '' + tariff_text = f'{safe_tariff} — {period_text}' if safe_tariff else period_text + + text = f'🎁 Вам подарили VPN подписку!\n{tariff_text}{gift_from}{gift_msg}' + + keyboard = None + if is_pending_activation: + text += '\n\nУ вас уже есть активная подписка. Нажмите кнопку ниже, чтобы активировать подарок (текущая подписка будет заменена).' + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text='Активировать подарок', + callback_data=f'gift_activate:{purchase.id}', + ) + ] + ] + ) + + async with Bot( + token=settings.BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) as bot: + await bot.send_message( + chat_id=user.telegram_id, + text=text, + reply_markup=keyboard, + ) + + logger.info( + 'Telegram gift notification sent', + purchase_id=purchase.id, + recipient_telegram_id=user.telegram_id, + is_pending_activation=is_pending_activation, + ) + except Exception: + logger.warning( + 'Failed to send Telegram gift notification', + purchase_id=purchase.id, + recipient_telegram_id=user.telegram_id if user else None, + exc_info=True, + ) + + async def send_guest_notification( purchase: GuestPurchase, *, @@ -471,9 +606,11 @@ async def send_guest_notification( language: str = 'ru', is_new_account: bool = False, ) -> None: - """Send email notification for guest purchase delivery or activation requirement. + """Send notification for guest purchase delivery or activation requirement. - For telegram contacts, no notification is sent (success page only). + For telegram gift contacts, sends a Telegram bot message to the recipient. + For telegram non-gift contacts, no notification is sent (success page only). + For email contacts, sends an email notification. For gifts, notification goes to the recipient, not the buyer. Args: @@ -488,8 +625,16 @@ async def send_guest_notification( from app.cabinet.services.email_templates import EmailNotificationTemplates from app.services.notification_delivery_service import NotificationType - recipient_type, recipient_email = _get_recipient_contact(purchase) + recipient_type, recipient_value = _get_recipient_contact(purchase) + if recipient_type == 'telegram': + if purchase.is_gift: + await _send_telegram_gift_notification( + purchase, is_pending_activation=is_pending_activation, tariff_name=tariff_name + ) + return + + recipient_email = recipient_value if recipient_type != 'email': return @@ -600,7 +745,7 @@ async def send_guest_notification( logger.warning('Failed to send cabinet credentials email', purchase_id=purchase.id) -async def activate_purchase(db: AsyncSession, purchase_token: str) -> GuestPurchase: +async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notification: bool = False) -> GuestPurchase: """Activate a PENDING_ACTIVATION purchase by replacing or creating a subscription. Uses SELECT ... FOR UPDATE to prevent concurrent activation. @@ -689,16 +834,17 @@ async def activate_purchase(db: AsyncSession, purchase_token: str) -> GuestPurch await db.commit() await db.refresh(purchase, attribute_names=['landing']) - try: - await send_guest_notification( - purchase, - is_pending_activation=False, - tariff_name=notification_tariff_name, - language=notification_language, - is_new_account=is_new_account, - ) - except Exception: - logger.exception('Failed to send delivery notification after activation', purchase_id=purchase.id) + if not skip_notification: + try: + await send_guest_notification( + purchase, + is_pending_activation=False, + tariff_name=notification_tariff_name, + language=notification_language, + is_new_account=is_new_account, + ) + except Exception: + logger.exception('Failed to send delivery notification after activation', purchase_id=purchase.id) await _send_admin_notification(purchase, notification_tariff_name, is_pending_activation=False) diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index e7062399..e2ba9011 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -248,7 +248,8 @@ class SubscriptionService: expire_at=subscription.end_date, traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff), - email=user.email, # Обновляем email в панели RemnaWave + telegram_id=user.telegram_id, + email=user.email, description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, @@ -403,7 +404,8 @@ class SubscriptionService: expire_at=subscription.end_date, traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff), - email=user.email, # Обновляем email в панели RemnaWave + telegram_id=user.telegram_id, + email=user.email, description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, From 330d1cb6fe2eee81a3e8f841de75d41e8b4cde40 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Mar 2026 12:47:25 +0300 Subject: [PATCH 2/8] fix: gift purchase notification and activation flow - Refresh purchase.user relationship after setting user_id to fix stale None value that prevented Telegram gift notifications - Route gift purchases with expired subscriptions through PENDING_ACTIVATION instead of auto-activating - Hide subscription URL from gift buyer in API response --- app/cabinet/routes/landing.py | 2 +- app/services/guest_purchase_service.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/cabinet/routes/landing.py b/app/cabinet/routes/landing.py index 353aa655..85316129 100644 --- a/app/cabinet/routes/landing.py +++ b/app/cabinet/routes/landing.py @@ -182,7 +182,7 @@ def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusRe within_ttl = False subscription_url = None subscription_crypto_link = None - if purchase.delivered_at and purchase.subscription_url: + if purchase.delivered_at and purchase.subscription_url and not purchase.is_gift: age = datetime.now(UTC) - purchase.delivered_at if age < timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS): within_ttl = True diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py index 2604feaa..4aaf3984 100644 --- a/app/services/guest_purchase_service.py +++ b/app/services/guest_purchase_service.py @@ -219,14 +219,14 @@ async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurcha # Check if user already has a subscription existing_subscription = await get_subscription_by_user_id(db, user.id) - if existing_subscription is not None and existing_subscription.is_active: - # Active subscription — hold for manual activation + if existing_subscription is not None and (existing_subscription.is_active or purchase.is_gift): + # Active subscription or gift with any existing subscription — hold for manual activation purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value purchase.user_id = user.id if recipient_type == 'email' and not purchase.is_gift: purchase.auto_login_token = create_auto_login_token(user.id) await db.commit() - await db.refresh(purchase, attribute_names=['landing']) + await db.refresh(purchase, attribute_names=['landing', 'user']) try: await send_guest_notification( @@ -295,7 +295,7 @@ async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurcha purchase.auto_login_token = create_auto_login_token(user.id) await db.commit() - await db.refresh(purchase, attribute_names=['landing']) + await db.refresh(purchase, attribute_names=['landing', 'user']) try: await send_guest_notification( @@ -832,7 +832,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif if user.auth_type == 'email' and not purchase.is_gift: purchase.auto_login_token = create_auto_login_token(user.id) await db.commit() - await db.refresh(purchase, attribute_names=['landing']) + await db.refresh(purchase, attribute_names=['landing', 'user']) if not skip_notification: try: From 1f664a9083d81bc4462c30bf0626a44b5a30f03e Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Mar 2026 13:05:52 +0300 Subject: [PATCH 3/8] fix: remove is_active_paid_subscription guard from admin deactivation The guard silently blocked admins from deactivating active paid subscriptions, returning a generic error with no explanation. Admin deactivation is intentional (with confirmation step) and should not be prevented. The guard remains in automated processes (monitoring, broadcast, user_service) where it makes sense. --- app/handlers/admin/users.py | 8 -------- app/webapi/routes/subscriptions.py | 10 ---------- app/webapi/routes/users.py | 10 ---------- 3 files changed, 28 deletions(-) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 1ab69c26..fe4aa812 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -4023,7 +4023,6 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id from app.database.crud.subscription import ( deactivate_subscription, get_subscription_by_user_id, - is_active_paid_subscription, ) from app.services.subscription_service import SubscriptionService @@ -4032,13 +4031,6 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id logger.error('Подписка не найдена для пользователя', user_id=user_id) return False - if is_active_paid_subscription(subscription): - logger.info( - '⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка', - user_id=user_id, - ) - return False - await deactivate_subscription(db, subscription) user = await get_user_by_id(db, user_id) diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py index 87c89bdd..e13f65a2 100644 --- a/app/webapi/routes/subscriptions.py +++ b/app/webapi/routes/subscriptions.py @@ -329,16 +329,6 @@ async def delete_subscription( """ subscription = await _get_subscription(db, subscription_id) - from app.database.crud.subscription import is_active_paid_subscription - - if is_active_paid_subscription(subscription): - logger.info( - '⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка', - subscription_id=subscription_id, - ) - subscription = await _get_subscription(db, subscription.id) - return _serialize_subscription(subscription) - await deactivate_subscription(db, subscription) # Деактивируем пользователя в RemnaWave, если есть UUID diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py index 1a760daa..e5c7c28e 100644 --- a/app/webapi/routes/users.py +++ b/app/webapi/routes/users.py @@ -453,20 +453,10 @@ async def delete_user_subscription( """ user = await _get_user_by_id_or_telegram_id(db, user_id) - from app.database.crud.subscription import is_active_paid_subscription - subscription = await get_subscription_by_user_id(db, user.id) if not subscription: raise HTTPException(status.HTTP_404_NOT_FOUND, 'User has no subscription') - if is_active_paid_subscription(subscription): - logger.info( - '⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка', - user_id=user.id, - ) - user = await get_user_by_id(db, user.id) - return _serialize_user(user) - await deactivate_subscription(db, subscription) # Деактивируем пользователя в RemnaWave, если есть UUID From f4eeb9a503d6da8a152f8cb60b89f7ebbdf41c4a Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Mar 2026 14:17:44 +0300 Subject: [PATCH 4/8] fix: multiple payment and notification bugs - CloudPayments: add missing process_referral_topup, has_made_first_topup flag, and admin notification (matching other adapters) - Promocode: handle TelegramBadRequest for broadcast messages without text (fallback to answer()) - Devices: unify price prorating to day-based calculation (matching cabinet behavior) - Auth: pass Bot instance to process_referral_registration for campaign referral notifications - Wata: remove WATA_TERMINAL_PUBLIC_ID from is_wata_enabled() (not used in API calls), make type field conditional, add transactionId webhook fallback --- app/cabinet/routes/auth.py | 21 +++++++++-- app/config.py | 2 +- app/handlers/promocode.py | 12 ++++++- app/handlers/subscription/devices.py | 37 ++++++++++--------- app/services/payment/cloudpayments.py | 52 +++++++++++++++++++++++++++ app/services/payment/wata.py | 2 +- app/services/wata_service.py | 4 ++- 7 files changed, 104 insertions(+), 26 deletions(-) diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index fc40830d..8c8c4e53 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -188,7 +188,12 @@ async def _process_campaign_bonus( user.referred_by_id = campaign.partner_user_id await db.flush() try: - await process_referral_registration(db, user.id, campaign.partner_user_id, bot=None) + from aiogram import Bot + from aiogram.client.default import DefaultBotProperties + from aiogram.enums import ParseMode + + bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot) logger.info( 'Referral set from campaign partner', user_id=user.id, @@ -242,7 +247,12 @@ async def _process_referral_code( return user.referred_by_id = referrer.id await db.flush() - await process_referral_registration(db, user.id, referrer.id, bot=None) + from aiogram import Bot + from aiogram.client.default import DefaultBotProperties + from aiogram.enums import ParseMode + + bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + await process_referral_registration(db, user.id, referrer.id, bot=bot) logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code) except Exception as e: logger.error('Failed to process referral code', error=e, referral_code=referral_code) @@ -917,7 +927,12 @@ async def register_email_standalone( # Обработать реферальную регистрацию (если есть реферер) if referrer: try: - await process_referral_registration(db, user.id, referrer.id, bot=None) + from aiogram import Bot + from aiogram.client.default import DefaultBotProperties + from aiogram.enums import ParseMode + + bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + await process_referral_registration(db, user.id, referrer.id, bot=bot) logger.info( 'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id ) diff --git a/app/config.py b/app/config.py index 8ee4281a..dfaff11a 100644 --- a/app/config.py +++ b/app/config.py @@ -1738,7 +1738,7 @@ class Settings(BaseSettings): return info.get('title') or info.get('name') or f'Platega {method_code}' def is_wata_enabled(self) -> bool: - return self.WATA_ENABLED and self.WATA_ACCESS_TOKEN is not None and self.WATA_TERMINAL_PUBLIC_ID is not None + return self.WATA_ENABLED and self.WATA_ACCESS_TOKEN is not None def get_wata_display_name(self) -> str: name = (self.WATA_DISPLAY_NAME or '').strip() diff --git a/app/handlers/promocode.py b/app/handlers/promocode.py index 3055a4f2..30132006 100644 --- a/app/handlers/promocode.py +++ b/app/handlers/promocode.py @@ -1,5 +1,6 @@ import structlog from aiogram import Bot, Dispatcher, F, types +from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.types import InaccessibleMessage from sqlalchemy.ext.asyncio import AsyncSession @@ -24,7 +25,16 @@ async def show_promocode_menu(callback: types.CallbackQuery, db_user: User, stat if isinstance(callback.message, InaccessibleMessage): await callback.message.answer(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language)) else: - await callback.message.edit_text(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language)) + try: + await callback.message.edit_text(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language)) + except TelegramBadRequest as error: + error_message = str(error).lower() + if 'there is no text in the message to edit' in error_message: + await callback.message.answer( + texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language) + ) + else: + raise await state.set_state(PromoCodeStates.waiting_for_code) await callback.answer() diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index 9ac70761..2732409b 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -27,7 +27,6 @@ from app.services.user_cart_service import user_cart_service from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( apply_percentage_discount, - calculate_prorated_price, get_remaining_months, ) from app.utils.subscription_utils import ( @@ -339,9 +338,10 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d total_discount = int(discount_per_month * days_left / 30) period_label = f'{days_left} дн.' if days_left > 1 else '1 день' else: - # Для обычных тарифов - по месяцам - months_hint = get_remaining_months(subscription.end_date) - period_hint_days = months_hint * 30 if months_hint > 0 else None + # Для обычных тарифов - по дням (как в кабинете) + now = datetime.now(UTC) + days_left = max(1, (subscription.end_date - now).days) + period_hint_days = days_left devices_discount_percent = _get_addon_discount_percent_for_user( db_user, @@ -352,12 +352,11 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d devices_price_per_month, devices_discount_percent, ) - price, charged_months = calculate_prorated_price( - discounted_per_month, - subscription.end_date, - ) - total_discount = discount_per_month * charged_months - period_label = f'{charged_months} мес' + # Цена = месячная_цена * days_left / 30 + price = int(discounted_per_month * days_left / 30) + price = max(100, price) # Минимум 1 рубль + total_discount = int(discount_per_month * days_left / 30) + period_label = f'{days_left} дн.' if days_left > 1 else '1 день' if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks @@ -1161,9 +1160,10 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: total_discount = int(discount_per_month * days_left / 30) period_label = f'{days_left} дн.' if days_left > 1 else '1 день' else: - # Для обычных тарифов - по месяцам - months_hint = get_remaining_months(subscription.end_date) - period_hint_days = months_hint * 30 if months_hint > 0 else None + # Для обычных тарифов - по дням (как в кабинете) + now = datetime.now(UTC) + days_left = max(1, (subscription.end_date - now).days) + period_hint_days = days_left devices_discount_percent = _get_addon_discount_percent_for_user( db_user, @@ -1174,12 +1174,11 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: devices_price_per_month, devices_discount_percent, ) - price, charged_months = calculate_prorated_price( - discounted_per_month, - subscription.end_date, - ) - total_discount = discount_per_month * charged_months - period_label = f'{charged_months} мес' + # Цена = месячная_цена * days_left / 30 + price = int(discounted_per_month * days_left / 30) + price = max(100, price) # Минимум 1 рубль + total_discount = int(discount_per_month * days_left / 30) + period_label = f'{days_left} дн.' if days_left > 1 else '1 день' logger.info( 'Добавление устройств: ₽/мес × = ₽ (скидка ₽)', diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index 06c119b3..02642d4f 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -258,6 +258,20 @@ class CloudPaymentsPaymentMixin: logger.error('Пользователь не найден: id', user_id=payment.user_id) return False + # Загружаем промогруппы и данные для уведомлений + await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups']) + for user_promo_group in getattr(user, 'user_promo_groups', []): + await db.refresh(user_promo_group, attribute_names=['promo_group']) + + from app.utils.user_utils import format_referrer_info + + promo_group = user.get_primary_promo_group() + subscription = getattr(user, 'subscription', None) + referrer_info = format_referrer_info(user) + + old_balance = user.balance_kopeks + was_first_topup = not user.has_made_first_topup + # Credit balance directly (not via add_user_balance which commits) user.balance_kopeks += amount_kopeks user.updated_at = datetime.now(UTC) @@ -313,6 +327,44 @@ class CloudPaymentsPaymentMixin: except Exception as error: logger.exception('Ошибка отправки уведомления CloudPayments', error=error) + # Начисляем реферальную комиссию + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup( + db, + user.id, + amount_kopeks, + getattr(self, 'bot', None), + ) + except Exception as error: + logger.error('Ошибка обработки реферального пополнения CloudPayments', error=error) + + if was_first_topup and not user.has_made_first_topup: + user.has_made_first_topup = True + await db.commit() + await db.refresh(user) + + topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение' + + if getattr(self, 'bot', None): + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + user, + transaction, + old_balance, + topup_status=topup_status, + referrer_info=referrer_info, + subscription=subscription, + promo_group=promo_group, + db=db, + ) + except Exception as error: + logger.error('Ошибка отправки админ уведомления CloudPayments', error=error) + # Автопокупка из сохранённой корзины и уведомление о корзине try: from app.services.payment.common import send_cart_notification_after_topup diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index d49d8198..af303430 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -194,7 +194,7 @@ class WataPaymentMixin: return False order_id_raw = payload.get('orderId') - payment_link_raw = payload.get('paymentLinkId') or payload.get('id') + payment_link_raw = payload.get('paymentLinkId') or payload.get('id') or payload.get('transactionId') transaction_status_raw = payload.get('transactionStatus') order_id = str(order_id_raw) if order_id_raw else None diff --git a/app/services/wata_service.py b/app/services/wata_service.py index be1e60a2..bde1c3f5 100644 --- a/app/services/wata_service.py +++ b/app/services/wata_service.py @@ -180,7 +180,9 @@ class WataService: 'orderId': order_id, } - payload['type'] = link_type or settings.WATA_PAYMENT_TYPE or 'OneTime' + payment_type = link_type or settings.WATA_PAYMENT_TYPE + if payment_type: + payload['type'] = payment_type if success_url or settings.WATA_SUCCESS_REDIRECT_URL: payload['successRedirectUrl'] = success_url or settings.WATA_SUCCESS_REDIRECT_URL From 20727b1017457769feccccf83e99523c19026a7e Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Mar 2026 14:25:54 +0300 Subject: [PATCH 5/8] fix: respect send_before_menu flag for pinned messages during new user registration All 6 registration paths now check pinned_message.send_before_menu to send pinned message before or after the menu, matching the existing user flow behavior. --- app/handlers/start.py | 54 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/app/handlers/start.py b/app/handlers/start.py index 241468d8..6576e7f1 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -1205,6 +1205,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta subscription_is_active=subscription_is_active, ) + pinned_message = await get_active_pinned_message(db) try: keyboard = await get_main_menu_keyboard_async( db=db, @@ -1219,8 +1220,11 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta is_moderator=is_moderator, custom_buttons=custom_buttons, ) + if pinned_message and pinned_message.send_before_menu: + await _send_pinned_message(callback.bot, db, existing_user, pinned_message) await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML') - await _send_pinned_message(callback.bot, db, existing_user) + if pinned_message and not pinned_message.send_before_menu: + await _send_pinned_message(callback.bot, db, existing_user, pinned_message) except Exception as e: logger.error('Ошибка при показе главного меню существующему пользователю', error=e) await callback.message.answer( @@ -1371,15 +1375,19 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, callback.from_user) + pinned_message = await get_active_pinned_message(db) if offer_text: try: + if pinned_message and pinned_message.send_before_menu: + await _send_pinned_message(callback.bot, db, user, pinned_message) await callback.message.answer( offer_text, reply_markup=get_post_registration_keyboard(user.language), ) logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id) - await _send_pinned_message(callback.bot, db, user) + if pinned_message and not pinned_message.send_before_menu: + await _send_pinned_message(callback.bot, db, user, pinned_message) except TelegramBadRequest as e: if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower(): logger.warning('HTML parse error в приветственном сообщении, повтор без parse_mode', error=e) @@ -1389,7 +1397,8 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta reply_markup=get_post_registration_keyboard(user.language), parse_mode=None, ) - await _send_pinned_message(callback.bot, db, user) + if pinned_message and not pinned_message.send_before_menu: + await _send_pinned_message(callback.bot, db, user, pinned_message) except Exception as fallback_err: logger.error('Ошибка при повторной отправке приветственного сообщения', fallback_err=fallback_err) else: @@ -1434,8 +1443,11 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta is_moderator=is_moderator, custom_buttons=custom_buttons, ) + if pinned_message and pinned_message.send_before_menu: + await _send_pinned_message(callback.bot, db, user, pinned_message) await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML') - await _send_pinned_message(callback.bot, db, user) + if pinned_message and not pinned_message.send_before_menu: + await _send_pinned_message(callback.bot, db, user, pinned_message) logger.info('✅ Главное меню показано пользователю', telegram_id=user.telegram_id) except Exception as e: logger.error('Ошибка при показе главного меню', error=e) @@ -1485,6 +1497,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A subscription_is_active=subscription_is_active, ) + pinned_message = await get_active_pinned_message(db) try: keyboard = await get_main_menu_keyboard_async( db=db, @@ -1499,8 +1512,11 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A is_moderator=is_moderator, custom_buttons=custom_buttons, ) + if pinned_message and pinned_message.send_before_menu: + await _send_pinned_message(message.bot, db, existing_user, pinned_message) await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML') - await _send_pinned_message(message.bot, db, existing_user) + if pinned_message and not pinned_message.send_before_menu: + await _send_pinned_message(message.bot, db, existing_user, pinned_message) except Exception as e: logger.error('Ошибка при показе главного меню существующему пользователю', error=e) await message.answer( @@ -1677,6 +1693,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, message.from_user) + pinned_message = await get_active_pinned_message(db) if offer_text: try: @@ -1687,12 +1704,15 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A else: keyboard = get_post_registration_keyboard(user.language) + if pinned_message and pinned_message.send_before_menu: + await _send_pinned_message(message.bot, db, user, pinned_message) await message.answer( offer_text, reply_markup=keyboard, ) logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id) - await _send_pinned_message(message.bot, db, user) + if pinned_message and not pinned_message.send_before_menu: + await _send_pinned_message(message.bot, db, user, pinned_message) except TelegramBadRequest as e: if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower(): logger.warning('HTML parse error в приветственном сообщении, повтор без parse_mode', error=e) @@ -1702,7 +1722,8 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A reply_markup=keyboard, parse_mode=None, ) - await _send_pinned_message(message.bot, db, user) + if pinned_message and not pinned_message.send_before_menu: + await _send_pinned_message(message.bot, db, user, pinned_message) except Exception as fallback_err: logger.error('Ошибка при повторной отправке приветственного сообщения', fallback_err=fallback_err) else: @@ -1747,9 +1768,12 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A is_moderator=is_moderator, custom_buttons=custom_buttons, ) + if pinned_message and pinned_message.send_before_menu: + await _send_pinned_message(message.bot, db, user, pinned_message) await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML') logger.info('✅ Главное меню показано пользователю', telegram_id=user.telegram_id) - await _send_pinned_message(message.bot, db, user) + if pinned_message and not pinned_message.send_before_menu: + await _send_pinned_message(message.bot, db, user, pinned_message) except Exception as e: logger.error('Ошибка при показе главного меню', error=e) await message.answer( @@ -2101,6 +2125,10 @@ async def required_sub_channel_check( custom_buttons=custom_buttons, ) + pinned_message = await get_active_pinned_message(db) + if pinned_message and pinned_message.send_before_menu: + await _send_pinned_message(bot, db, user, pinned_message) + if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900: _result = await bot.send_photo( chat_id=query.from_user.id, @@ -2117,7 +2145,8 @@ async def required_sub_channel_check( reply_markup=keyboard, parse_mode='HTML', ) - await _send_pinned_message(bot, db, user) + if pinned_message and not pinned_message.send_before_menu: + await _send_pinned_message(bot, db, user, pinned_message) else: from app.keyboards.inline import get_rules_keyboard @@ -2222,6 +2251,10 @@ async def required_sub_channel_check( custom_buttons=custom_buttons, ) + pinned_message = await get_active_pinned_message(db) + if pinned_message and pinned_message.send_before_menu: + await _send_pinned_message(bot, db, user, pinned_message) + if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900: _result = await bot.send_photo( chat_id=query.from_user.id, @@ -2238,7 +2271,8 @@ async def required_sub_channel_check( reply_markup=keyboard, parse_mode='HTML', ) - await _send_pinned_message(bot, db, user) + if pinned_message and not pinned_message.send_before_menu: + await _send_pinned_message(bot, db, user, pinned_message) else: await bot.send_message( chat_id=query.from_user.id, From 5ebe1072c9c8a1dcb6ee4cbbea2dc55211b534c4 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Mar 2026 14:58:17 +0300 Subject: [PATCH 6/8] fix: quick topup buttons include device/server/traffic costs, broadcast button crash on media messages - Quick amount buttons now calculate full renewal cost (base + devices + servers + traffic with discounts) - Tariff mode uses tariff-specific device pricing (device_price_kopeks, device_limit) - Broadcast inline buttons no longer crash with "no text in message to edit" on photo/video messages - Media messages are now handled in _edit_with_photo: delete old message + send new text --- app/handlers/balance/main.py | 122 ++++++++++++++++++++++++++++------- app/utils/message_patch.py | 26 ++++++++ 2 files changed, 124 insertions(+), 24 deletions(-) diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 606914dd..f2616b96 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -145,6 +145,8 @@ async def get_quick_amount_buttons(language: str, user: User) -> list: """ Generate quick amount buttons with user-specific pricing and discounts. + Includes full subscription cost: base period price + devices + servers + traffic. + Args: language: User's language for formatting user: User object to calculate personalized discounts @@ -156,25 +158,63 @@ async def get_quick_amount_buttons(language: str, user: User) -> list: return [] from app.config import PERIOD_PRICES - from app.localization.texts import get_texts + from app.database.crud.subscription import get_subscription_by_user_id + from app.database.database import AsyncSessionLocal + from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days texts = get_texts(language) - # В режиме тарифов получаем цены из тарифа пользователя + tariff = None tariff_prices = None tariff_periods = None - if settings.is_tariffs_mode(): - from app.database.crud.subscription import get_subscription_by_user_id - from app.database.crud.tariff import get_tariff_by_id - from app.database.database import AsyncSessionLocal + devices_price_per_month = 0 + servers_per_month_prices: list[int] = [] + traffic_price_per_month = 0 - async with AsyncSessionLocal() as db: - subscription = await get_subscription_by_user_id(db, user.id) - if subscription and subscription.tariff_id: - tariff = await get_tariff_by_id(db, subscription.tariff_id) - if tariff and tariff.period_prices: - tariff_prices = {int(k): v for k, v in tariff.period_prices.items()} - tariff_periods = sorted(tariff_prices.keys()) + async with AsyncSessionLocal() as db: + subscription = await get_subscription_by_user_id(db, user.id) + + # В режиме тарифов получаем цены из тарифа пользователя + if settings.is_tariffs_mode() and subscription and subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and tariff.period_prices: + tariff_prices = {int(k): v for k, v in tariff.period_prices.items()} + tariff_periods = sorted(tariff_prices.keys()) + + # Получаем стоимость устройств, серверов и трафика из подписки + if subscription and not subscription.is_trial: + # Устройства: в режиме тарифов используем цену и базовый лимит из тарифа + if settings.is_tariffs_mode() and tariff and tariff_prices: + tariff_device_price = getattr(tariff, 'device_price_kopeks', None) + if tariff_device_price and tariff_device_price > 0: + device_unit_price = tariff_device_price + base_device_limit = tariff.device_limit or 0 + else: + device_unit_price = settings.PRICE_PER_DEVICE + base_device_limit = settings.DEFAULT_DEVICE_LIMIT + else: + device_unit_price = settings.PRICE_PER_DEVICE + base_device_limit = settings.DEFAULT_DEVICE_LIMIT + + device_limit = subscription.device_limit or base_device_limit + additional_devices = max(0, device_limit - base_device_limit) + if additional_devices > 0: + devices_price_per_month = additional_devices * device_unit_price + + # Серверы + connected_squads = subscription.connected_squads or [] + if connected_squads: + from app.services.subscription_service import SubscriptionService + + subscription_service = SubscriptionService() + _, servers_per_month_prices = await subscription_service.get_countries_price_by_uuids( + connected_squads, db, promo_group_id=user.promo_group_id + ) + + # Трафик + traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) buttons = [] @@ -192,23 +232,57 @@ async def get_quick_amount_buttons(language: str, user: User) -> list: base_price_kopeks = PERIOD_PRICES.get(period, 0) if base_price_kopeks > 0: - # Calculate price with user's promo group discount using unified system + # Базовая цена периода с промо-скидками price_info = calculate_user_price(user, base_price_kopeks, period, 'period') - callback_data = f'quick_amount_{price_info.final_price}' + months = calculate_months_from_days(period) + + # Стоимость устройств со скидкой + devices_addon = 0 + if devices_price_per_month > 0: + devices_discount = user.get_promo_discount('devices', period) + devices_discounted, _ = apply_percentage_discount(devices_price_per_month, devices_discount) + devices_addon = devices_discounted * months + + # Стоимость серверов со скидкой + servers_addon = 0 + if servers_per_month_prices: + servers_discount = user.get_promo_discount('servers', period) + for server_price in servers_per_month_prices: + discounted, _ = apply_percentage_discount(server_price, servers_discount) + servers_addon += discounted + servers_addon *= months + + # Стоимость трафика со скидкой + traffic_addon = 0 + if traffic_price_per_month > 0: + traffic_discount = user.get_promo_discount('traffic', period) + traffic_discounted, _ = apply_percentage_discount(traffic_price_per_month, traffic_discount) + traffic_addon = traffic_discounted * months + + total_price = price_info.final_price + devices_addon + servers_addon + traffic_addon + callback_data = f'quick_amount_{total_price}' - # Format button text with discount display period_label = f'{period} дней' - # For balance buttons, use simpler format without emoji and period label prefix - if price_info.has_discount: - button_text = ( - f'{texts.format_price(price_info.base_price)} ➜ ' - f'{texts.format_price(price_info.final_price)} ' - f'(-{price_info.discount_percent}%) • {period_label}' - ) + # Скидка считается от полной базовой стоимости (период + аддоны без скидок) + total_base = base_price_kopeks + ( + devices_price_per_month + sum(servers_per_month_prices) + traffic_price_per_month + ) * months + has_discount = total_base > total_price and total_base > 0 + + if has_discount: + discount_pct = round((total_base - total_price) * 100 / total_base) + if discount_pct > 0: + button_text = ( + f'{texts.format_price(total_base)} ➜ ' + f'{texts.format_price(total_price)} ' + f'(-{discount_pct}%) • {period_label}' + ) + else: + button_text = f'{texts.format_price(total_price)} • {period_label}' else: - button_text = f'{texts.format_price(price_info.final_price)} • {period_label}' + button_text = f'{texts.format_price(total_price)} • {period_label}' buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data)) diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py index 19a7c846..8fd6049e 100644 --- a/app/utils/message_patch.py +++ b/app/utils/message_patch.py @@ -189,6 +189,19 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): # Уважаем флаг в рантайме: если логотип выключен — не подменяем редактирование if not settings.ENABLE_LOGO_MODE: kwargs.setdefault('disable_web_page_preview', True) + # Медиа-сообщения (фото/видео из рассылки и т.д.) не имеют text — edit_text упадёт. + # Удаляем старое сообщение и отправляем новое. + if self.text is None: + try: + await self.delete() + except TelegramBadRequest: + pass + try: + return await _original_answer(self, text, **kwargs) + except TelegramBadRequest as error: + if is_topic_required_error(error): + return None + raise return await _original_edit_text(self, text, **kwargs) if self.photo: language = _get_language(self) @@ -242,6 +255,19 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): if is_topic_required_error(inner_error): return None raise + # Не-фото медиа (видео, анимация и т.д.) с включённым логотипом — удаляем и отправляем с фото + if self.text is None: + try: + await self.delete() + except TelegramBadRequest: + pass + try: + return await _answer_with_photo(self, text, **kwargs) + except TelegramBadRequest as error: + if is_topic_required_error(error): + return None + raise + # Обработка ошибок MESSAGE_ID_INVALID для сообщений без фото try: return await _text_edit(self, text, **kwargs) From 7dc5e4ab94a415dc739a49c74cde511aad0cbb29 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Mar 2026 15:31:58 +0300 Subject: [PATCH 7/8] fix: auto-purchase classic extend missing device_limit and traffic_limit_gb - Add device_limit and traffic_limit_gb to classic extend cart data in confirm_extend_subscription handler - Add classic mode branch in cabinet renew_subscription to save device_limit and traffic_limit_gb (previously only saved for tariffs) - Ensure device_limit >= DEFAULT_DEVICE_LIMIT when converting trial subscription to paid via auto-extend - Add None guards for subscription.device_limit in both trial and non-trial branches of _apply_extension_updates --- app/cabinet/routes/subscription.py | 6 +++++- app/handlers/subscription/purchase.py | 2 ++ app/services/subscription_auto_purchase_service.py | 9 +++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 83e326a5..b3d16228 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -510,12 +510,16 @@ async def renew_subscription( 'source': 'cabinet', } - # Add tariff parameters for tariffs mode + # Add subscription parameters for auto-purchase if tariff_id: cart_data['traffic_limit_gb'] = tariff_traffic_limit_gb # Сохраняем актуальный device_limit подписки (включая докупленные устройства) cart_data['device_limit'] = user.subscription.device_limit cart_data['allowed_squads'] = tariff_allowed_squads + else: + # Classic mode: сохраняем текущие параметры подписки для корректной автопокупки + cart_data['device_limit'] = user.subscription.device_limit + cart_data['traffic_limit_gb'] = user.subscription.traffic_limit_gb try: await user_cart_service.save_user_cart(user.id, cart_data) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 508ffce1..88aa0565 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1952,6 +1952,8 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us 'return_to_cart': True, 'description': f'Продление подписки на {days} дней', 'consume_promo_offer': bool(promo_component['discount'] > 0), + 'device_limit': device_limit, + 'traffic_limit_gb': renewal_traffic_gb, } await user_cart_service.save_user_cart(db_user.id, cart_data) diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 6524ad59..56a9e748 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -346,15 +346,20 @@ def _apply_extension_updates(context: AutoExtendContext) -> None: # subscription.is_trial = False # УДАЛЕНО: преждевременное удаление триала if context.traffic_limit_gb is not None: subscription.traffic_limit_gb = context.traffic_limit_gb + # При конвертации триала device_limit должен быть не ниже DEFAULT_DEVICE_LIMIT if context.device_limit is not None: - subscription.device_limit = max(subscription.device_limit, context.device_limit) + subscription.device_limit = max( + subscription.device_limit or 0, context.device_limit, settings.DEFAULT_DEVICE_LIMIT + ) + else: + subscription.device_limit = max(subscription.device_limit or 0, settings.DEFAULT_DEVICE_LIMIT) if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []): subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid] else: # Обновляем лимиты для платной подписки if context.traffic_limit_gb not in (None, 0): subscription.traffic_limit_gb = context.traffic_limit_gb - if context.device_limit is not None and context.device_limit > subscription.device_limit: + if context.device_limit is not None and context.device_limit > (subscription.device_limit or 0): subscription.device_limit = context.device_limit if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []): subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid] From 928e3e98f8fa68da6441d6e6cbc443c60ad74c79 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Mar 2026 15:41:59 +0300 Subject: [PATCH 8/8] chore: format balance/main.py and promocode.py --- app/handlers/balance/main.py | 7 ++++--- app/handlers/promocode.py | 4 +--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index f2616b96..9c641d26 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -266,9 +266,10 @@ async def get_quick_amount_buttons(language: str, user: User) -> list: period_label = f'{period} дней' # Скидка считается от полной базовой стоимости (период + аддоны без скидок) - total_base = base_price_kopeks + ( - devices_price_per_month + sum(servers_per_month_prices) + traffic_price_per_month - ) * months + total_base = ( + base_price_kopeks + + (devices_price_per_month + sum(servers_per_month_prices) + traffic_price_per_month) * months + ) has_discount = total_base > total_price and total_base > 0 if has_discount: diff --git a/app/handlers/promocode.py b/app/handlers/promocode.py index 30132006..ce2d4621 100644 --- a/app/handlers/promocode.py +++ b/app/handlers/promocode.py @@ -30,9 +30,7 @@ async def show_promocode_menu(callback: types.CallbackQuery, db_user: User, stat except TelegramBadRequest as error: error_message = str(error).lower() if 'there is no text in the message to edit' in error_message: - await callback.message.answer( - texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.answer(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language)) else: raise