From d7f91c83584384ef5d0fc90a28debc722b2db79f Mon Sep 17 00:00:00 2001 From: "sMedia.tech" <81699471+smediainfo@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:10:56 +0300 Subject: [PATCH 01/16] fix: include SUBSCRIPTION_PAYMENT in dashboard revenue calculations Guest/landing purchases create SUBSCRIPTION_PAYMENT transactions (not DEPOSIT), so they were excluded from income_today, total_income, revenue_by_period, and payment_methods breakdown. Also use func.abs() for SUBSCRIPTION_PAYMENT amounts since they are stored as negative values. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/database/crud/transaction.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 0338f9ce..9192dfed 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -278,11 +278,11 @@ async def get_transactions_statistics( if not end_date: end_date = datetime.now(UTC) - # Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения) + # Доход считаем по реальным платежам + прямые покупки подписок (лендинги) income_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.created_at <= end_date, @@ -343,7 +343,7 @@ async def get_transactions_statistics( ) .where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.created_at <= end_date, @@ -363,11 +363,11 @@ async def get_transactions_statistics( ) transactions_today = today_result.scalar() - # Доход за сегодня - только реальные платежи + # Доход за сегодня — реальные платежи + прямые покупки подписок (лендинги) today_income_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= today, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), @@ -391,17 +391,17 @@ async def get_transactions_statistics( async def get_revenue_by_period(db: AsyncSession, days: int = 30) -> list[dict]: - """Доход по дням - только реальные платежи.""" + """Доход по дням — реальные платежи + прямые покупки подписок (лендинги).""" start_date = datetime.now(UTC) - timedelta(days=days) result = await db.execute( select( func.date(Transaction.created_at).label('date'), - func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'), + func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'), ) .where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), From 67da3903715e97c0d84d0018efdd74b085ee8720 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 20 Mar 2026 23:12:00 +0300 Subject: [PATCH 02/16] feat: show both bot and cabinet referral links everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the bot showed only one referral link (cabinet when CABINET_URL is set, bot otherwise). Users who received the cabinet link were confused — they opened a web registration form instead of being directed to the bot. Now the bot, cabinet API, and miniapp API all return both links: - Bot link (t.me deep link) — always shown - Cabinet link (web registration) — shown when CABINET_URL is configured Changes: - Add get_bot_referral_link() and get_cabinet_referral_link() to config - Refactor config methods to eliminate code duplication - Update bot referral handler to display both links - Fix switch_inline_query 256-char limit with auto-truncation - Add html_escape() to all user-controlled strings in HTML messages - Add translations for 5 new keys in all 5 locale files (ru/en/ua/zh/fa) - Simplify cabinet route to use new methods instead of inline URL construction - Add bot_referral_link to MiniApp API schema and response --- app/cabinet/routes/referral.py | 10 +-- app/config.py | 40 +++++++++--- app/handlers/referral.py | 104 +++++++++++++++++++++---------- app/localization/locales/en.json | 5 ++ app/localization/locales/fa.json | 5 ++ app/localization/locales/ru.json | 5 ++ app/localization/locales/ua.json | 5 ++ app/localization/locales/zh.json | 5 ++ app/webapi/routes/miniapp.py | 5 +- app/webapi/schemas/miniapp.py | 1 + 10 files changed, 134 insertions(+), 51 deletions(-) diff --git a/app/cabinet/routes/referral.py b/app/cabinet/routes/referral.py index 41407f92..524aad5c 100644 --- a/app/cabinet/routes/referral.py +++ b/app/cabinet/routes/referral.py @@ -92,14 +92,8 @@ async def get_referral_info( available_balance = min(user.balance_kopeks, referral_entitlement) # Build referral links - referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else '' - bot_username = settings.get_bot_username() - bot_referral_link = '' - if user.referral_code and bot_username: - from urllib.parse import quote - - safe_code = quote(user.referral_code, safe='') - bot_referral_link = f'https://t.me/{bot_username}?start={safe_code}' + referral_link = (settings.get_cabinet_referral_link(user.referral_code) or '') if user.referral_code else '' + bot_referral_link = settings.get_bot_referral_link(user.referral_code) if user.referral_code else '' return ReferralInfoResponse( referral_code=user.referral_code or '', diff --git a/app/config.py b/app/config.py index aef035d4..14b984a1 100644 --- a/app/config.py +++ b/app/config.py @@ -7,7 +7,7 @@ from collections import defaultdict from datetime import time from pathlib import Path from typing import Literal -from urllib.parse import urlparse +from urllib.parse import quote as _url_quote, urlparse from zoneinfo import ZoneInfo import structlog @@ -1439,24 +1439,44 @@ class Settings(BaseSettings): _CABINET_URL_DEFAULT = 'https://example.com/cabinet' + def _encode_referral_code(self, referral_code: str) -> str: + """Validate and URL-encode a referral code.""" + if not referral_code: + raise ValueError('referral_code must not be empty or None') + return _url_quote(referral_code, safe='') + + def _normalized_cabinet_url(self) -> str | None: + """Return normalized cabinet URL, or None if not configured.""" + cabinet_url = (self.CABINET_URL or '').strip().rstrip('/') + if not cabinet_url or cabinet_url == self._CABINET_URL_DEFAULT: + return None + return cabinet_url + def get_referral_link(self, referral_code: str, bot_username: str | None = None) -> str: """Build a referral link pointing to the web cabinet. Falls back to a Telegram bot deep link when CABINET_URL is not configured. """ - from urllib.parse import quote + cabinet_link = self.get_cabinet_referral_link(referral_code) + if cabinet_link: + return cabinet_link + return self.get_bot_referral_link(referral_code, bot_username) - if not referral_code: - raise ValueError('referral_code must not be empty or None') - - safe_code = quote(referral_code, safe='') - cabinet_url = (self.CABINET_URL or '').strip().rstrip('/') - if cabinet_url and cabinet_url != self._CABINET_URL_DEFAULT: - sep = '&' if '?' in cabinet_url else '?' - return f'{cabinet_url}{sep}ref={safe_code}' + def get_bot_referral_link(self, referral_code: str, bot_username: str | None = None) -> str: + """Always return the Telegram bot deep link for a referral code.""" + safe_code = self._encode_referral_code(referral_code) username = bot_username or self.get_bot_username() or 'bot' return f'https://t.me/{username}?start={safe_code}' + def get_cabinet_referral_link(self, referral_code: str) -> str | None: + """Return the cabinet referral link, or None if cabinet is not configured.""" + cabinet_url = self._normalized_cabinet_url() + if not cabinet_url: + return None + safe_code = self._encode_referral_code(referral_code) + sep = '&' if '?' in cabinet_url else '?' + return f'{cabinet_url}{sep}ref={safe_code}' + def is_deep_links_enabled(self) -> bool: return self.ENABLE_DEEP_LINKS diff --git a/app/handlers/referral.py b/app/handlers/referral.py index f9bf1351..f68a4e6c 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -1,5 +1,6 @@ import hashlib import json +from html import escape as html_escape from pathlib import Path import qrcode @@ -45,7 +46,8 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A summary = await get_user_referral_summary(db, db_user.id) bot_username = (await callback.bot.get_me()).username - referral_link = settings.get_referral_link(db_user.referral_code, bot_username) + bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username) + cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code) referral_text = ( texts.t('REFERRAL_PROGRAM_TITLE', '👥 Реферальная программа') @@ -114,13 +116,27 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A '• Комиссия с каждого пополнения реферала: {percent}%', ).format(percent=get_effective_referral_commission_percent(db_user)) + referral_text += '\n' + commission_line + '\n\n' + + # Show bot link + referral_text += ( + texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 Ссылка на бота:') + + f'\n{html_escape(bot_referral_link)}\n' + ) + + # Show cabinet link if configured + if cabinet_referral_link: + referral_text += ( + '\n' + + texts.t('REFERRAL_CABINET_LINK_TITLE', '🌐 Ссылка на кабинет:') + + f'\n{html_escape(cabinet_referral_link)}\n' + ) + referral_text += ( '\n' - + commission_line - + '\n\n' - + texts.t('REFERRAL_LINK_TITLE', '🔗 Ваша реферальная ссылка:') - + f'\n{referral_link}\n\n' - + texts.t('REFERRAL_CODE_TITLE', '🆔 Ваш код: {code}').format(code=db_user.referral_code) + + texts.t('REFERRAL_CODE_TITLE', '🆔 Ваш код: {code}').format( + code=html_escape(str(db_user.referral_code or '')) + ) + '\n\n' ) @@ -158,7 +174,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A ).format( reason=reason_text, amount=texts.format_price(earning['amount_kopeks']), - referral_name=earning['referral_name'], + referral_name=html_escape(str(earning['referral_name'] or '')), ) + '\n' ) @@ -243,15 +259,15 @@ async def show_referral_qr( await callback.answer() bot_username = (await callback.bot.get_me()).username - referral_link = settings.get_referral_link(db_user.referral_code, bot_username) + bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username) qr_dir = Path('data') / 'referral_qr' qr_dir.mkdir(parents=True, exist_ok=True) - link_hash = hashlib.md5(referral_link.encode()).hexdigest()[:8] + link_hash = hashlib.md5(bot_referral_link.encode()).hexdigest()[:8] file_path = qr_dir / f'{db_user.id}_{link_hash}.png' if not file_path.exists(): - img = qrcode.make(referral_link) + img = qrcode.make(bot_referral_link) img.save(file_path) photo = FSInputFile(file_path) @@ -259,25 +275,28 @@ async def show_referral_qr( inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')]] ) + caption = texts.t( + 'REFERRAL_QR_BOT_LINK', + '🤖 Ссылка на бота:\n{link}', + ).format(link=bot_referral_link) + + cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code) + if cabinet_referral_link: + caption += '\n\n' + texts.t( + 'REFERRAL_QR_CABINET_LINK', + '🌐 Ссылка на кабинет:\n{link}', + ).format(link=cabinet_referral_link) + try: await callback.message.edit_media( - types.InputMediaPhoto( - media=photo, - caption=texts.t( - 'REFERRAL_LINK_CAPTION', - '🔗 Ваша реферальная ссылка:\n{link}', - ).format(link=referral_link), - ), + types.InputMediaPhoto(media=photo, caption=caption), reply_markup=keyboard, ) except TelegramBadRequest: await callback.message.delete() await callback.message.answer_photo( photo, - caption=texts.t( - 'REFERRAL_LINK_CAPTION', - '🔗 Ваша реферальная ссылка:\n{link}', - ).format(link=referral_link), + caption=caption, reply_markup=keyboard, ) @@ -322,7 +341,7 @@ async def show_detailed_referral_list(callback: types.CallbackQuery, db_user: Us texts.t( 'REFERRAL_LIST_ITEM_HEADER', '{index}. {status} {name}', - ).format(index=i, status=status_emoji, name=referral['full_name']) + ).format(index=i, status=status_emoji, name=html_escape(str(referral['full_name'] or ''))) + '\n' ) text += ( @@ -454,7 +473,7 @@ async def show_referral_analytics(callback: types.CallbackQuery, db_user: User, '{index}. {name}: {amount} ({count} начислений)', ).format( index=i, - name=ref['referral_name'], + name=html_escape(str(ref['referral_name'] or '')), amount=texts.format_price(ref['total_earned_kopeks']), count=ref['earnings_count'], ) @@ -485,7 +504,8 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User): return bot_username = (await callback.bot.get_me()).username - referral_link = settings.get_referral_link(db_user.referral_code, bot_username) + bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username) + cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code) invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') @@ -507,14 +527,32 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User): + texts.t('REFERRAL_INVITE_FEATURE_SECURE', '🔒 Надежная защита') + '\n\n' + texts.t('REFERRAL_INVITE_LINK_PROMPT', '👇 Переходи по ссылке:') - + f'\n{referral_link}' + + f'\n{bot_referral_link}' ) + if cabinet_referral_link: + invite_text += ( + '\n\n' + + texts.t('REFERRAL_INVITE_CABINET_LINK', '🌐 Или через личный кабинет:') + + f'\n{cabinet_referral_link}' + ) + + # Compact share text for switch_inline_query (256-char limit) + share_text = invite_text + if len(share_text) > 256: + share_text = ( + texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') + + f'\n\n👇 {bot_referral_link}' + ) + if cabinet_referral_link and len(share_text) + len(cabinet_referral_link) + 5 <= 256: + share_text += f'\n🌐 {cabinet_referral_link}' + share_text = share_text[:256] + keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=invite_text + text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=share_text ) ], [types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')], @@ -531,7 +569,7 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User): 'Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:', ) + '\n\n' - f'{invite_text}' + f'{html_escape(invite_text)}' ), keyboard, ) @@ -584,7 +622,7 @@ async def show_withdrawal_info(callback: types.CallbackQuery, db_user: User, db: ] ) else: - text += f'❌ {reason}\n' + text += f'❌ {html_escape(str(reason))}\n' keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')]) @@ -746,7 +784,7 @@ async def process_payment_details(message: types.Message, db_user: User, db: Asy ) text += ( texts.t('REFERRAL_WITHDRAWAL_CONFIRM_DETAILS', '💳 Реквизиты:\n{details}').format( - details=payment_details + details=html_escape(payment_details) ) + '\n\n' ) @@ -792,16 +830,18 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use # Отправляем уведомление админам analysis = json.loads(request.risk_analysis) if request.risk_analysis else {} - user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}' + user_id_display = html_escape(str(db_user.telegram_id or db_user.email or f'#{db_user.id}')) + safe_name = html_escape(db_user.full_name or 'Без имени') + safe_details = html_escape(payment_details) admin_text = f""" 🔔 Новая заявка на вывод #{request.id} -👤 Пользователь: {db_user.full_name or 'Без имени'} +👤 Пользователь: {safe_name} 🆔 ID: {user_id_display} 💰 Сумма: {amount_kopeks / 100:.0f}₽ 💳 Реквизиты: -{payment_details} +{safe_details} {referral_withdrawal_service.format_analysis_for_admin(analysis)} """ diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 5ad8b089..a89a5e24 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1317,6 +1317,11 @@ "REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!", "REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}", "REFERRAL_LINK_TITLE": "🔗 Your referral link:", + "REFERRAL_BOT_LINK_TITLE": "🤖 Bot link:", + "REFERRAL_CABINET_LINK_TITLE": "🌐 Cabinet link:", + "REFERRAL_QR_BOT_LINK": "🤖 Bot link:\n{link}", + "REFERRAL_QR_CABINET_LINK": "🌐 Cabinet link:\n{link}", + "REFERRAL_INVITE_CABINET_LINK": "🌐 Or via personal cabinet:", "REFERRAL_LIST_BUTTON": "👥 Referral list", "REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!", "REFERRAL_LIST_HEADER": "👥 Your referrals (page {current}/{total})", diff --git a/app/localization/locales/fa.json b/app/localization/locales/fa.json index c0dd6d7d..ca5ba305 100644 --- a/app/localization/locales/fa.json +++ b/app/localization/locales/fa.json @@ -1338,6 +1338,11 @@ "REFERRAL_INVITE_TITLE": "🎉 به سرویس VPN بپیوند!", "REFERRAL_LINK_CAPTION": "🔗 لینک دعوت شما:\n{link}", "REFERRAL_LINK_TITLE": "🔗 لینک دعوت شما:", + "REFERRAL_BOT_LINK_TITLE": "🤖 لینک ربات:", + "REFERRAL_CABINET_LINK_TITLE": "🌐 لینک کابینت:", + "REFERRAL_QR_BOT_LINK": "🤖 لینک ربات:\n{link}", + "REFERRAL_QR_CABINET_LINK": "🌐 لینک کابینت:\n{link}", + "REFERRAL_INVITE_CABINET_LINK": "🌐 یا از طریق کابینت شخصی:", "REFERRAL_LIST_BUTTON": "👥 لیست دعوت‌شدگان", "REFERRAL_LIST_EMPTY": "📋 هنوز دعوت‌شده‌ای ندارید.\n\nلینک دعوت خود را به اشتراک بگذارید!", "REFERRAL_LIST_HEADER": "👥 دعوت‌شدگان شما (صفحه {current}/{total})", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index addc5b6d..99dea881 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1338,6 +1338,11 @@ "REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!", "REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}", "REFERRAL_LINK_TITLE": "🔗 Ваша реферальная ссылка:", + "REFERRAL_BOT_LINK_TITLE": "🤖 Ссылка на бота:", + "REFERRAL_CABINET_LINK_TITLE": "🌐 Ссылка на кабинет:", + "REFERRAL_QR_BOT_LINK": "🤖 Ссылка на бота:\n{link}", + "REFERRAL_QR_CABINET_LINK": "🌐 Ссылка на кабинет:\n{link}", + "REFERRAL_INVITE_CABINET_LINK": "🌐 Или через личный кабинет:", "REFERRAL_LIST_BUTTON": "👥 Список рефералов", "REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!", "REFERRAL_LIST_HEADER": "👥 Ваши рефералы (стр. {current}/{total})", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 2c03e93d..4842e4bc 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -1254,6 +1254,11 @@ "REFERRAL_INVITE_TITLE": "🎉 Приєднуйся до VPN сервісу!", "REFERRAL_LINK_CAPTION": "🔗 Ваше реферальне посилання:\n{link}", "REFERRAL_LINK_TITLE": "🔗 Ваше реферальне посилання:", + "REFERRAL_BOT_LINK_TITLE": "🤖 Посилання на бота:", + "REFERRAL_CABINET_LINK_TITLE": "🌐 Посилання на кабінет:", + "REFERRAL_QR_BOT_LINK": "🤖 Посилання на бота:\n{link}", + "REFERRAL_QR_CABINET_LINK": "🌐 Посилання на кабінет:\n{link}", + "REFERRAL_INVITE_CABINET_LINK": "🌐 Або через особистий кабінет:", "REFERRAL_LIST_BUTTON": "👥 Список рефералів", "REFERRAL_LIST_EMPTY": "📋 У вас поки немає рефералів.\n\nПоділіться своїм реферальним посиланням, щоб почати заробляти!", "REFERRAL_LIST_HEADER": "👥 Ваші реферали (стор. {current}/{total})", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 4a8e0ce3..0abc11f5 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -1252,6 +1252,11 @@ "REFERRAL_INVITE_TITLE": "🎉加入VPN服务!", "REFERRAL_LINK_CAPTION": "🔗您的推荐链接:\n{link}", "REFERRAL_LINK_TITLE": "🔗您的推荐链接:", +"REFERRAL_BOT_LINK_TITLE": "🤖机器人链接:", +"REFERRAL_CABINET_LINK_TITLE": "🌐控制面板链接:", +"REFERRAL_QR_BOT_LINK": "🤖机器人链接:\n{link}", +"REFERRAL_QR_CABINET_LINK": "🌐控制面板链接:\n{link}", +"REFERRAL_INVITE_CABINET_LINK": "🌐或通过个人面板:", "REFERRAL_LIST_BUTTON": "👥推荐列表", "REFERRAL_LIST_EMPTY": "📋您目前没有推荐。\n\n分享您的推荐链接开始赚钱吧!", "REFERRAL_LIST_HEADER": "👥您的推荐(第{current}/{total}页)", diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 9bfff69e..8baab883 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -2869,8 +2869,10 @@ async def _build_referral_info( referral_settings = settings.get_referral_settings() or {} referral_link = None + bot_referral_link = None if referral_code: - referral_link = settings.get_referral_link(referral_code) + referral_link = settings.get_cabinet_referral_link(referral_code) + bot_referral_link = settings.get_bot_referral_link(referral_code) minimum_topup_kopeks = int(referral_settings.get('minimum_topup_kopeks') or 0) first_topup_bonus_kopeks = int(referral_settings.get('first_topup_bonus_kopeks') or 0) @@ -2967,6 +2969,7 @@ async def _build_referral_info( return MiniAppReferralInfo( referral_code=referral_code, referral_link=referral_link, + bot_referral_link=bot_referral_link, terms=terms, stats=stats, recent_earnings=recent_earnings, diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 2762ad02..78377510 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -376,6 +376,7 @@ class MiniAppReferralList(BaseModel): class MiniAppReferralInfo(BaseModel): referral_code: str | None = None referral_link: str | None = None + bot_referral_link: str | None = None terms: MiniAppReferralTerms | None = None stats: MiniAppReferralStats | None = None recent_earnings: list[MiniAppReferralRecentEarning] = Field(default_factory=list) From f601bedb4874c360580d8fab96bfff55946cb567 Mon Sep 17 00:00:00 2001 From: SayonaraQ Date: Sat, 21 Mar 2026 00:18:32 +0300 Subject: [PATCH 03/16] add toggle for Remnawave node connection webhook alerts --- .env.example | 3 +++ app/config.py | 1 + app/services/remnawave_webhook_service.py | 9 +++++++++ app/services/system_settings_service.py | 13 +++++++++++++ 4 files changed, 26 insertions(+) diff --git a/.env.example b/.env.example index 36bdac6b..5670fbda 100644 --- a/.env.example +++ b/.env.example @@ -194,6 +194,9 @@ REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook # Сгенерируйте: openssl rand -hex 32 # ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука REMNAWAVE_WEBHOOK_SECRET= +# Уведомления администраторам о потере/восстановлении связи с нодами +# false = не отправлять события node.connection_lost / node.connection_restored +REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS=true # ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) ===== # Глобальный переключатель уведомлений пользователям от вебхуков diff --git a/app/config.py b/app/config.py index 14b984a1..59e1b501 100644 --- a/app/config.py +++ b/app/config.py @@ -118,6 +118,7 @@ class Settings(BaseSettings): REMNAWAVE_WEBHOOK_ENABLED: bool = False REMNAWAVE_WEBHOOK_PATH: str = '/remnawave-webhook' REMNAWAVE_WEBHOOK_SECRET: str | None = None # HMAC-SHA256 shared secret (min 32 chars) + REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS: bool = True # Webhook user notification toggles (what Telegram messages users receive from webhook events) WEBHOOK_NOTIFY_USER_ENABLED: bool = True diff --git a/app/services/remnawave_webhook_service.py b/app/services/remnawave_webhook_service.py index dce9871c..958d3fe1 100644 --- a/app/services/remnawave_webhook_service.py +++ b/app/services/remnawave_webhook_service.py @@ -114,6 +114,8 @@ _ADMIN_ERROR_EVENTS: dict[str, str] = { 'errors.bandwidth_usage_threshold_reached_max_notifications': '⚠️ Достигнут лимит уведомлений о трафике', } +_ADMIN_NODE_CONNECTION_EVENTS = frozenset({'node.connection_lost', 'node.connection_restored'}) + class RemnaWaveWebhookService: """Processes incoming webhooks from RemnaWave backend.""" @@ -216,6 +218,13 @@ class RemnaWaveWebhookService: async def _process_admin_event(self, event_name: str, data: dict) -> bool: """Format and send admin notification for infrastructure events.""" + if ( + event_name in _ADMIN_NODE_CONNECTION_EVENTS + and not settings.REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS + ): + logger.debug('RemnaWave node connection notifications disabled, skipping event', event_name=event_name) + return True + if not self._admin_service.is_enabled: logger.debug('Admin notifications disabled, skipping event', event_name=event_name) return True diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 6a4d5634..3106f9b8 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -218,6 +218,7 @@ class BotConfigurationService: 'BOT_USERNAME': 'CORE', 'DEFAULT_LANGUAGE': 'LOCALIZATION', 'AVAILABLE_LANGUAGES': 'LOCALIZATION', + 'REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS': 'ADMIN_NOTIFICATIONS', 'LANGUAGE_SELECTION_ENABLED': 'LOCALIZATION', 'DEFAULT_DEVICE_LIMIT': 'SUBSCRIPTIONS_CORE', 'DEFAULT_TRAFFIC_LIMIT_GB': 'SUBSCRIPTIONS_CORE', @@ -843,6 +844,18 @@ class BotConfigurationService: 'example': '60', 'warning': 'Защита от спама уведомлениями по одному и тому же пользователю.', }, + 'REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS': { + 'description': ( + 'Уведомления администраторам о потере и восстановлении соединения с нодами из webhook-ов RemnaWave.' + ), + 'format': 'Булево значение.', + 'example': 'false', + 'warning': ( + 'Отключает только события node.connection_lost и node.connection_restored. ' + 'Остальные инфраструктурные уведомления продолжают отправляться.' + ), + 'dependencies': 'REMNAWAVE_WEBHOOK_ENABLED, ADMIN_NOTIFICATIONS_ENABLED', + }, 'WEBHOOK_NOTIFY_USER_ENABLED': { 'description': ( 'Глобальный переключатель уведомлений пользователям от вебхуков RemnaWave. ' From ce36fba54faf89b4623943cb5878d7477e30d612 Mon Sep 17 00:00:00 2001 From: SayonaraQ Date: Sat, 21 Mar 2026 00:54:54 +0300 Subject: [PATCH 04/16] style: format remnawave webhook service --- app/services/remnawave_webhook_service.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/services/remnawave_webhook_service.py b/app/services/remnawave_webhook_service.py index 958d3fe1..d3c386f2 100644 --- a/app/services/remnawave_webhook_service.py +++ b/app/services/remnawave_webhook_service.py @@ -222,7 +222,10 @@ class RemnaWaveWebhookService: event_name in _ADMIN_NODE_CONNECTION_EVENTS and not settings.REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS ): - logger.debug('RemnaWave node connection notifications disabled, skipping event', event_name=event_name) + logger.debug( + 'RemnaWave node connection notifications disabled, skipping event', + event_name=event_name, + ) return True if not self._admin_service.is_enabled: From 8a5710aff3e1752f3e605dd3b340314c517b6dc6 Mon Sep 17 00:00:00 2001 From: SayonaraQ Date: Sat, 21 Mar 2026 00:59:18 +0300 Subject: [PATCH 05/16] style: align webhook service with ruff format --- app/services/remnawave_webhook_service.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/app/services/remnawave_webhook_service.py b/app/services/remnawave_webhook_service.py index d3c386f2..28ab502a 100644 --- a/app/services/remnawave_webhook_service.py +++ b/app/services/remnawave_webhook_service.py @@ -218,14 +218,8 @@ class RemnaWaveWebhookService: async def _process_admin_event(self, event_name: str, data: dict) -> bool: """Format and send admin notification for infrastructure events.""" - if ( - event_name in _ADMIN_NODE_CONNECTION_EVENTS - and not settings.REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS - ): - logger.debug( - 'RemnaWave node connection notifications disabled, skipping event', - event_name=event_name, - ) + if event_name in _ADMIN_NODE_CONNECTION_EVENTS and not settings.REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS: + logger.debug('RemnaWave node connection notifications disabled, skipping event', event_name=event_name) return True if not self._admin_service.is_enabled: From 9dd6b54c6e963d2a842d4ac48c9a463014306ed7 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 01:09:13 +0300 Subject: [PATCH 06/16] fix: prevent bootstrap from reactivating revoked superadmin roles - bootstrap _assign_if_missing no longer reactivates revoked UserRole rows - revoke_role uses SELECT FOR UPDATE + pg_advisory_xact_lock to prevent TOCTOU race on last-superadmin check - block self-revocation of superadmin role - block is_active/level changes on system roles - block expires_at on superadmin role assignments - single SUPERADMIN_LEVEL constant in crud/rbac.py, imported everywhere - get_superadmin_count excludes expired assignments - removed dead UserRoleCRUD.revoke_role method - warn when revoking RBAC role from a legacy ADMIN_IDS user - added migration 0043: indexes on user_roles.role_id, access_policies.role_id, lower(users.email) --- app/cabinet/routes/admin_roles.py | 82 ++++++++++++++----- app/database/crud/rbac.py | 25 ++---- app/services/permission_service.py | 5 +- app/services/rbac_bootstrap_service.py | 63 +++++++++----- .../0043_add_rbac_and_email_indexes.py | 46 +++++++++++ 5 files changed, 159 insertions(+), 62 deletions(-) create mode 100644 migrations/alembic/versions/0043_add_rbac_and_email_indexes.py diff --git a/app/cabinet/routes/admin_roles.py b/app/cabinet/routes/admin_roles.py index 58790a64..e1c4df00 100644 --- a/app/cabinet/routes/admin_roles.py +++ b/app/cabinet/routes/admin_roles.py @@ -4,12 +4,13 @@ from __future__ import annotations from datetime import datetime +import sqlalchemy as sa import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession -from app.database.crud.rbac import AdminRoleCRUD, UserRoleCRUD +from app.database.crud.rbac import SUPERADMIN_LEVEL, AdminRoleCRUD, UserRoleCRUD from app.database.models import User from app.services.permission_service import PERMISSION_REGISTRY, get_all_permissions @@ -129,21 +130,26 @@ async def _role_to_response(db: AsyncSession, role) -> RoleResponse: async def _get_admin_level(db: AsyncSession, admin: User) -> int: - """Get the maximum role level of the current admin. + """Get the effective management level of the current admin. - Legacy config-based admins (ADMIN_IDS) get superadmin level (999+1=1000) - so they can manage all roles including level 999. + Superadmin-tier users (DB level 999 or legacy ADMIN_IDS) are promoted to + level 1000 so they can manage peer Superadmins. Without this, the ``>=`` + hierarchy guard would block 999-vs-999 operations. """ from app.config import settings _perms, _names, max_level = await UserRoleCRUD.get_user_permissions(db, admin.id) + # DB-assigned Superadmins can manage peers + if max_level >= SUPERADMIN_LEVEL: + max_level = SUPERADMIN_LEVEL + 1 + # Legacy config-based admins always get the highest level if settings.is_admin( telegram_id=admin.telegram_id, email=admin.email if admin.email_verified else None, ): - max_level = max(max_level, 1000) + max_level = max(max_level, SUPERADMIN_LEVEL + 1) return max_level @@ -339,6 +345,15 @@ async def update_role( update_data = payload.model_dump(exclude_unset=True) + # System roles: only permissions can be extended, block is_active/level changes + if role.is_system: + blocked = {'is_active', 'level'} & update_data.keys() + if blocked: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f'Cannot change {", ".join(sorted(blocked))} on a system role', + ) + # Validate level change if 'level' in update_data and update_data['level'] >= admin_level: raise HTTPException( @@ -435,6 +450,13 @@ async def assign_role( detail='Cannot assign a role with level >= your own role level', ) + # Superadmin assignments must be permanent — expiry would cause silent lockout + if role.level == SUPERADMIN_LEVEL and payload.expires_at is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Superadmin role assignments cannot be time-limited', + ) + # Verify target user exists from app.database.crud.user import get_user_by_id @@ -484,12 +506,14 @@ async def revoke_role( db: AsyncSession = Depends(get_cabinet_db), ): """Revoke a role assignment. Cannot remove the last superadmin.""" - from sqlalchemy import select as sa_select - + from app.config import settings + from app.database.crud.user import get_user_by_id from app.database.models import UserRole - # Load the assignment to check hierarchy - result = await db.execute(sa_select(UserRole).where(UserRole.id == assignment_id)) + # Lock the assignment row (FOR UPDATE held until commit) + result = await db.execute( + sa.select(UserRole).where(UserRole.id == assignment_id).with_for_update() + ) user_role = result.scalar_one_or_none() if not user_role: raise HTTPException( @@ -513,9 +537,19 @@ async def revoke_role( detail='Cannot revoke a role at or above your own level', ) - # Protect last superadmin (level 999) - superadmin_level = 999 - if role.level == superadmin_level: + # Block self-revocation of superadmin role + if role.level == SUPERADMIN_LEVEL and user_role.user_id == admin.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Cannot revoke your own superadmin role', + ) + + # Protect last superadmin (level 999). + # Advisory lock serializes concurrent superadmin revocations so two requests + # cannot both read count=2 and then both proceed to revoke. + if role.level == SUPERADMIN_LEVEL: + if not settings.is_sqlite(): + await db.execute(sa.text('SELECT pg_advisory_xact_lock(736453)')) superadmin_count = await UserRoleCRUD.get_superadmin_count(db) if superadmin_count <= 1: raise HTTPException( @@ -523,13 +557,16 @@ async def revoke_role( detail='Cannot remove the last superadmin', ) - revoked = await UserRoleCRUD.revoke_role(db, assignment_id) - if not revoked: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail='Failed to revoke role', - ) + # Warn if target user is a legacy admin — RBAC revocation won't actually block access + target_user = await get_user_by_id(db, user_role.user_id) + is_target_legacy = target_user and settings.is_admin( + telegram_id=target_user.telegram_id, + email=target_user.email if target_user.email_verified else None, + ) + # Revoke directly on the locked object (avoid CRUD re-fetch without FOR UPDATE) + user_role.is_active = False + await db.flush() await db.commit() logger.info( @@ -539,4 +576,11 @@ async def revoke_role( target_user_id=user_role.user_id, role_name=role.name, ) - return {'message': 'Role revoked', 'assignment_id': assignment_id} + + result_msg = {'message': 'Role revoked', 'assignment_id': assignment_id} + if is_target_legacy: + result_msg['warning'] = ( + 'This user is still listed in ADMIN_IDS/ADMIN_EMAILS env config. ' + 'They retain full access until removed from those settings and the bot is restarted.' + ) + return result_msg diff --git a/app/database/crud/rbac.py b/app/database/crud/rbac.py index 5204d535..124f6ce5 100644 --- a/app/database/crud/rbac.py +++ b/app/database/crud/rbac.py @@ -38,8 +38,8 @@ _POLICY_UPDATABLE_FIELDS = frozenset( } ) -# Superadmin level constant -_SUPERADMIN_LEVEL = 999 +# Superadmin level constant — single source of truth, imported by admin_roles and bootstrap +SUPERADMIN_LEVEL = 999 class AdminRoleCRUD: @@ -240,21 +240,6 @@ class UserRoleCRUD: logger.info('Assigned role to user', user_role_id=user_role.id, user_id=user_id, role_id=role_id) return user_role - @staticmethod - async def revoke_role(db: AsyncSession, user_role_id: int) -> bool: - """Soft-revoke: set is_active=False. Returns False if not found.""" - result = await db.execute(select(UserRole).where(UserRole.id == user_role_id)) - user_role = result.scalar_one_or_none() - if not user_role: - return False - - user_role.is_active = False - await db.flush() - logger.info( - 'Revoked user role', user_role_id=user_role_id, user_id=user_role.user_id, role_id=user_role.role_id - ) - return True - @staticmethod async def get_all_admins( db: AsyncSession, @@ -296,14 +281,16 @@ class UserRoleCRUD: @staticmethod async def get_superadmin_count(db: AsyncSession) -> int: - """Count users with an active role at superadmin level (999).""" + """Count users with an active, non-expired role at superadmin level (999).""" + now = datetime.now(UTC) result = await db.execute( select(func.count(func.distinct(UserRole.user_id))) .join(AdminRole, UserRole.role_id == AdminRole.id) .where( UserRole.is_active.is_(True), AdminRole.is_active.is_(True), - AdminRole.level == _SUPERADMIN_LEVEL, + AdminRole.level == SUPERADMIN_LEVEL, + or_(UserRole.expires_at.is_(None), UserRole.expires_at > now), ) ) return result.scalar() or 0 diff --git a/app/services/permission_service.py b/app/services/permission_service.py index fc37e88e..fbcebd66 100644 --- a/app/services/permission_service.py +++ b/app/services/permission_service.py @@ -15,16 +15,13 @@ import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.crud.rbac import AccessPolicyCRUD, AuditLogCRUD, UserRoleCRUD +from app.database.crud.rbac import SUPERADMIN_LEVEL, AccessPolicyCRUD, AuditLogCRUD, UserRoleCRUD if TYPE_CHECKING: from app.database.models import AccessPolicy, User -SUPERADMIN_LEVEL = 999 - - logger = structlog.get_logger(__name__) diff --git a/app/services/rbac_bootstrap_service.py b/app/services/rbac_bootstrap_service.py index 0064b00a..0111059b 100644 --- a/app/services/rbac_bootstrap_service.py +++ b/app/services/rbac_bootstrap_service.py @@ -5,7 +5,6 @@ Auto-assigns the Superadmin role to users listed in ADMIN_IDS / ADMIN_EMAILS config on bot startup. Runs once during the startup sequence. """ -from datetime import UTC, datetime from typing import Final import structlog @@ -13,6 +12,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.database.crud.rbac import SUPERADMIN_LEVEL, UserRoleCRUD from app.database.models import AdminRole, User, UserRole @@ -137,7 +137,7 @@ async def _ensure_preset_roles(db: AsyncSession) -> AdminRole | None: existing = result.scalars().first() if existing is not None: - if existing.level == 999: # Superadmin level + if existing.level == SUPERADMIN_LEVEL: superadmin_role = existing # Добавить НОВЫЕ permissions из кода, не трогая существующие (админ мог кастомизировать) if existing.is_system: @@ -196,6 +196,8 @@ async def bootstrap_superadmins(db: AsyncSession) -> None: if not admin_ids and not admin_emails: logger.debug('No admin IDs or emails configured, skipping superadmin assignment') await db.commit() + # Safety check even when no IDs configured — someone may have cleared them + await _warn_if_no_superadmins(db, admin_ids, admin_emails) return role_id: int = superadmin_role.id @@ -225,11 +227,35 @@ async def bootstrap_superadmins(db: AsyncSession) -> None: else: logger.debug('Superadmin bootstrap: no new assignments needed') + # ── 5. Safety: warn if no active superadmins exist ──────────── + await _warn_if_no_superadmins(db, admin_ids, admin_emails) + except Exception: await db.rollback() logger.exception('Failed to bootstrap superadmins, continuing startup') +async def _warn_if_no_superadmins( + db: AsyncSession, + admin_ids: list[int], + admin_emails: list[str], +) -> None: + """Log critical/warning if no active superadmin RBAC roles exist in DB.""" + active = await UserRoleCRUD.get_superadmin_count(db) + if active > 0: + return + if not admin_ids and not admin_emails: + logger.critical( + 'No active superadmins exist and no ADMIN_IDS/ADMIN_EMAILS configured. ' + 'Cabinet admin access is not possible until this is resolved.', + ) + else: + logger.warning( + 'No active superadmin RBAC roles in DB. ' + 'Legacy config admins (ADMIN_IDS/ADMIN_EMAILS) still have access.', + ) + + async def _ensure_role_by_telegram_id( db: AsyncSession, *, @@ -277,14 +303,14 @@ async def _assign_if_missing( role_id: int, identifier: str, ) -> bool: - """Create or reactivate a UserRole row for this user/role pair. + """Create a UserRole row if none exists for this user/role pair. - Handles the unique constraint on (user_id, role_id) by checking for - ANY existing assignment (active or inactive) and reactivating if needed. + If an assignment already exists (active or revoked), it is left as-is. + This ensures that an admin-revoked role is NOT silently reactivated + on every bot restart. - Returns True if a new assignment was created or an inactive one was reactivated. + Returns True only if a brand-new assignment was created. """ - # Check for ANY existing assignment (active or not) to respect unique constraint result = await db.execute( select(UserRole).where( UserRole.user_id == user_id, @@ -300,19 +326,16 @@ async def _assign_if_missing( user_id=user_id, identifier=identifier, ) - return False - # Reactivate previously revoked assignment - existing.is_active = True - existing.assigned_at = datetime.now(UTC) - await db.flush() - logger.info( - 'Reactivated Superadmin role for user', - user_id=user_id, - role_id=role_id, - identifier=identifier, - user_role_id=existing.id, - ) - return True + else: + logger.info( + 'Superadmin role was previously revoked, not reactivating ' + '(remove user from ADMIN_IDS to stop this warning, ' + 'or re-assign via cabinet)', + user_id=user_id, + identifier=identifier, + user_role_id=existing.id, + ) + return False user_role = UserRole( user_id=user_id, diff --git a/migrations/alembic/versions/0043_add_rbac_and_email_indexes.py b/migrations/alembic/versions/0043_add_rbac_and_email_indexes.py new file mode 100644 index 00000000..2a0ee775 --- /dev/null +++ b/migrations/alembic/versions/0043_add_rbac_and_email_indexes.py @@ -0,0 +1,46 @@ +"""add missing indexes for RBAC foreign keys and lower(email) expression + +Revision ID: 0043 +Revises: 0042 +Create Date: 2026-03-20 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0043' +down_revision: Union[str, None] = '0042' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.get_context().autocommit_block(): + op.execute( + sa.text( + 'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_user_roles_role_id ' + 'ON user_roles (role_id)' + ) + ) + op.execute( + sa.text( + 'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_access_policies_role_id ' + 'ON access_policies (role_id)' + ) + ) + op.execute( + sa.text( + 'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_users_email_lower ' + 'ON users (lower(email))' + ) + ) + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.execute(sa.text('DROP INDEX CONCURRENTLY IF EXISTS ix_users_email_lower')) + op.execute(sa.text('DROP INDEX CONCURRENTLY IF EXISTS ix_access_policies_role_id')) + op.execute(sa.text('DROP INDEX CONCURRENTLY IF EXISTS ix_user_roles_role_id')) From 0e1296e0ea291ee661aa82508d5eb8049ce23cac Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 01:46:44 +0300 Subject: [PATCH 07/16] fix: prevent double balance credit on concurrent Platega webhooks - acquire FOR UPDATE lock immediately after payment lookup, before is_paid check - use populate_existing=True to prevent SQLAlchemy identity map stale reads - replace intermediate update_platega_payment(commit) with inline assignments + flush - re-check locked.is_paid after lock in get_platega_payment_status - guard _finalize_platega_payment: only called when lock held and is_paid=False - suppress "message is not modified" TelegramBadRequest in message_patch --- app/database/crud/platega.py | 7 +- app/services/payment/platega.py | 110 ++++++++++++++++++-------------- app/utils/message_patch.py | 3 + 3 files changed, 70 insertions(+), 50 deletions(-) diff --git a/app/database/crud/platega.py b/app/database/crud/platega.py index 579d8aa6..297c5023 100644 --- a/app/database/crud/platega.py +++ b/app/database/crud/platega.py @@ -71,7 +71,12 @@ async def get_platega_payment_by_id(db: AsyncSession, payment_id: int) -> Plateg async def get_platega_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> PlategaPayment | None: - result = await db.execute(select(PlategaPayment).where(PlategaPayment.id == payment_id).with_for_update()) + result = await db.execute( + select(PlategaPayment) + .where(PlategaPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) return result.scalar_one_or_none() diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index bdab8c46..a45e805f 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -153,35 +153,38 @@ class PlategaPaymentMixin: logger.warning('Platega webhook: платеж не найден (id=)', transaction_id=transaction_id) return False + # Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race) + platega_crud = import_module('app.database.crud.platega') + locked = await platega_crud.get_platega_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('Platega: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + status_raw = str(payload.get('status') or '').upper() if not status_raw: logger.warning('Platega webhook без статуса для платежа', payment_id=payment.id) return False - update_kwargs = { - 'status': status_raw, - 'callback_payload': payload, - } - - if transaction_id: - update_kwargs['platega_transaction_id'] = transaction_id - if status_raw in self._SUCCESS_STATUSES: if payment.is_paid: logger.info('Platega платеж уже помечен как оплачен', correlation_id=payment.correlation_id) - await payment_module.update_platega_payment( - db, - payment=payment, - **update_kwargs, - is_paid=True, - ) + # Update callback payload without releasing the lock prematurely + payment.callback_payload = payload + if transaction_id and not payment.platega_transaction_id: + payment.platega_transaction_id = transaction_id + payment.updated_at = datetime.now(UTC) + await db.commit() return True - payment = await payment_module.update_platega_payment( - db, - payment=payment, - **update_kwargs, - ) + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock + payment.status = status_raw + payment.callback_payload = payload + if transaction_id and not payment.platega_transaction_id: + payment.platega_transaction_id = transaction_id + payment.updated_at = datetime.now(UTC) + await db.flush() + result = await self._finalize_platega_payment(db, payment, payload) if result is None: logger.error('Platega webhook: финализация не удалась', payment_id=payment.id) @@ -192,7 +195,9 @@ class PlategaPaymentMixin: await payment_module.update_platega_payment( db, payment=payment, - **update_kwargs, + status=status_raw, + callback_payload=payload, + platega_transaction_id=transaction_id or None, is_paid=False, ) logger.info('Platega платеж перешёл в статус', correlation_id=payment.correlation_id, status_raw=status_raw) @@ -201,7 +206,9 @@ class PlategaPaymentMixin: await payment_module.update_platega_payment( db, payment=payment, - **update_kwargs, + status=status_raw, + callback_payload=payload, + platega_transaction_id=transaction_id or None, ) return True @@ -231,28 +238,40 @@ class PlategaPaymentMixin: if remote_payload: remote_status = str(remote_payload.get('status') or '').upper() - if remote_status and remote_status != payment.status: - await payment_module.update_platega_payment( - db, - payment=payment, - status=remote_status, - metadata={ - **(getattr(payment, 'metadata_json', {}) or {}), - 'remote_status': remote_payload, - }, - ) - payment = await payment_module.get_platega_payment_by_id(db, local_payment_id) + status_changed = remote_status and remote_status != payment.status if remote_status in self._SUCCESS_STATUSES and not payment.is_paid: - payment = await payment_module.update_platega_payment( - db, - payment=payment, - status=remote_status, - callback_payload=remote_payload, - ) - result = await self._finalize_platega_payment(db, payment, remote_payload) - if result is not None: - payment = result + # Lock payment row before finalization to prevent concurrent double-processing + platega_crud = import_module('app.database.crud.platega') + locked = await platega_crud.get_platega_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('Platega status check: не удалось заблокировать платёж', payment_id=payment.id) + elif locked.is_paid: + # Another concurrent handler already processed — skip + logger.info('Platega платеж уже оплачен после блокировки', correlation_id=locked.correlation_id) + payment = locked + else: + payment = locked + payment.status = remote_status + payment.callback_payload = remote_payload + payment.metadata_json = { + **(getattr(payment, 'metadata_json', {}) or {}), + 'remote_status': remote_payload, + } + payment.updated_at = datetime.now(UTC) + await db.flush() + result = await self._finalize_platega_payment(db, payment, remote_payload) + if result is not None: + payment = result + elif status_changed: + # Non-success status change — safe to persist without lock + payment.status = remote_status + payment.metadata_json = { + **(getattr(payment, 'metadata_json', {}) or {}), + 'remote_status': remote_payload, + } + payment.updated_at = datetime.now(UTC) + await db.commit() return { 'payment': payment, @@ -279,14 +298,7 @@ class PlategaPaymentMixin: except ValueError: paid_at = None - # Lock FIRST, then read fresh state - platega_lock_crud = import_module('app.database.crud.platega') - locked = await platega_lock_crud.get_platega_payment_by_id_for_update(db, payment.id) - if not locked: - logger.error('Platega: не удалось заблокировать платёж', payment_id=payment.id) - return None - payment = locked - + # FOR UPDATE lock already acquired by caller — just check idempotency if payment.transaction_id: logger.info( 'Platega платеж уже связан с транзакцией', diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py index e6ca9cd6..0409191e 100644 --- a/app/utils/message_patch.py +++ b/app/utils/message_patch.py @@ -293,6 +293,9 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): if 'MESSAGE_ID_INVALID' in str(error) or 'message to edit not found' in str(error).lower(): # Сообщение удалено или недоступно — просто игнорируем return None + if 'message is not modified' in str(error).lower(): + # Контент не изменился — безопасно игнорируем + return None raise From 82c79c130601736cd149350f761dcceacfb7c2db Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 01:52:56 +0300 Subject: [PATCH 08/16] fix: prevent double-payment TOCTOU race in all payment providers Apply the same FOR UPDATE locking pattern across 8 providers: - RioPay: added FOR UPDATE lock (had none at all) - CryptoBot: moved lock before status check, removed redundant lock - WATA: moved lock before is_paid commit, removed redundant lock - Freekassa: moved lock before is_paid commit, removed redundant lock - KassaAI: moved lock before is_paid commit, removed redundant lock - MulenPay: moved lock before is_paid commit, removed redundant lock - Pal24: moved lock before is_paid commit, removed redundant lock - SeverPay: moved lock before is_paid check, removed redundant lock Pattern applied to all: acquire FOR UPDATE with populate_existing=True immediately after finding the payment, replace intermediate commits with inline assignments + flush(), re-check is_paid from locked row. --- app/database/crud/cryptobot.py | 20 +++++- app/database/crud/freekassa.py | 7 +- app/database/crud/kassa_ai.py | 7 +- app/database/crud/mulenpay.py | 7 +- app/database/crud/pal24.py | 7 +- app/database/crud/riopay.py | 11 ++++ app/database/crud/severpay.py | 7 +- app/database/crud/wata.py | 7 +- app/services/payment/cryptobot.py | 28 ++++---- app/services/payment/freekassa.py | 95 +++++++++++++++------------ app/services/payment/kassa_ai.py | 95 +++++++++++++++------------ app/services/payment/mulenpay.py | 39 ++++++----- app/services/payment/pal24.py | 89 ++++++++++++++----------- app/services/payment/riopay.py | 105 ++++++++++++++++++++---------- app/services/payment/severpay.py | 62 +++++++++++------- app/services/payment/wata.py | 102 ++++++++++++++++++----------- 16 files changed, 429 insertions(+), 259 deletions(-) diff --git a/app/database/crud/cryptobot.py b/app/database/crud/cryptobot.py index 8f509c7f..ee7b3fc3 100644 --- a/app/database/crud/cryptobot.py +++ b/app/database/crud/cryptobot.py @@ -67,8 +67,26 @@ async def get_cryptobot_payment_by_id(db: AsyncSession, payment_id: int) -> Cryp return result.scalar_one_or_none() +async def get_cryptobot_payment_by_invoice_id_for_update( + db: AsyncSession, invoice_id: str +) -> CryptoBotPayment | None: + result = await db.execute( + select(CryptoBotPayment) + .options(selectinload(CryptoBotPayment.user)) + .where(CryptoBotPayment.invoice_id == invoice_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return result.scalar_one_or_none() + + async def get_cryptobot_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> CryptoBotPayment | None: - result = await db.execute(select(CryptoBotPayment).where(CryptoBotPayment.id == payment_id).with_for_update()) + result = await db.execute( + select(CryptoBotPayment) + .where(CryptoBotPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) return result.scalar_one_or_none() diff --git a/app/database/crud/freekassa.py b/app/database/crud/freekassa.py index 89ec00c3..3b75d7b2 100644 --- a/app/database/crud/freekassa.py +++ b/app/database/crud/freekassa.py @@ -63,7 +63,12 @@ async def get_freekassa_payment_by_id(db: AsyncSession, payment_id: int) -> Free async def get_freekassa_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> FreekassaPayment | None: - result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.id == payment_id).with_for_update()) + result = await db.execute( + select(FreekassaPayment) + .where(FreekassaPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) return result.scalar_one_or_none() diff --git a/app/database/crud/kassa_ai.py b/app/database/crud/kassa_ai.py index 7d9e3d8e..4c5a36c2 100644 --- a/app/database/crud/kassa_ai.py +++ b/app/database/crud/kassa_ai.py @@ -65,7 +65,12 @@ async def get_kassa_ai_payment_by_id(db: AsyncSession, payment_id: int) -> Kassa async def get_kassa_ai_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> KassaAiPayment | None: - result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.id == payment_id).with_for_update()) + result = await db.execute( + select(KassaAiPayment) + .where(KassaAiPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) return result.scalar_one_or_none() diff --git a/app/database/crud/mulenpay.py b/app/database/crud/mulenpay.py index ec59a5c1..c0222921 100644 --- a/app/database/crud/mulenpay.py +++ b/app/database/crud/mulenpay.py @@ -58,7 +58,12 @@ async def get_mulenpay_payment_by_local_id(db: AsyncSession, payment_id: int) -> async def get_mulenpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> MulenPayPayment | None: - result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.id == payment_id).with_for_update()) + result = await db.execute( + select(MulenPayPayment) + .where(MulenPayPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) return result.scalar_one_or_none() diff --git a/app/database/crud/pal24.py b/app/database/crud/pal24.py index dc7174ce..67b414d7 100644 --- a/app/database/crud/pal24.py +++ b/app/database/crud/pal24.py @@ -67,7 +67,12 @@ async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Pal24Pay async def get_pal24_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> Pal24Payment | None: - result = await db.execute(select(Pal24Payment).where(Pal24Payment.id == payment_id).with_for_update()) + result = await db.execute( + select(Pal24Payment) + .where(Pal24Payment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) return result.scalar_one_or_none() diff --git a/app/database/crud/riopay.py b/app/database/crud/riopay.py index 4fd4d1bf..13194f1f 100644 --- a/app/database/crud/riopay.py +++ b/app/database/crud/riopay.py @@ -66,6 +66,17 @@ async def get_riopay_payment_by_id(db: AsyncSession, payment_id: int) -> RioPayP return result.scalar_one_or_none() +async def get_riopay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> RioPayPayment | None: + """Получает платеж по ID с блокировкой FOR UPDATE (для защиты от TOCTOU race).""" + result = await db.execute( + select(RioPayPayment) + .where(RioPayPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return result.scalar_one_or_none() + + async def update_riopay_payment_status( db: AsyncSession, payment: RioPayPayment, diff --git a/app/database/crud/severpay.py b/app/database/crud/severpay.py index d72b6883..a5b595a4 100644 --- a/app/database/crud/severpay.py +++ b/app/database/crud/severpay.py @@ -70,7 +70,12 @@ async def get_severpay_payment_by_id(db: AsyncSession, payment_id: int) -> Sever async def get_severpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> SeverPayPayment | None: """Получает платеж по ID с блокировкой FOR UPDATE.""" - result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.id == payment_id).with_for_update()) + result = await db.execute( + select(SeverPayPayment) + .where(SeverPayPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) return result.scalar_one_or_none() diff --git a/app/database/crud/wata.py b/app/database/crud/wata.py index cdb36ff5..4129f73a 100644 --- a/app/database/crud/wata.py +++ b/app/database/crud/wata.py @@ -72,7 +72,12 @@ async def get_wata_payment_by_id( async def get_wata_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> WataPayment | None: - result = await db.execute(select(WataPayment).where(WataPayment.id == payment_id).with_for_update()) + result = await db.execute( + select(WataPayment) + .where(WataPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) return result.scalar_one_or_none() diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index be8bacf7..233797f0 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -151,6 +151,13 @@ class CryptoBotPaymentMixin: ) return True + # Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race) + locked = await cryptobot_crud.get_cryptobot_payment_by_invoice_id_for_update(db, invoice_id) + if not locked: + logger.error('CryptoBot: не удалось заблокировать платёж', invoice_id=invoice_id) + return False + payment = locked + if payment.status == 'paid': logger.info('CryptoBot платеж уже обработан', invoice_id=invoice_id) return True @@ -164,13 +171,14 @@ class CryptoBotPaymentMixin: else: paid_at = datetime.now(UTC) - updated_payment = await cryptobot_crud.update_cryptobot_payment_status( - db, - invoice_id, - status, - paid_at, - commit=False, - ) + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock + payment.status = status + payment.updated_at = datetime.now(UTC) + if status == 'paid' and paid_at: + payment.paid_at = paid_at + await db.flush() + + updated_payment = payment descriptor = decode_payment_payload( getattr(updated_payment, 'payload', '') or '', @@ -202,11 +210,7 @@ class CryptoBotPaymentMixin: if renewal_handled: return True - locked = await cryptobot_crud.get_cryptobot_payment_by_id_for_update(db, updated_payment.id) - if not locked: - logger.error('CryptoBot: не удалось заблокировать платёж', payment_id=updated_payment.id) - return False - updated_payment = locked + # FOR UPDATE lock already acquired above — no need to re-lock # --- Guest purchase flow (landing page) --- # CryptoBot stores guest metadata in the payload field (JSON string), diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index c49aca8e..848ddbd9 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -198,7 +198,14 @@ class FreekassaPaymentMixin: logger.warning('Freekassa webhook: платеж не найден order_id', order_id=order_id) return False - # Проверка дублирования + # Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race) + locked = await freekassa_crud.get_freekassa_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('Freekassa webhook: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + + # Re-check is_paid from the locked row if payment.is_paid: logger.info('Freekassa webhook: платеж уже обработан order_id', order_id=order_id) return True @@ -213,7 +220,7 @@ class FreekassaPaymentMixin: ) return False - # Обновляем статус платежа + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock callback_payload = { 'merchant_id': merchant_id, 'amount': amount, @@ -221,16 +228,13 @@ class FreekassaPaymentMixin: 'intid': intid, 'cur_id': cur_id, } - - payment = await freekassa_crud.update_freekassa_payment_status( - db=db, - payment=payment, - status='success', - is_paid=True, - freekassa_order_id=intid, - payment_system_id=cur_id, - callback_payload=callback_payload, - ) + payment.status = 'success' + payment.callback_payload = callback_payload + payment.freekassa_order_id = intid + if cur_id is not None: + payment.payment_system_id = cur_id + payment.updated_at = datetime.now(UTC) + await db.flush() # Финализируем платеж (начисляем баланс, создаем транзакцию) return await self._finalize_freekassa_payment(db, payment, intid=intid, trigger='webhook') @@ -250,13 +254,7 @@ class FreekassaPaymentMixin: """Создаёт транзакцию, начисляет баланс и отправляет уведомления.""" payment_module = import_module('app.services.payment_service') - freekassa_lock_crud = import_module('app.database.crud.freekassa') - locked = await freekassa_lock_crud.get_freekassa_payment_by_id_for_update(db, payment.id) - if not locked: - logger.error('Freekassa: не удалось заблокировать платёж', payment_id=payment.id) - return False - payment = locked - + # FOR UPDATE lock already acquired by caller — just check idempotency if payment.transaction_id: logger.info( 'Freekassa платеж уже привязан к транзакции (trigger=)', order_id=payment.order_id, trigger=trigger @@ -506,32 +504,45 @@ class FreekassaPaymentMixin: if fk_status == 1: logger.info('Freekassa payment confirmed via API', order_id=payment.order_id) - callback_payload = { - 'check_source': 'api', - 'fk_order_data': target_order, - } + # Lock payment row before finalization to prevent concurrent double-processing + locked = await freekassa_crud.get_freekassa_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error( + 'Freekassa status check: не удалось заблокировать платёж', payment_id=payment.id + ) + elif locked.is_paid: + # Another concurrent handler already processed — skip + logger.info( + 'Freekassa платеж уже оплачен после блокировки', order_id=locked.order_id + ) + payment = locked + else: + payment = locked - # ID заказа на стороне FK (fk_order_id или id) - fk_intid = str(target_order.get('fk_order_id') or target_order.get('id')) + callback_payload = { + 'check_source': 'api', + 'fk_order_data': target_order, + } - # Обновляем статус - payment = await freekassa_crud.update_freekassa_payment_status( - db=db, - payment=payment, - status='success', - is_paid=True, - freekassa_order_id=fk_intid, - payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None, - callback_payload=callback_payload, - ) + # ID заказа на стороне FK (fk_order_id или id) + fk_intid = str(target_order.get('fk_order_id') or target_order.get('id')) - # Финализируем - await self._finalize_freekassa_payment( - db, - payment, - intid=fk_intid, - trigger='api_check', - ) + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock + payment.status = 'success' + payment.callback_payload = callback_payload + payment.freekassa_order_id = fk_intid + if target_order.get('curID'): + payment.payment_system_id = int(target_order['curID']) + payment.updated_at = datetime.now(UTC) + await db.flush() + + # Финализируем + await self._finalize_freekassa_payment( + db, + payment, + intid=fk_intid, + trigger='api_check', + ) except Exception as e: logger.error('Error checking Freekassa payment status', e=e) diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py index 7a88162d..907019aa 100644 --- a/app/services/payment/kassa_ai.py +++ b/app/services/payment/kassa_ai.py @@ -191,7 +191,14 @@ class KassaAiPaymentMixin: logger.warning('KassaAI webhook: платеж не найден order_id', order_id=order_id) return False - # Проверка дублирования + # Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race) + locked = await kassa_ai_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('KassaAI webhook: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + + # Re-check is_paid from the locked row if payment.is_paid: logger.info('KassaAI webhook: платеж уже обработан order_id', order_id=order_id) return True @@ -206,7 +213,7 @@ class KassaAiPaymentMixin: ) return False - # Обновляем статус платежа + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock callback_payload = { 'merchant_id': merchant_id, 'amount': amount, @@ -214,16 +221,13 @@ class KassaAiPaymentMixin: 'intid': intid, 'cur_id': cur_id, } - - payment = await kassa_ai_crud.update_kassa_ai_payment_status( - db=db, - payment=payment, - status='success', - is_paid=True, - kassa_ai_order_id=intid, - payment_system_id=cur_id, - callback_payload=callback_payload, - ) + payment.status = 'success' + payment.callback_payload = callback_payload + payment.kassa_ai_order_id = intid + if cur_id is not None: + payment.payment_system_id = cur_id + payment.updated_at = datetime.now(UTC) + await db.flush() # Финализируем платеж (начисляем баланс, создаем транзакцию) return await self._finalize_kassa_ai_payment(db, payment, intid=intid, trigger='webhook') @@ -243,13 +247,7 @@ class KassaAiPaymentMixin: """Создаёт транзакцию, начисляет баланс и отправляет уведомления.""" payment_module = import_module('app.services.payment_service') - kassa_ai_lock_crud = import_module('app.database.crud.kassa_ai') - locked = await kassa_ai_lock_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id) - if not locked: - logger.error('KassaAI: не удалось заблокировать платёж', payment_id=payment.id) - return False - payment = locked - + # FOR UPDATE lock already acquired by caller — just check idempotency if payment.transaction_id: logger.info( 'KassaAI платеж уже привязан к транзакции (trigger=)', order_id=payment.order_id, trigger=trigger @@ -485,32 +483,45 @@ class KassaAiPaymentMixin: if kai_status == 1: logger.info('KassaAI payment confirmed via API', order_id=payment.order_id) - callback_payload = { - 'check_source': 'api', - 'kai_order_data': target_order, - } + # Lock payment row before finalization to prevent concurrent double-processing + locked = await kassa_ai_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error( + 'KassaAI status check: не удалось заблокировать платёж', payment_id=payment.id + ) + elif locked.is_paid: + # Another concurrent handler already processed — skip + logger.info( + 'KassaAI платеж уже оплачен после блокировки', order_id=locked.order_id + ) + payment = locked + else: + payment = locked - # ID заказа на стороне KassaAI - kai_intid = str(target_order.get('fk_order_id') or target_order.get('id')) + callback_payload = { + 'check_source': 'api', + 'kai_order_data': target_order, + } - # Обновляем статус - payment = await kassa_ai_crud.update_kassa_ai_payment_status( - db=db, - payment=payment, - status='success', - is_paid=True, - kassa_ai_order_id=kai_intid, - payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None, - callback_payload=callback_payload, - ) + # ID заказа на стороне KassaAI + kai_intid = str(target_order.get('fk_order_id') or target_order.get('id')) - # Финализируем (начисляем баланс) - await self._finalize_kassa_ai_payment( - db, - payment, - intid=kai_intid, - trigger='api_check', - ) + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock + payment.status = 'success' + payment.callback_payload = callback_payload + payment.kassa_ai_order_id = kai_intid + if target_order.get('curID'): + payment.payment_system_id = int(target_order['curID']) + payment.updated_at = datetime.now(UTC) + await db.flush() + + # Финализируем (начисляем баланс) + await self._finalize_kassa_ai_payment( + db, + payment, + intid=kai_intid, + trigger='api_check', + ) except Exception as e: logger.error('Error checking KassaAI payment status', e=e) diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 3cb241f7..2988f856 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -174,6 +174,14 @@ class MulenPayPaymentMixin: ) return False + # Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race) + mulenpay_lock_crud = import_module('app.database.crud.mulenpay') + locked = await mulenpay_lock_crud.get_mulenpay_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('MulenPay: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) invoice_message = metadata.get('invoice_message') or {} @@ -199,11 +207,9 @@ class MulenPayPaymentMixin: if payment.is_paid: if invoice_message_removed: try: - await payment_module.update_mulenpay_payment_metadata( - db, - payment=payment, - metadata=metadata, - ) + payment.metadata_json = metadata + payment.updated_at = datetime.now(UTC) + await db.commit() except Exception as error: # pragma: no cover - diagnostics logger.warning( 'Не удалось обновить метаданные после удаления счёта', @@ -217,21 +223,14 @@ class MulenPayPaymentMixin: return True if payment_status == 'success': - await payment_module.update_mulenpay_payment_status( - db, - payment=payment, - status='success', - callback_payload=callback_data, - mulen_payment_id=mulen_payment_id_int, - metadata=metadata, - ) - - mulenpay_lock_crud = import_module('app.database.crud.mulenpay') - locked = await mulenpay_lock_crud.get_mulenpay_payment_by_id_for_update(db, payment.id) - if not locked: - logger.error('MulenPay: не удалось заблокировать платёж', payment_id=payment.id) - return False - payment = locked + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock + payment.status = 'success' + payment.callback_payload = callback_data + if mulen_payment_id_int is not None and not payment.mulen_payment_id: + payment.mulen_payment_id = mulen_payment_id_int + payment.metadata_json = metadata + payment.updated_at = datetime.now(UTC) + await db.flush() if payment.transaction_id: logger.info('Для платежа уже создана транзакция', display_name=display_name, uuid=payment.uuid) diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index c9b721ca..2789fdda 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -240,6 +240,14 @@ class Pal24PaymentMixin: logger.error('Pal24 платеж не найден: /', bill_id=bill_id, order_id=order_id) return False + # Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race) + pal24_lock_crud = import_module('app.database.crud.pal24') + locked = await pal24_lock_crud.get_pal24_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('Pal24: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + if payment.is_paid: logger.info('Pal24 платеж уже обработан', bill_id=payment.bill_id) return True @@ -249,25 +257,32 @@ class Pal24PaymentMixin: if not isinstance(metadata, dict): metadata = {} - payment = await payment_module.update_pal24_payment_status( - db, - payment, - status=status, - is_paid=True, - paid_at=datetime.now(UTC), - callback_payload=callback, - payment_id=payment_id, - payment_status=callback.get('Status') or status, - payment_method=( - callback.get('payment_method') - or callback.get('PaymentMethod') - or metadata.get('selected_method') - or getattr(payment, 'payment_method', None) - ), - balance_amount=callback.get('BalanceAmount') or callback.get('balance_amount'), - balance_currency=callback.get('BalanceCurrency') or callback.get('balance_currency'), - payer_account=callback.get('AccountNumber') or callback.get('account') or callback.get('Account'), + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock + payment.status = status + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.callback_payload = callback + if payment_id is not None: + payment.payment_id = payment_id + payment.payment_status = (callback.get('Status') or status) + payment.payment_method = ( + callback.get('payment_method') + or callback.get('PaymentMethod') + or metadata.get('selected_method') + or getattr(payment, 'payment_method', None) ) + balance_amount = callback.get('BalanceAmount') or callback.get('balance_amount') + if balance_amount is not None: + payment.balance_amount = balance_amount + balance_currency = callback.get('BalanceCurrency') or callback.get('balance_currency') + if balance_currency is not None: + payment.balance_currency = balance_currency + payer_account = callback.get('AccountNumber') or callback.get('account') or callback.get('Account') + if payer_account is not None: + payment.payer_account = payer_account + payment.last_status = status + payment.updated_at = datetime.now(UTC) + await db.flush() return await self._finalize_pal24_payment( db, @@ -336,23 +351,13 @@ class Pal24PaymentMixin: if invoice_message_removed: try: - await payment_module.update_pal24_payment_status( - db, - payment, - status=payment.status, - metadata=metadata, - ) payment.metadata_json = metadata + payment.updated_at = datetime.now(UTC) + await db.flush() except Exception as error: # pragma: no cover - diagnostics logger.warning('Не удалось обновить метаданные PayPalych после удаления счёта', error=error) - pal24_lock_crud = import_module('app.database.crud.pal24') - locked = await pal24_lock_crud.get_pal24_payment_by_id_for_update(db, payment.id) - if not locked: - logger.error('Pal24: не удалось заблокировать платёж', payment_id=payment.id) - return False - payment = locked - + # FOR UPDATE lock already acquired by caller — just check idempotency if payment.transaction_id: logger.info('Pal24 платеж уже привязан к транзакции (trigger=)', bill_id=payment.bill_id, trigger=trigger) return True @@ -643,14 +648,20 @@ class Pal24PaymentMixin: if payment.is_paid and not payment.transaction_id: try: - finalized = await self._finalize_pal24_payment( - db, - payment, - payment_id=getattr(payment, 'payment_id', None), - trigger='status_check', - ) - if finalized: - payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id) + # Acquire FOR UPDATE lock before finalization (status_check path) + pal24_lock_crud = import_module('app.database.crud.pal24') + locked = await pal24_lock_crud.get_pal24_payment_by_id_for_update(db, payment.id) + if locked: + payment = locked + if not payment.transaction_id: + finalized = await self._finalize_pal24_payment( + db, + payment, + payment_id=getattr(payment, 'payment_id', None), + trigger='status_check', + ) + if finalized: + payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id) except Exception as error: logger.error('Ошибка автоматического начисления по Pal24 статусу', error=error, exc_info=True) diff --git a/app/services/payment/riopay.py b/app/services/payment/riopay.py index 06349ce8..3c0b70a6 100644 --- a/app/services/payment/riopay.py +++ b/app/services/payment/riopay.py @@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.riopay import ( create_riopay_payment as crud_create_riopay_payment, + get_riopay_payment_by_id_for_update, get_riopay_payment_by_order_id, get_riopay_payment_by_riopay_order_id, update_riopay_payment_status, @@ -202,7 +203,14 @@ class RioPayPaymentMixin: ) return False - # Проверка дублирования + # Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race) + locked = await get_riopay_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('RioPay: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + + # Re-check is_paid from the locked row if payment.is_paid: logger.info('RioPay webhook: платеж уже обработан', order_id=payment.order_id) return True @@ -241,23 +249,33 @@ class RioPayPaymentMixin: ) return False - # Обновляем статус платежа только после проверки суммы - payment = await update_riopay_payment_status( - db=db, - payment=payment, - status=internal_status, - is_paid=is_paid, - riopay_order_id=riopay_order_id, - payment_method=payload.get('paymentType'), - callback_payload=callback_payload, - ) - - # Финализируем платеж если оплачен if is_paid: + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock + payment.status = internal_status + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.updated_at = datetime.now(UTC) + if riopay_order_id: + payment.riopay_order_id = riopay_order_id + if payload.get('paymentType') is not None: + payment.payment_method = payload.get('paymentType') + payment.callback_payload = callback_payload + await db.flush() + return await self._finalize_riopay_payment( db, payment, riopay_order_id=riopay_order_id, trigger='webhook' ) + # Non-success status — safe to use update with commit + await update_riopay_payment_status( + db=db, + payment=payment, + status=internal_status, + is_paid=False, + riopay_order_id=riopay_order_id, + payment_method=payload.get('paymentType'), + callback_payload=callback_payload, + ) return True except Exception as e: @@ -272,7 +290,10 @@ class RioPayPaymentMixin: riopay_order_id: str | None, trigger: str, ) -> bool: - """Создаёт транзакцию, начисляет баланс и отправляет уведомления.""" + """Создаёт транзакцию, начисляет баланс и отправляет уведомления. + + FOR UPDATE lock already acquired by caller — do NOT acquire again here. + """ if payment.transaction_id: logger.info('RioPay платеж уже привязан к транзакции', order_id=payment.order_id, trigger=trigger) return True @@ -478,29 +499,45 @@ class RioPayPaymentMixin: 'is_paid': False, } - logger.info('RioPay payment confirmed via API', order_id=payment.order_id) + # Lock payment row before finalization (TOCTOU race protection) + locked = await get_riopay_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error( + 'RioPay status check: не удалось заблокировать платёж', + payment_id=payment.id, + ) + elif locked.is_paid: + # Another concurrent handler already processed — skip + logger.info( + 'RioPay платеж уже оплачен после блокировки', + order_id=locked.order_id, + ) + payment = locked + else: + payment = locked + logger.info('RioPay payment confirmed via API', order_id=payment.order_id) - callback_payload = { - 'check_source': 'api', - 'riopay_order_data': order_data, - } + callback_payload = { + 'check_source': 'api', + 'riopay_order_data': order_data, + } - payment = await update_riopay_payment_status( - db=db, - payment=payment, - status='success', - is_paid=True, - riopay_order_id=payment.riopay_order_id, - payment_method=order_data.get('paymentType'), - callback_payload=callback_payload, - ) + # Inline field updates — NO intermediate commit + payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.updated_at = datetime.now(UTC) + if order_data.get('paymentType') is not None: + payment.payment_method = order_data.get('paymentType') + payment.callback_payload = callback_payload + await db.flush() - await self._finalize_riopay_payment( - db, - payment, - riopay_order_id=payment.riopay_order_id, - trigger='api_check', - ) + await self._finalize_riopay_payment( + db, + payment, + riopay_order_id=payment.riopay_order_id, + trigger='api_check', + ) elif internal_status != payment.status: # Обновляем статус если изменился payment = await update_riopay_payment_status( diff --git a/app/services/payment/severpay.py b/app/services/payment/severpay.py index db1522dc..a3941636 100644 --- a/app/services/payment/severpay.py +++ b/app/services/payment/severpay.py @@ -217,7 +217,14 @@ class SeverPayPaymentMixin: ) return False - # Проверка дублирования + # Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race) + locked = await severpay_crud.get_severpay_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('SeverPay: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + + # Проверка дублирования (re-check from locked row) if payment.is_paid: logger.info('SeverPay webhook: платеж уже обработан', order_id=payment.order_id) return True @@ -290,18 +297,12 @@ class SeverPayPaymentMixin: ) -> bool: """Создаёт транзакцию, начисляет баланс и отправляет уведомления. - Использует FOR UPDATE lock для защиты от race condition. + FOR UPDATE lock must be acquired by the caller before invoking this method. """ payment_module = import_module('app.services.payment_service') severpay_crud = import_module('app.database.crud.severpay') - # Lock FIRST, then read fresh state - locked = await severpay_crud.get_severpay_payment_by_id_for_update(db, payment.id) - if not locked: - logger.error('SeverPay: не удалось заблокировать платёж', payment_id=payment.id) - return False - payment = locked - + # FOR UPDATE lock already acquired by caller — just check idempotency if payment.transaction_id: logger.info( 'SeverPay платеж уже связан с транзакцией', @@ -327,11 +328,12 @@ class SeverPayPaymentMixin: if guest_result is not None: return True - # Inline field assignments to keep FOR UPDATE lock - payment.status = 'success' - payment.is_paid = True - payment.paid_at = datetime.now(UTC) - payment.updated_at = datetime.now(UTC) + # Ensure paid fields are set (idempotent — caller may have already set them) + if not payment.is_paid: + payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.updated_at = datetime.now(UTC) balance_already_credited = bool(metadata.get('balance_credited')) @@ -559,21 +561,33 @@ class SeverPayPaymentMixin: 'is_paid': False, } + # Acquire FOR UPDATE lock before finalization + locked = await severpay_crud.get_severpay_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('SeverPay: не удалось заблокировать платёж', payment_id=payment.id) + return None + payment = locked + + if payment.is_paid: + logger.info('SeverPay платеж уже обработан (api_check)', order_id=payment.order_id) + return { + 'payment': payment, + 'status': 'success', + 'is_paid': True, + } + logger.info('SeverPay payment confirmed via API', order_id=payment.order_id) - callback_payload = { + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock + payment.status = 'success' + payment.is_paid = True + payment.severpay_id = payment.severpay_id + payment.callback_payload = { 'check_source': 'api', 'severpay_order_data': order_data, } - - payment = await severpay_crud.update_severpay_payment_status( - db=db, - payment=payment, - status='success', - is_paid=True, - severpay_id=payment.severpay_id, - callback_payload=callback_payload, - ) + payment.updated_at = datetime.now(UTC) + await db.flush() await self._finalize_severpay_payment( db, diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index 21bb7f14..0a0b94b6 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -223,6 +223,14 @@ class WataPaymentMixin: ) return False + # Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race) + wata_crud = import_module('app.database.crud.wata') + locked = await wata_crud.get_wata_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('WATA: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + status_lower = transaction_status.lower() metadata = dict(getattr(payment, 'metadata_json', {}) or {}) metadata['last_webhook'] = payload @@ -230,17 +238,38 @@ class WataPaymentMixin: payload.get('terminalPublicId') or payload.get('terminal_public_id') or payload.get('terminalPublicID') ) + if status_lower == 'paid': + if payment.is_paid: + logger.info('WATA платеж уже помечен как оплачен', payment_link_id=payment.payment_link_id) + # Update callback payload without releasing the lock prematurely + payment.callback_payload = payload + payment.metadata_json = metadata + if terminal_public_id: + payment.terminal_public_id = terminal_public_id + await db.commit() + return True + + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock + payment.status = transaction_status + payment.last_status = transaction_status + payment.callback_payload = payload + payment.metadata_json = metadata + if terminal_public_id: + payment.terminal_public_id = terminal_public_id + await db.flush() + + await self._finalize_wata_payment(db, payment, payload) + return True + + # Non-success statuses: safe to use update_wata_payment_status (commits) update_kwargs: dict[str, Any] = { 'metadata': metadata, 'callback_payload': payload, 'terminal_public_id': terminal_public_id, + 'status': transaction_status, + 'last_status': transaction_status, } - - if transaction_status: - update_kwargs['status'] = transaction_status - update_kwargs['last_status'] = transaction_status - - if status_lower != 'paid' and not payment.is_paid: + if not payment.is_paid: update_kwargs['is_paid'] = False payment = await payment_module.update_wata_payment_status( @@ -249,14 +278,6 @@ class WataPaymentMixin: **update_kwargs, ) - if status_lower == 'paid': - if payment.is_paid: - logger.info('WATA платеж уже помечен как оплачен', payment_link_id=payment.payment_link_id) - return True - - await self._finalize_wata_payment(db, payment, payload) - return True - if status_lower == 'declined': logger.info('WATA платеж отклонён', payment_link_id=payment.payment_link_id) @@ -364,7 +385,18 @@ class WataPaymentMixin: if raw_status: normalized_status = str(raw_status).lower() if normalized_status == 'paid': - payment = await self._finalize_wata_payment(db, payment, transaction_payload) + # Lock payment row before finalization to prevent concurrent double-processing + wata_crud = import_module('app.database.crud.wata') + locked = await wata_crud.get_wata_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('WATA status check: не удалось заблокировать платёж', payment_id=payment.id) + elif locked.is_paid: + # Another concurrent handler already processed — skip + logger.info('WATA платеж уже оплачен после блокировки', payment_link_id=locked.payment_link_id) + payment = locked + else: + payment = locked + payment = await self._finalize_wata_payment(db, payment, transaction_payload) else: logger.debug( 'WATA транзакция в статусе , повторная обработка не требуется', @@ -399,6 +431,15 @@ class WataPaymentMixin: paid_status=paid_status, ) + # FOR UPDATE lock already acquired by caller — just check idempotency + if payment.transaction_id: + logger.info( + 'WATA платеж уже привязан к транзакции', + payment_link_id=payment.payment_link_id, + transaction_id=payment.transaction_id, + ) + return payment + paid_at = None if isinstance(transaction_payload, dict): paid_at = WataService._parse_datetime(transaction_payload.get('paymentTime')) @@ -420,30 +461,13 @@ class WataPaymentMixin: existing_metadata['transaction'] = transaction_payload - await payment_module.update_wata_payment_status( - db, - payment=payment, - status='Paid', - is_paid=True, - paid_at=paid_at, - callback_payload=transaction_payload, - metadata=existing_metadata, - ) - - wata_lock_crud = import_module('app.database.crud.wata') - locked = await wata_lock_crud.get_wata_payment_by_id_for_update(db, payment.id) - if not locked: - logger.error('WATA: не удалось заблокировать платёж', payment_id=payment.id) - return None - payment = locked - - if payment.transaction_id: - logger.info( - 'WATA платеж уже привязан к транзакции', - payment_link_id=payment.payment_link_id, - transaction_id=payment.transaction_id, - ) - return payment + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock + payment.status = 'Paid' + payment.is_paid = True + payment.paid_at = paid_at + payment.callback_payload = transaction_payload + payment.metadata_json = existing_metadata + await db.flush() # --- Guest purchase flow (landing page) --- wata_metadata = dict(getattr(payment, 'metadata_json', {}) or {}) From afefcc9c07a80ecf65871bc2d0fbcfe13ffc24fd Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 02:10:41 +0300 Subject: [PATCH 09/16] fix: resolve remaining TOCTOU issues in RioPay, SeverPay and restore paid_at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RioPay: use create_transaction(commit=False) to keep FOR UPDATE lock, replace update_riopay_payment_status with inline assignment + flush, add emit_transaction_side_effects after commit - SeverPay: add db.flush() before _finalize, remove self-assignment, add paid_at to both webhook and status-check paths - Freekassa/KassaAI: add is_paid and paid_at to webhook and status-check inline sections (regression from CRUD→inline migration) - MulenPay: add is_paid and paid_at to webhook inline section --- app/services/payment/freekassa.py | 4 ++++ app/services/payment/kassa_ai.py | 4 ++++ app/services/payment/mulenpay.py | 2 ++ app/services/payment/riopay.py | 30 ++++++++++++++++++++++-------- app/services/payment/severpay.py | 4 +++- 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index 848ddbd9..6374b895 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -229,6 +229,8 @@ class FreekassaPaymentMixin: 'cur_id': cur_id, } payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) payment.callback_payload = callback_payload payment.freekassa_order_id = intid if cur_id is not None: @@ -529,6 +531,8 @@ class FreekassaPaymentMixin: # Inline field updates — NO intermediate commit that would release FOR UPDATE lock payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) payment.callback_payload = callback_payload payment.freekassa_order_id = fk_intid if target_order.get('curID'): diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py index 907019aa..c0821bd1 100644 --- a/app/services/payment/kassa_ai.py +++ b/app/services/payment/kassa_ai.py @@ -222,6 +222,8 @@ class KassaAiPaymentMixin: 'cur_id': cur_id, } payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) payment.callback_payload = callback_payload payment.kassa_ai_order_id = intid if cur_id is not None: @@ -508,6 +510,8 @@ class KassaAiPaymentMixin: # Inline field updates — NO intermediate commit that would release FOR UPDATE lock payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) payment.callback_payload = callback_payload payment.kassa_ai_order_id = kai_intid if target_order.get('curID'): diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 2988f856..be08197f 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -225,6 +225,8 @@ class MulenPayPaymentMixin: if payment_status == 'success': # Inline field updates — NO intermediate commit that would release FOR UPDATE lock payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) payment.callback_payload = callback_data if mulen_payment_id_int is not None and not payment.mulen_payment_id: payment.mulen_payment_id = mulen_payment_id_int diff --git a/app/services/payment/riopay.py b/app/services/payment/riopay.py index 3c0b70a6..ac008e49 100644 --- a/app/services/payment/riopay.py +++ b/app/services/payment/riopay.py @@ -323,7 +323,7 @@ class RioPayPaymentMixin: ) return False - # Создаем транзакцию + # Создаем транзакцию (commit=False to keep FOR UPDATE lock intact) transaction = await create_transaction( db, user_id=payment.user_id, @@ -334,15 +334,13 @@ class RioPayPaymentMixin: external_id=str(riopay_order_id) if riopay_order_id else payment.order_id, is_completed=True, created_at=getattr(payment, 'created_at', None), + commit=False, ) - # Связываем платеж с транзакцией - await update_riopay_payment_status( - db=db, - payment=payment, - status=payment.status, - transaction_id=transaction.id, - ) + # Связываем платеж с транзакцией (inline — no commit to preserve lock) + payment.transaction_id = transaction.id + payment.updated_at = datetime.now(UTC) + await db.flush() old_balance = user.balance_kopeks was_first_topup = not user.has_made_first_topup @@ -365,6 +363,22 @@ class RioPayPaymentMixin: await db.commit() + # Emit deferred side-effects after atomic commit (events, promo group checks) + try: + from app.database.crud.transaction import emit_transaction_side_effects + + await emit_transaction_side_effects( + db, + transaction, + amount_kopeks=payment.amount_kopeks, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + payment_method=PaymentMethod.RIOPAY, + external_id=str(riopay_order_id) if riopay_order_id else payment.order_id, + ) + except Exception as error: + logger.error('Ошибка emit_transaction_side_effects RioPay', error=error) + # Обработка реферального пополнения try: from app.services.referral_service import process_referral_topup diff --git a/app/services/payment/severpay.py b/app/services/payment/severpay.py index a3941636..50630b9e 100644 --- a/app/services/payment/severpay.py +++ b/app/services/payment/severpay.py @@ -266,9 +266,11 @@ class SeverPayPaymentMixin: # Inline field assignments to keep FOR UPDATE lock intact payment.status = internal_status payment.is_paid = True + payment.paid_at = datetime.now(UTC) payment.severpay_id = severpay_id or payment.severpay_id payment.callback_payload = callback_payload payment.updated_at = datetime.now(UTC) + await db.flush() return await self._finalize_severpay_payment(db, payment, severpay_id=severpay_id, trigger='webhook') # Для не-success статусов можно безопасно коммитить @@ -581,7 +583,7 @@ class SeverPayPaymentMixin: # Inline field updates — NO intermediate commit that would release FOR UPDATE lock payment.status = 'success' payment.is_paid = True - payment.severpay_id = payment.severpay_id + payment.paid_at = datetime.now(UTC) payment.callback_payload = { 'check_source': 'api', 'severpay_order_data': order_data, From 2a72deadd6ac3baea15a8d4ee414d763c16690b0 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 02:21:25 +0300 Subject: [PATCH 10/16] fix: resolve EmailService stale SMTP config causing NoneType crash on from_email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmailService singleton cached SMTP settings in __init__ at import time. is_configured() read live from settings, but self.from_email stayed None when SMTP was unconfigured at startup → AttributeError on .split('@'). Replace cached attributes with @property accessors, snapshot from_email once per send_email call with validation guard. --- app/cabinet/services/email_service.py | 46 ++++++++++++++++++++------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/app/cabinet/services/email_service.py b/app/cabinet/services/email_service.py index 1f8a1964..00964f83 100644 --- a/app/cabinet/services/email_service.py +++ b/app/cabinet/services/email_service.py @@ -17,14 +17,33 @@ logger = structlog.get_logger(__name__) class EmailService: """Service for sending emails via SMTP.""" - def __init__(self): - self.host = settings.SMTP_HOST - self.port = settings.SMTP_PORT - self.user = settings.SMTP_USER - self.password = settings.SMTP_PASSWORD - self.from_email = settings.get_smtp_from_email() - self.from_name = settings.SMTP_FROM_NAME - self.use_tls = settings.SMTP_USE_TLS + @property + def host(self) -> str | None: + return settings.SMTP_HOST + + @property + def port(self) -> int: + return settings.SMTP_PORT + + @property + def user(self) -> str | None: + return settings.SMTP_USER + + @property + def password(self) -> str | None: + return settings.SMTP_PASSWORD + + @property + def from_email(self) -> str | None: + return settings.get_smtp_from_email() + + @property + def from_name(self) -> str: + return settings.SMTP_FROM_NAME + + @property + def use_tls(self) -> bool: + return settings.SMTP_USE_TLS def is_configured(self) -> bool: """Check if SMTP is properly configured.""" @@ -71,6 +90,11 @@ class EmailService: logger.warning('SMTP is not configured, cannot send email') return False + sender_email = self.from_email + if not sender_email or '@' not in sender_email: + logger.error('Invalid or missing SMTP from_email, cannot send email', from_email=sender_email) + return False + # Defensive: strip newlines to prevent header injection to_email = to_email.strip().replace('\n', '').replace('\r', '') subject = subject.replace('\n', '').replace('\r', '') @@ -79,11 +103,11 @@ class EmailService: msg = MIMEMultipart('alternative') msg['Subject'] = subject safe_from_name = self.from_name.replace('\n', '').replace('\r', '') if self.from_name else '' - safe_from_email = self.from_email.replace('\n', '').replace('\r', '') if self.from_email else '' + safe_from_email = sender_email.replace('\n', '').replace('\r', '') msg['From'] = f'{safe_from_name} <{safe_from_email}>' msg['To'] = to_email msg['Date'] = formatdate(localtime=False) - msg['Message-ID'] = make_msgid(domain=self.from_email.split('@')[-1]) + msg['Message-ID'] = make_msgid(domain=safe_from_email.split('@')[-1]) # Plain text version if body_text is None: @@ -103,7 +127,7 @@ class EmailService: msg.attach(part2) with self._get_smtp_connection() as smtp: - smtp.sendmail(self.from_email, to_email, msg.as_string()) + smtp.sendmail(safe_from_email, to_email, msg.as_string()) logger.info('Email sent successfully to', to_email=to_email) return True From 82b6a8bf707736541b58637b0fc9a84b0c403a6c Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 02:32:35 +0300 Subject: [PATCH 11/16] feat: add SOCKS5 proxy support for Telegram API traffic Route bot traffic through SOCKS5 proxy when PROXY_URL env var is set. Validates scheme to reject HTTP proxies (would expose bot token). Credentials are masked in logs. --- .env.example | 5 +++++ app/bot.py | 14 +++++++++++++- app/config.py | 25 +++++++++++++++++++++++++ pyproject.toml | 1 + requirements.txt | 1 + uv.lock | 26 +++++++++++++++++++++++++- 6 files changed, 70 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 36bdac6b..6c290d9c 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ SUPPORT_USERNAME=@support # Имя пользователя бота (опционально, автоопределяется) # BOT_USERNAME= +# ===== SOCKS5 ПРОКСИ ===== +# URL SOCKS5 прокси-сервера для маршрутизации трафика бота к Telegram API +# Формат: socks5://user:password@host:port или socks5://host:port +# PROXY_URL=socks5://127.0.0.1:1080 + # ===== СИСТЕМА ПОДДЕРЖКИ ===== # Включить меню поддержки в интерфейсе SUPPORT_MENU_ENABLED=true diff --git a/app/bot.py b/app/bot.py index f456798a..e4c92dbb 100644 --- a/app/bot.py +++ b/app/bot.py @@ -99,7 +99,19 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode - bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + proxy_url = settings.get_proxy_url() + session = None + if proxy_url: + from urllib.parse import urlparse + + from aiogram.client.session.aiohttp import AiohttpSession + + session = AiohttpSession(proxy=proxy_url) + parsed = urlparse(proxy_url) + masked = f'{parsed.scheme}://***@{parsed.hostname}:{parsed.port}' if parsed.username else proxy_url + logger.info('Proxy configured', proxy_url=masked) + + bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML), session=session) maintenance_service.set_bot(bot) logger.info('Бот установлен в maintenance_service') diff --git a/app/config.py b/app/config.py index 14b984a1..30fd9f2b 100644 --- a/app/config.py +++ b/app/config.py @@ -802,6 +802,27 @@ class Settings(BaseSettings): BAN_SYSTEM_API_TOKEN: str | None = None BAN_SYSTEM_REQUEST_TIMEOUT: int = 30 + # SOCKS5 proxy for routing bot traffic to Telegram API + # Format: socks5://user:password@host:port or socks5://host:port + PROXY_URL: str | None = None + + @field_validator('PROXY_URL', mode='before') + @classmethod + def validate_proxy_url(cls, value: str | None) -> str | None: + if not value: + return None + from urllib.parse import urlparse + + parsed = urlparse(value) + if parsed.scheme not in ('socks5', 'socks4'): + raise ValueError( + f'PROXY_URL must use socks5:// or socks4:// scheme, got: {parsed.scheme!r}. ' + 'HTTP proxies are not supported for security reasons (bot token would be exposed).' + ) + if not parsed.hostname: + raise ValueError('PROXY_URL must contain a hostname') + return value + @field_validator('MAIN_MENU_MODE', mode='before') @classmethod def normalize_main_menu_mode(cls, value: str | None) -> str: @@ -928,6 +949,10 @@ class Settings(BaseSettings): """Проверяет, используется ли SQLite""" return 'sqlite' in self.get_database_url() + def get_proxy_url(self) -> str | None: + """Return SOCKS5 proxy URL or None.""" + return self.PROXY_URL if self.PROXY_URL else None + def is_admin(self, telegram_id: int | None = None, email: str | None = None) -> bool: """ Check if user is admin by telegram_id or email. diff --git a/pyproject.toml b/pyproject.toml index 6c98fbee..6a9d0ba1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ 'pyzipper>=0.3.6', 'structlog>=25.1.0,<26', 'rich>=14.0', + 'aiohttp-socks>=0.10.1', ] [dependency-groups] diff --git a/requirements.txt b/requirements.txt index 3065a0d2..f94b1657 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # Основные зависимости aiogram==3.22.0 aiohttp==3.12.15 +aiohttp-socks>=0.10.1 asyncpg==0.31.0 SQLAlchemy==2.0.46 alembic==1.18.4 diff --git a/uv.lock b/uv.lock index 392c7554..b04db498 100644 --- a/uv.lock +++ b/uv.lock @@ -71,6 +71,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, ] +[[package]] +name = "aiohttp-socks" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "python-socks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/cc/e5bbd54f76bd56291522251e47267b645dac76327b2657ade9545e30522c/aiohttp_socks-0.11.0.tar.gz", hash = "sha256:0afe51638527c79077e4bd6e57052c87c4824233d6e20bb061c53766421b10f0", size = 11196, upload-time = "2025-12-09T13:35:52.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/7d/4b633d709b8901d59444d2e512b93e72fe62d2b492a040097c3f7ba017bb/aiohttp_socks-0.11.0-py3-none-any.whl", hash = "sha256:9aacce57c931b8fbf8f6d333cf3cafe4c35b971b35430309e167a35a8aab9ec1", size = 10556, upload-time = "2025-12-09T13:35:50.18Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -1057,6 +1070,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, ] +[[package]] +name = "python-socks" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz", hash = "sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687", size = 38909, upload-time = "2026-02-16T05:24:00.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1115,10 +1137,11 @@ wheels = [ [[package]] name = "remnawave-bedolaga-telegram-bot" -version = "3.34.1" +version = "3.36.0" source = { virtual = "." } dependencies = [ { name = "aiogram" }, + { name = "aiohttp-socks" }, { name = "aiosqlite" }, { name = "alembic" }, { name = "asyncpg" }, @@ -1149,6 +1172,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiogram", specifier = ">=3.25.0" }, + { name = "aiohttp-socks", specifier = ">=0.10.1" }, { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "alembic", specifier = ">=1.18.4" }, { name = "asyncpg", specifier = ">=0.31.0" }, From 0a53b85b8a3193f67e148c0f2bde246cc92f010c Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 02:49:37 +0300 Subject: [PATCH 12/16] refactor: centralize Bot instantiation via create_bot() factory Replace all ~45 direct Bot() calls across the codebase with a centralized create_bot() factory function that automatically configures SOCKS5 proxy session when PROXY_URL is set. This ensures proxy support applies uniformly to all Telegram API traffic. Key changes: - Add app/bot_factory.py with create_bot() factory - Replace direct Bot() instantiation in 33 files - Fix session leaks in cloudpayments.py and auth.py (async with) - Replace 2 direct httpx calls to api.telegram.org with bot.create_invoice_link() (balance.py, wheel.py) - Remove now-unused imports (Bot, DefaultBotProperties, ParseMode, httpx) --- app/bot.py | 11 +--- app/bot_factory.py | 20 ++++++ app/cabinet/routes/admin_partners.py | 10 ++- app/cabinet/routes/admin_payments.py | 7 +- app/cabinet/routes/admin_pinned_messages.py | 9 +-- app/cabinet/routes/admin_promo_offers.py | 9 +-- app/cabinet/routes/admin_roles.py | 4 +- app/cabinet/routes/admin_tickets.py | 9 +-- app/cabinet/routes/admin_traffic.py | 10 +-- app/cabinet/routes/admin_withdrawals.py | 10 ++- app/cabinet/routes/auth.py | 25 +++----- app/cabinet/routes/balance.py | 67 ++++++++------------ app/cabinet/routes/gift.py | 8 +-- app/cabinet/routes/media.py | 14 +--- app/cabinet/routes/partner_application.py | 5 +- app/cabinet/routes/subscription.py | 35 ++++------ app/cabinet/routes/wheel.py | 54 ++++++---------- app/cabinet/routes/withdrawal.py | 5 +- app/database/crud/cryptobot.py | 4 +- app/handlers/referral.py | 5 +- app/services/guest_purchase_service.py | 29 ++++----- app/services/payment/cloudpayments.py | 56 ++++++---------- app/services/payment/freekassa.py | 8 +-- app/services/payment/kassa_ai.py | 8 +-- app/services/payment/pal24.py | 2 +- app/services/promo_group_assignment.py | 5 +- app/services/rbac_bootstrap_service.py | 3 +- app/services/subscription_renewal_service.py | 3 +- app/webapi/routes/media.py | 14 +--- app/webapi/routes/miniapp.py | 6 +- app/webapi/routes/pinned_messages.py | 9 +-- app/webapi/routes/polls.py | 10 +-- app/webapi/routes/tickets.py | 15 +---- main.py | 5 +- 34 files changed, 180 insertions(+), 314 deletions(-) create mode 100644 app/bot_factory.py diff --git a/app/bot.py b/app/bot.py index e4c92dbb..3d656be7 100644 --- a/app/bot.py +++ b/app/bot.py @@ -96,23 +96,18 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: except Exception as e: logger.warning('Кеш не инициализирован', error=e) - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode + from app.bot_factory import create_bot + + bot = create_bot() proxy_url = settings.get_proxy_url() - session = None if proxy_url: from urllib.parse import urlparse - from aiogram.client.session.aiohttp import AiohttpSession - - session = AiohttpSession(proxy=proxy_url) parsed = urlparse(proxy_url) masked = f'{parsed.scheme}://***@{parsed.hostname}:{parsed.port}' if parsed.username else proxy_url logger.info('Proxy configured', proxy_url=masked) - bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML), session=session) - maintenance_service.set_bot(bot) logger.info('Бот установлен в maintenance_service') diff --git a/app/bot_factory.py b/app/bot_factory.py new file mode 100644 index 00000000..89e6b5f6 --- /dev/null +++ b/app/bot_factory.py @@ -0,0 +1,20 @@ +"""Factory for creating Bot instances with proxy support.""" + +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode + +from app.config import settings + + +def create_bot(token: str | None = None, **kwargs) -> Bot: + """Create a Bot instance with SOCKS5 proxy session if PROXY_URL is configured.""" + proxy_url = settings.get_proxy_url() + session = None + if proxy_url: + from aiogram.client.session.aiohttp import AiohttpSession + + session = AiohttpSession(proxy=proxy_url) + + kwargs.setdefault('default', DefaultBotProperties(parse_mode=ParseMode.HTML)) + return Bot(token=token or settings.BOT_TOKEN, session=session, **kwargs) diff --git a/app/cabinet/routes/admin_partners.py b/app/cabinet/routes/admin_partners.py index 6f6d65a8..0997f566 100644 --- a/app/cabinet/routes/admin_partners.py +++ b/app/cabinet/routes/admin_partners.py @@ -227,8 +227,7 @@ async def approve_application( # Notify user about approval try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.config import settings from app.services.notification_delivery_service import notification_delivery_service @@ -240,7 +239,7 @@ async def approve_application( tg_message = ( f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}' ) - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: await notification_delivery_service.notify_partner_approved( user=user, @@ -280,8 +279,7 @@ async def reject_application( # Notify user about rejection try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.config import settings from app.services.notification_delivery_service import notification_delivery_service @@ -291,7 +289,7 @@ async def reject_application( if user: comment_text = f'\nПричина: {request.comment}' if request.comment else '' tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}' - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: await notification_delivery_service.notify_partner_rejected( user=user, diff --git a/app/cabinet/routes/admin_payments.py b/app/cabinet/routes/admin_payments.py index 030137c8..b082754e 100644 --- a/app/cabinet/routes/admin_payments.py +++ b/app/cabinet/routes/admin_payments.py @@ -4,14 +4,11 @@ import math from datetime import UTC, datetime, timedelta import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings +from app.bot_factory import create_bot from app.database.models import PaymentMethod, User from app.services.payment_search_service import ( MAX_ALL_TIME_DAYS, @@ -550,7 +547,7 @@ async def check_payment_status( old_is_paid = record.is_paid # Run manual check - bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + bot = create_bot() try: payment_service = PaymentService(bot=bot) updated = await run_manual_check(db, payment_method, payment_id, payment_service) diff --git a/app/cabinet/routes/admin_pinned_messages.py b/app/cabinet/routes/admin_pinned_messages.py index 77dc0341..f780893b 100644 --- a/app/cabinet/routes/admin_pinned_messages.py +++ b/app/cabinet/routes/admin_pinned_messages.py @@ -5,13 +5,11 @@ from datetime import UTC, datetime import structlog from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings +from app.bot_factory import create_bot from app.database.models import PinnedMessage, User from app.services.pinned_message_service import ( broadcast_pinned_message, @@ -77,10 +75,7 @@ _cached_bot: Bot | None = None def _get_bot() -> Bot: global _cached_bot if _cached_bot is None: - _cached_bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + _cached_bot = create_bot() return _cached_bot diff --git a/app/cabinet/routes/admin_promo_offers.py b/app/cabinet/routes/admin_promo_offers.py index a7102219..65e73acb 100644 --- a/app/cabinet/routes/admin_promo_offers.py +++ b/app/cabinet/routes/admin_promo_offers.py @@ -8,15 +8,13 @@ from typing import Any import structlog from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings +from app.bot_factory import create_bot from app.database.crud.discount_offer import ( count_discount_offers, list_discount_offers, @@ -369,10 +367,7 @@ async def list_offers( def _get_bot() -> Bot: """Create bot instance for sending notifications.""" - return Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + return create_bot() def _build_default_promo_message( diff --git a/app/cabinet/routes/admin_roles.py b/app/cabinet/routes/admin_roles.py index e1c4df00..c7191c29 100644 --- a/app/cabinet/routes/admin_roles.py +++ b/app/cabinet/routes/admin_roles.py @@ -511,9 +511,7 @@ async def revoke_role( from app.database.models import UserRole # Lock the assignment row (FOR UPDATE held until commit) - result = await db.execute( - sa.select(UserRole).where(UserRole.id == assignment_id).with_for_update() - ) + result = await db.execute(sa.select(UserRole).where(UserRole.id == assignment_id).with_for_update()) user_role = result.scalar_one_or_none() if not user_role: raise HTTPException( diff --git a/app/cabinet/routes/admin_tickets.py b/app/cabinet/routes/admin_tickets.py index 3778f42b..24ee2ad4 100644 --- a/app/cabinet/routes/admin_tickets.py +++ b/app/cabinet/routes/admin_tickets.py @@ -479,14 +479,9 @@ async def reply_to_ticket( # Try to notify user via Telegram try: - from aiogram import Bot - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode + from app.bot_factory import create_bot - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: from app.handlers.admin.tickets import notify_user_about_ticket_reply diff --git a/app/cabinet/routes/admin_traffic.py b/app/cabinet/routes/admin_traffic.py index 8c53fac1..e59ac1fe 100644 --- a/app/cabinet/routes/admin_traffic.py +++ b/app/cabinet/routes/admin_traffic.py @@ -7,16 +7,13 @@ import time from datetime import UTC, datetime, timedelta import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from aiogram.types import BufferedInputFile from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.config import settings +from app.bot_factory import create_bot from app.database.models import Subscription, Transaction, TransactionType, User from app.services.remnawave_service import RemnaWaveService @@ -680,10 +677,7 @@ async def export_traffic_csv( filename = f'traffic_usage_{period_label}_{timestamp}.csv' try: - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() async with bot: await bot.send_document( chat_id=admin.telegram_id, diff --git a/app/cabinet/routes/admin_withdrawals.py b/app/cabinet/routes/admin_withdrawals.py index 9016e78f..956b18f7 100644 --- a/app/cabinet/routes/admin_withdrawals.py +++ b/app/cabinet/routes/admin_withdrawals.py @@ -199,8 +199,7 @@ async def approve_withdrawal( # Notify user about approval try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.config import settings from app.services.notification_delivery_service import notification_delivery_service @@ -211,7 +210,7 @@ async def approve_withdrawal( formatted_amount = settings.format_price(withdrawal.amount_kopeks) comment_text = f'\n{request.comment}' if request.comment else '' tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}' - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: await notification_delivery_service.notify_withdrawal_approved( user=user, @@ -251,8 +250,7 @@ async def reject_withdrawal( # Notify user about rejection try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.config import settings from app.services.notification_delivery_service import notification_delivery_service @@ -263,7 +261,7 @@ async def reject_withdrawal( formatted_amount = settings.format_price(withdrawal.amount_kopeks) comment_text = f'\nПричина: {request.comment}' if request.comment else '' tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}' - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: await notification_delivery_service.notify_withdrawal_rejected( user=user, diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index f2adcd7b..d47cce94 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -196,12 +196,10 @@ async def _process_campaign_bonus( user.referred_by_id = campaign.partner_user_id await db.flush() try: - from aiogram import Bot - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode + from app.bot_factory import create_bot - 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) + async with create_bot() as bot: + await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot) logger.info( 'Referral set from campaign partner', user_id=user.id, @@ -255,12 +253,11 @@ async def _process_referral_code( return user.referred_by_id = referrer.id await db.flush() - 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) + from app.bot_factory import create_bot + + async with create_bot() as bot: + 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) @@ -937,12 +934,10 @@ async def register_email_standalone( # Обработать реферальную регистрацию (если есть реферер) if referrer: try: - from aiogram import Bot - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode + from app.bot_factory import create_bot - bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) - await process_referral_registration(db, user.id, referrer.id, bot=bot) + async with create_bot() as bot: + 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/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index db0b7771..d1bb2842 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -4,15 +4,12 @@ import math import time from decimal import ROUND_HALF_UP, Decimal, InvalidOperation -import httpx import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.bot_factory import create_bot from app.config import settings from app.database.crud.saved_payment_method import ( deactivate_payment_method, @@ -272,50 +269,36 @@ async def create_stars_invoice( # Create invoice through Telegram Bot API try: - bot_token = settings.BOT_TOKEN - api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink' + from aiogram.types import LabeledPrice - async with httpx.AsyncClient() as client: - response = await client.post( - api_url, - json={ - 'title': 'Пополнение баланса VPN', - 'description': f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)', - 'payload': payload, - 'provider_token': '', # Empty for Stars - 'currency': 'XTR', - 'prices': [{'label': 'Пополнение баланса', 'amount': stars_amount}], - }, + async with create_bot() as bot: + invoice_url = await bot.create_invoice_link( + title='Пополнение баланса VPN', + description=f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)', + payload=payload, + provider_token='', + currency='XTR', + prices=[LabeledPrice(label='Пополнение баланса', amount=stars_amount)], ) - result = response.json() + logger.info( + 'Created Stars invoice for balance top-up: user=, amount= kopeks, stars', + user_id=user.id, + amount_kopeks=request.amount_kopeks, + stars_amount=stars_amount, + ) - if not result.get('ok'): - logger.error('Telegram API error', result=result) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail='Failed to create Stars invoice', - ) + return StarsInvoiceResponse( + invoice_url=invoice_url, + stars_amount=stars_amount, + amount_kopeks=normalized_kopeks, + ) - invoice_url = result['result'] - logger.info( - 'Created Stars invoice for balance top-up: user=, amount= kopeks, stars', - user_id=user.id, - amount_kopeks=request.amount_kopeks, - stars_amount=stars_amount, - ) - - return StarsInvoiceResponse( - invoice_url=invoice_url, - stars_amount=stars_amount, - amount_kopeks=normalized_kopeks, - ) - - except httpx.HTTPError as e: - logger.error('HTTP error creating Stars invoice', error=e) + except Exception as e: + logger.error('Error creating Stars invoice', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail='Failed to connect to Telegram API', + detail='Failed to create Stars invoice', ) @@ -1202,7 +1185,7 @@ async def check_payment_status( old_is_paid = record.is_paid # Run manual check - bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + bot = create_bot() try: payment_service = PaymentService(bot=bot) updated = await run_manual_check(db, payment_method, payment_id, payment_service) diff --git a/app/cabinet/routes/gift.py b/app/cabinet/routes/gift.py index 9bc3bcc5..0d107360 100644 --- a/app/cabinet/routes/gift.py +++ b/app/cabinet/routes/gift.py @@ -306,9 +306,9 @@ async def create_gift_purchase( else: # 2) Fall back to Bot API (works for public usernames the bot has seen) try: - from aiogram import Bot + from app.bot_factory import create_bot - async with Bot(token=settings.BOT_TOKEN) as bot: + async with create_bot() as bot: chat = await asyncio.wait_for(bot.get_chat(chat_id=f'@{tg_username}'), timeout=5.0) pre_resolved_telegram_id = chat.id except Exception: @@ -371,9 +371,9 @@ async def create_gift_purchase( # Stars payments need a Bot instance to create invoice links bot = None if body.payment_method == 'telegram_stars': - from aiogram import Bot + from app.bot_factory import create_bot - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() payment_service = PaymentService(bot=bot) payment_result = await payment_service.create_guest_payment( diff --git a/app/cabinet/routes/media.py b/app/cabinet/routes/media.py index 4cc99427..23f5c955 100644 --- a/app/cabinet/routes/media.py +++ b/app/cabinet/routes/media.py @@ -3,13 +3,11 @@ import mimetypes import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from aiogram.types import BufferedInputFile from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status from pydantic import BaseModel +from app.bot_factory import create_bot from app.config import settings from app.database.models import User @@ -98,10 +96,7 @@ async def upload_media( target_chat_id = _resolve_target_chat_id() upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload') - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: if media_type_normalized == 'photo': @@ -158,10 +153,7 @@ async def download_media( Download media file by file_id. Used to display images/documents in ticket messages. """ - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: file = await bot.get_file(file_id) diff --git a/app/cabinet/routes/partner_application.py b/app/cabinet/routes/partner_application.py index 13b551da..6b2e3999 100644 --- a/app/cabinet/routes/partner_application.py +++ b/app/cabinet/routes/partner_application.py @@ -178,12 +178,11 @@ async def apply_for_partner( # Уведомляем админов о новой заявке try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) await notification_service.send_partner_application_notification( diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index da060680..641fa799 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -815,12 +815,11 @@ async def purchase_traffic( # Отправляем уведомление админам try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) old_traffic = subscription.traffic_limit_gb - request.gb @@ -1043,12 +1042,11 @@ async def purchase_devices_legacy( # Отправляем уведомление админам try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) await notification_service.send_subscription_update_notification( @@ -1344,12 +1342,11 @@ async def activate_trial( # Send admin notification about trial activation try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) charged_amount = settings.TRIAL_ACTIVATION_PRICE if requires_payment else None @@ -1763,12 +1760,11 @@ async def submit_purchase( # Отправляем уведомление админам о покупке подписки try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) is_new_subscription = result.get('was_trial_conversion') or not context.subscription @@ -2161,12 +2157,11 @@ async def purchase_tariff( # Отправляем уведомление админам о покупке/продлении тарифа try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) # Определяем тип покупки: новая подписка или продление @@ -2418,12 +2413,11 @@ async def purchase_devices( # Отправляем уведомление админам try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) await notification_service.send_subscription_update_notification( @@ -4204,12 +4198,11 @@ async def switch_tariff( # Отправляем уведомление админам о смене тарифа try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) await notification_service.send_subscription_purchase_notification( diff --git a/app/cabinet/routes/wheel.py b/app/cabinet/routes/wheel.py index a8f965b1..9dcfe447 100644 --- a/app/cabinet/routes/wheel.py +++ b/app/cabinet/routes/wheel.py @@ -5,7 +5,6 @@ API роуты колеса удачи для пользователей. import math import time -import httpx import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel @@ -21,7 +20,6 @@ from app.cabinet.schemas.wheel import ( WheelConfigResponse, WheelPrizeDisplay, ) -from app.config import settings from app.database.crud.wheel import ( get_or_create_wheel_config, get_user_spin_history, @@ -251,44 +249,30 @@ async def create_stars_invoice( # Создаем invoice через Telegram Bot API try: - bot_token = settings.BOT_TOKEN - api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink' + from aiogram.types import LabeledPrice - async with httpx.AsyncClient() as client: - response = await client.post( - api_url, - json={ - 'title': 'Колесо удачи', - 'description': f'Спин колеса удачи ({stars_amount} ⭐)', - 'payload': payload, - 'provider_token': '', # Пустой для Stars - 'currency': 'XTR', - 'prices': [{'label': 'Спин колеса', 'amount': stars_amount}], - }, + from app.bot_factory import create_bot + + async with create_bot() as bot: + invoice_url = await bot.create_invoice_link( + title='Колесо удачи', + description=f'Спин колеса удачи ({stars_amount} ⭐)', + payload=payload, + provider_token='', + currency='XTR', + prices=[LabeledPrice(label='Спин колеса', amount=stars_amount)], ) - result = response.json() + logger.info('Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount) - if not result.get('ok'): - logger.error('Telegram API error', result=result) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail='Ошибка создания инвойса', - ) + return StarsInvoiceResponse( + invoice_url=invoice_url, + stars_amount=stars_amount, + ) - invoice_url = result['result'] - logger.info( - 'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount - ) - - return StarsInvoiceResponse( - invoice_url=invoice_url, - stars_amount=stars_amount, - ) - - except httpx.HTTPError as e: - logger.error('HTTP error creating invoice', error=e) + except Exception as e: + logger.error('Error creating invoice', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail='Ошибка соединения с Telegram', + detail='Ошибка создания инвойса', ) diff --git a/app/cabinet/routes/withdrawal.py b/app/cabinet/routes/withdrawal.py index 132298f3..c7e9d69f 100644 --- a/app/cabinet/routes/withdrawal.py +++ b/app/cabinet/routes/withdrawal.py @@ -70,12 +70,11 @@ async def create_withdrawal( # Уведомляем админов о запросе на вывод try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) await notification_service.send_withdrawal_request_notification( diff --git a/app/database/crud/cryptobot.py b/app/database/crud/cryptobot.py index ee7b3fc3..309844d0 100644 --- a/app/database/crud/cryptobot.py +++ b/app/database/crud/cryptobot.py @@ -67,9 +67,7 @@ async def get_cryptobot_payment_by_id(db: AsyncSession, payment_id: int) -> Cryp return result.scalar_one_or_none() -async def get_cryptobot_payment_by_invoice_id_for_update( - db: AsyncSession, invoice_id: str -) -> CryptoBotPayment | None: +async def get_cryptobot_payment_by_invoice_id_for_update(db: AsyncSession, invoice_id: str) -> CryptoBotPayment | None: result = await db.execute( select(CryptoBotPayment) .options(selectinload(CryptoBotPayment.user)) diff --git a/app/handlers/referral.py b/app/handlers/referral.py index f68a4e6c..56b0f947 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -540,10 +540,7 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User): # Compact share text for switch_inline_query (256-char limit) share_text = invite_text if len(share_text) > 256: - share_text = ( - texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') - + f'\n\n👇 {bot_referral_link}' - ) + share_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') + f'\n\n👇 {bot_referral_link}' if cabinet_referral_link and len(share_text) + len(cabinet_referral_link) + 5 <= 256: share_text += f'\n🌐 {cabinet_referral_link}' share_text = share_text[:256] diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py index e5fadc5f..3bb36cbc 100644 --- a/app/services/guest_purchase_service.py +++ b/app/services/guest_purchase_service.py @@ -46,11 +46,10 @@ async def _send_admin_notification( if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN: return try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService - async with Bot(token=settings.BOT_TOKEN) as bot: + async with create_bot() as bot: service = AdminNotificationService(bot) await service.send_guest_purchase_notification( purchase, @@ -546,9 +545,9 @@ async def _find_or_create_user( resolved_telegram_id: int | None = pre_resolved_telegram_id if resolved_telegram_id is None: try: - from aiogram import Bot + from app.bot_factory import create_bot - async with Bot(token=settings.BOT_TOKEN) as bot: + async with create_bot() as bot: chat = await asyncio.wait_for( bot.get_chat(chat_id=f'@{username}'), timeout=5.0, @@ -656,11 +655,10 @@ async def _send_telegram_gift_notification( 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 + from app.bot_factory import create_bot + gift_from = '' if purchase.contact_value: safe_name = html_mod.escape(purchase.contact_value) @@ -691,10 +689,7 @@ async def _send_telegram_gift_notification( ] ) - async with Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) as bot: + async with create_bot() as bot: await bot.send_message( chat_id=user.telegram_id, text=text, @@ -1205,8 +1200,7 @@ async def _send_stuck_purchase_alert(data: dict, retry_count: int, phase: str) - try: import html as html_mod - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService, NotificationCategory amount_rub = data['amount_kopeks'] / 100 @@ -1225,7 +1219,7 @@ async def _send_stuck_purchase_alert(data: dict, retry_count: int, phase: str) - f'Requires manual investigation.' ) - async with Bot(token=settings.BOT_TOKEN) as bot: + async with create_bot() as bot: service = AdminNotificationService(bot) await service.send_admin_notification(text, category=NotificationCategory.ERRORS) except Exception: @@ -1244,8 +1238,7 @@ async def _send_amount_mismatch_alert( try: import html as html_mod - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService, NotificationCategory text = ( @@ -1260,7 +1253,7 @@ async def _send_amount_mismatch_alert( f'Requires manual investigation.' ) - async with Bot(token=settings.BOT_TOKEN) as bot: + async with create_bot() as bot: service = AdminNotificationService(bot) await service.send_admin_notification(text, category=NotificationCategory.ERRORS) except Exception: diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index 3248e2c4..fd9966ca 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -452,18 +452,10 @@ class CloudPaymentsPaymentMixin: transaction: Any, ) -> None: """Send success notification to user via Telegram.""" - from aiogram import Bot - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode - from app.config import settings + from app.bot_factory import create_bot from app.localization.texts import get_texts - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) - # Skip email-only users (no telegram_id) if not user.telegram_id: logger.debug('Skipping CloudPayments notification for email-only user', user_id=user.id) @@ -492,15 +484,16 @@ class CloudPaymentsPaymentMixin: if referrer_info: message += f'\n\n{referrer_info}' - try: - await bot.send_message( - chat_id=user.telegram_id, - text=message, - parse_mode='HTML', - reply_markup=keyboard, - ) - except Exception as error: - logger.warning('Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error) + async with create_bot() as bot: + try: + await bot.send_message( + chat_id=user.telegram_id, + text=message, + parse_mode='HTML', + reply_markup=keyboard, + ) + except Exception as error: + logger.warning('Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error) async def _send_cloudpayments_fail_notification( self, @@ -508,27 +501,20 @@ class CloudPaymentsPaymentMixin: message: str, ) -> None: """Send failure notification to user via Telegram.""" - from aiogram import Bot - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode - from app.config import settings - - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + from app.bot_factory import create_bot text = f'❌ Оплата не прошла\n\n{message}' - try: - await bot.send_message( - chat_id=telegram_id, - text=text, - parse_mode='HTML', - ) - except Exception as error: - logger.warning('Не удалось отправить уведомление пользователю', telegram_id=telegram_id, error=error) + async with create_bot() as bot: + try: + await bot.send_message( + chat_id=telegram_id, + text=text, + parse_mode='HTML', + ) + except Exception as error: + logger.warning('Не удалось отправить уведомление пользователю', telegram_id=telegram_id, error=error) async def get_cloudpayments_payment_status( self, diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index 6374b895..5c87750b 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -509,14 +509,10 @@ class FreekassaPaymentMixin: # Lock payment row before finalization to prevent concurrent double-processing locked = await freekassa_crud.get_freekassa_payment_by_id_for_update(db, payment.id) if not locked: - logger.error( - 'Freekassa status check: не удалось заблокировать платёж', payment_id=payment.id - ) + logger.error('Freekassa status check: не удалось заблокировать платёж', payment_id=payment.id) elif locked.is_paid: # Another concurrent handler already processed — skip - logger.info( - 'Freekassa платеж уже оплачен после блокировки', order_id=locked.order_id - ) + logger.info('Freekassa платеж уже оплачен после блокировки', order_id=locked.order_id) payment = locked else: payment = locked diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py index c0821bd1..3c7b032b 100644 --- a/app/services/payment/kassa_ai.py +++ b/app/services/payment/kassa_ai.py @@ -488,14 +488,10 @@ class KassaAiPaymentMixin: # Lock payment row before finalization to prevent concurrent double-processing locked = await kassa_ai_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id) if not locked: - logger.error( - 'KassaAI status check: не удалось заблокировать платёж', payment_id=payment.id - ) + logger.error('KassaAI status check: не удалось заблокировать платёж', payment_id=payment.id) elif locked.is_paid: # Another concurrent handler already processed — skip - logger.info( - 'KassaAI платеж уже оплачен после блокировки', order_id=locked.order_id - ) + logger.info('KassaAI платеж уже оплачен после блокировки', order_id=locked.order_id) payment = locked else: payment = locked diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 2789fdda..d7c23c0c 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -264,7 +264,7 @@ class Pal24PaymentMixin: payment.callback_payload = callback if payment_id is not None: payment.payment_id = payment_id - payment.payment_status = (callback.get('Status') or status) + payment.payment_status = callback.get('Status') or status payment.payment_method = ( callback.get('payment_method') or callback.get('PaymentMethod') diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py index 468eaa63..5b34a6f0 100644 --- a/app/services/promo_group_assignment.py +++ b/app/services/promo_group_assignment.py @@ -1,11 +1,10 @@ from datetime import UTC, datetime import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.bot_factory import create_bot from app.config import settings from app.database.crud.transaction import get_user_total_spent_kopeks from app.database.crud.user import lock_user_for_update @@ -31,7 +30,7 @@ async def _notify_admins_about_auto_assignment( logger.debug('BOT_TOKEN не настроен — пропускаем уведомление о промогруппе') return - bot = Bot(token=bot_token, default=DefaultBotProperties(parse_mode='HTML')) + bot = create_bot(token=bot_token) try: notification_service = AdminNotificationService(bot) reason = ( diff --git a/app/services/rbac_bootstrap_service.py b/app/services/rbac_bootstrap_service.py index 0111059b..82fda20f 100644 --- a/app/services/rbac_bootstrap_service.py +++ b/app/services/rbac_bootstrap_service.py @@ -251,8 +251,7 @@ async def _warn_if_no_superadmins( ) else: logger.warning( - 'No active superadmin RBAC roles in DB. ' - 'Legacy config admins (ADMIN_IDS/ADMIN_EMAILS) still have access.', + 'No active superadmin RBAC roles in DB. Legacy config admins (ADMIN_IDS/ADMIN_EMAILS) still have access.', ) diff --git a/app/services/subscription_renewal_service.py b/app/services/subscription_renewal_service.py index ce5be416..25396204 100644 --- a/app/services/subscription_renewal_service.py +++ b/app/services/subscription_renewal_service.py @@ -12,6 +12,7 @@ import structlog from aiogram import Bot from sqlalchemy.ext.asyncio import AsyncSession +from app.bot_factory import create_bot from app.config import settings from app.database.crud.subscription import ( add_subscription_servers, @@ -337,7 +338,7 @@ async def with_admin_notification_service( bot: Bot | None = None try: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() service = AdminNotificationService(bot) await handler(service) except Exception as error: # pragma: no cover - defensive logging diff --git a/app/webapi/routes/media.py b/app/webapi/routes/media.py index 9e30d600..7e50a386 100644 --- a/app/webapi/routes/media.py +++ b/app/webapi/routes/media.py @@ -4,9 +4,6 @@ import mimetypes from typing import Any import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from aiogram.types import BufferedInputFile from fastapi import ( APIRouter, @@ -20,6 +17,7 @@ from fastapi import ( status, ) +from app.bot_factory import create_bot from app.config import settings from ..dependencies import require_api_token @@ -72,10 +70,7 @@ async def upload_media( target_chat_id = _resolve_target_chat_id() upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload') - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: if media_type_normalized == 'photo': @@ -121,10 +116,7 @@ async def download_media( file_id: str, _: Any = Security(require_api_token), ) -> Response: - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: file = await bot.get_file(file_id) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 8baab883..65b3a29e 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -10,13 +10,13 @@ from typing import Any from uuid import uuid4 import structlog -from aiogram import Bot from fastapi import APIRouter, Depends, HTTPException, status from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.bot_factory import create_bot from app.config import settings from app.database.crud.discount_offer import ( get_latest_claimed_offer_for_user, @@ -928,7 +928,7 @@ async def create_payment_link( detail='Failed to prepare Stars payment', ) from exc - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() invoice_payload = _build_balance_invoice_payload(user.id, amount_kopeks) try: payment_service = PaymentService(bot) @@ -1399,7 +1399,7 @@ async def create_payment_link( if not settings.BOT_TOKEN: raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Bot token is not configured') - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: tribute_service = TributeService(bot) payment_url = await tribute_service.create_payment_link( diff --git a/app/webapi/routes/pinned_messages.py b/app/webapi/routes/pinned_messages.py index 584abc93..3d526bfc 100644 --- a/app/webapi/routes/pinned_messages.py +++ b/app/webapi/routes/pinned_messages.py @@ -4,13 +4,11 @@ from datetime import UTC, datetime from typing import Any, Optional from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings +from app.bot_factory import create_bot from app.database.models import PinnedMessage from app.services.pinned_message_service import ( broadcast_pinned_message, @@ -52,10 +50,7 @@ def _serialize_pinned_message(msg: PinnedMessage) -> PinnedMessageResponse: def _get_bot() -> Bot: """Создать экземпляр бота для API операций.""" - return Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + return create_bot() @router.get('', response_model=PinnedMessageListResponse) diff --git a/app/webapi/routes/polls.py b/app/webapi/routes/polls.py index 016a3dac..e0844a5f 100644 --- a/app/webapi/routes/polls.py +++ b/app/webapi/routes/polls.py @@ -2,9 +2,6 @@ from __future__ import annotations from typing import Any -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from fastapi import ( APIRouter, Depends, @@ -328,12 +325,9 @@ async def send_poll( total=0, ) - from app.config import settings + from app.bot_factory import create_bot - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: result = await send_poll_to_users(bot, db, poll, users) diff --git a/app/webapi/routes/tickets.py b/app/webapi/routes/tickets.py index a66f7a2a..01eba7f4 100644 --- a/app/webapi/routes/tickets.py +++ b/app/webapi/routes/tickets.py @@ -4,13 +4,10 @@ from datetime import UTC, datetime from typing import Any import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from fastapi import APIRouter, Depends, HTTPException, Query, Request, Security, status from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings +from app.bot_factory import create_bot from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD from app.database.models import Ticket, TicketMessage, TicketStatus @@ -224,10 +221,7 @@ async def reply_to_ticket( media_caption=payload.media_caption, ) - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: from app.handlers.admin.tickets import notify_user_about_ticket_reply @@ -286,10 +280,7 @@ async def get_ticket_message_media( raise HTTPException(status.HTTP_404_NOT_FOUND, 'Media not found for this message') media_url: str | None = None - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: file = await bot.get_file(message.media_file_id) if file.file_path: diff --git a/main.py b/main.py index d0053181..cc489bd9 100644 --- a/main.py +++ b/main.py @@ -964,11 +964,10 @@ async def _send_crash_notification_on_error(error: Exception) -> None: return try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.startup_notification_service import send_crash_notification - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: traceback_str = traceback.format_exc() await send_crash_notification(bot, error, traceback_str) From 3875335cd7083115043d22f8726f0d33a225cd10 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 02:55:48 +0300 Subject: [PATCH 13/16] fix: narrow exception handling and fix session leak in gift.py - Replace broad `except Exception` with `except TelegramAPIError` in balance.py and wheel.py Stars invoice creation (prevents masking programming errors) - Fix session leak in gift.py telegram_stars path: wrap PaymentService usage in try/finally to ensure bot.session.close() is called --- app/cabinet/routes/balance.py | 3 ++- app/cabinet/routes/gift.py | 22 +++++++++++++--------- app/cabinet/routes/wheel.py | 3 ++- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index d1bb2842..f31c0b21 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -269,6 +269,7 @@ async def create_stars_invoice( # Create invoice through Telegram Bot API try: + from aiogram.exceptions import TelegramAPIError from aiogram.types import LabeledPrice async with create_bot() as bot: @@ -294,7 +295,7 @@ async def create_stars_invoice( amount_kopeks=normalized_kopeks, ) - except Exception as e: + except TelegramAPIError as e: logger.error('Error creating Stars invoice', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/app/cabinet/routes/gift.py b/app/cabinet/routes/gift.py index 0d107360..e934cff3 100644 --- a/app/cabinet/routes/gift.py +++ b/app/cabinet/routes/gift.py @@ -375,15 +375,19 @@ async def create_gift_purchase( bot = create_bot() - payment_service = PaymentService(bot=bot) - payment_result = await payment_service.create_guest_payment( - db=db, - amount_kopeks=price_kopeks, - payment_method=body.payment_method, - description=f'Gift: {tariff.name} ({body.period_days}d)', - purchase_token=purchase.token, - return_url=return_url, - ) + try: + payment_service = PaymentService(bot=bot) + payment_result = await payment_service.create_guest_payment( + db=db, + amount_kopeks=price_kopeks, + payment_method=body.payment_method, + description=f'Gift: {tariff.name} ({body.period_days}d)', + purchase_token=purchase.token, + return_url=return_url, + ) + finally: + if bot: + await bot.session.close() if payment_result is None: await db.rollback() diff --git a/app/cabinet/routes/wheel.py b/app/cabinet/routes/wheel.py index 9dcfe447..f081eb07 100644 --- a/app/cabinet/routes/wheel.py +++ b/app/cabinet/routes/wheel.py @@ -249,6 +249,7 @@ async def create_stars_invoice( # Создаем invoice через Telegram Bot API try: + from aiogram.exceptions import TelegramAPIError from aiogram.types import LabeledPrice from app.bot_factory import create_bot @@ -270,7 +271,7 @@ async def create_stars_invoice( stars_amount=stars_amount, ) - except Exception as e: + except TelegramAPIError as e: logger.error('Error creating invoice', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, From db3ac254ba21a7371cf284cf42eadecf62274c2b Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 21 Mar 2026 03:03:06 +0300 Subject: [PATCH 14/16] Revert "Revert "fix: include landing page revenue in dashboard statistics"" --- app/cabinet/routes/admin_broadcasts.py | 7 ++ app/cabinet/routes/admin_email_templates.py | 4 +- app/cabinet/routes/admin_landings.py | 22 +++++- app/cabinet/routes/admin_sales_stats.py | 10 +-- app/cabinet/routes/admin_stats.py | 20 ++++-- app/cabinet/routes/landing.py | 2 +- app/cabinet/services/email_templates.py | 67 +++++++++++++++++++ app/database/crud/transaction.py | 25 ++++--- app/services/subscription_purchase_service.py | 3 +- 9 files changed, 135 insertions(+), 25 deletions(-) diff --git a/app/cabinet/routes/admin_broadcasts.py b/app/cabinet/routes/admin_broadcasts.py index cebc4068..b72c6b8f 100644 --- a/app/cabinet/routes/admin_broadcasts.py +++ b/app/cabinet/routes/admin_broadcasts.py @@ -411,6 +411,13 @@ async def create_broadcast( media_payload = request.media + # Validate caption length for media messages (Telegram limit: 1024 chars) + if media_payload and len(message_text) > 1024: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Текст слишком длинный для сообщения с медиа. Максимум 1024 символов, сейчас {len(message_text)}. Сократите текст или уберите медиафайл.', + ) + # Create broadcast record broadcast = BroadcastHistory( target_type=request.target, diff --git a/app/cabinet/routes/admin_email_templates.py b/app/cabinet/routes/admin_email_templates.py index 099411c1..b1c133e3 100644 --- a/app/cabinet/routes/admin_email_templates.py +++ b/app/cabinet/routes/admin_email_templates.py @@ -312,7 +312,7 @@ TEMPLATE_TYPES = [ 'zh': '通过落地页成功付款后发送给买家的邮件', 'ua': 'Лист покупцю після успішної оплати через лендінг', }, - 'context_vars': ['tariff_name', 'period_days', 'cabinet_url'], + 'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'], }, { 'type': 'guest_activation_required', @@ -425,6 +425,8 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = { 'tariff_name': 'Premium', 'period_days': 30, 'cabinet_url': 'https://example.com/cabinet', + 'cabinet_email': 'user@example.com', + 'cabinet_password': 'SecurePass123', }, 'guest_activation_required': { 'tariff_name': 'Premium', diff --git a/app/cabinet/routes/admin_landings.py b/app/cabinet/routes/admin_landings.py index af8a4e70..73bb94fc 100644 --- a/app/cabinet/routes/admin_landings.py +++ b/app/cabinet/routes/admin_landings.py @@ -483,6 +483,7 @@ class OrderRequest(BaseModel): class LandingDailyStat(BaseModel): date: str # YYYY-MM-DD + created: int = 0 purchases: int revenue_kopeks: int gifts: int @@ -844,17 +845,35 @@ async def get_landing_stats( ) daily_rows = {str(r.day): r for r in daily_result.all()} + # Created per day (all statuses, by created_at) + day_created_utc = func.date(func.timezone('UTC', GuestPurchase.created_at)) + created_result = await db.execute( + select( + day_created_utc.label('day'), + func.count(GuestPurchase.id).label('created'), + ) + .where( + GuestPurchase.landing_id == landing_id, + GuestPurchase.created_at >= cutoff, + ) + .group_by(day_created_utc) + .order_by(day_created_utc) + ) + created_rows = {str(r.day): r.created for r in created_result.all()} + # Fill missing days with zeros today = now.date() daily_stats: list[LandingDailyStat] = [] for i in range(_STATS_PERIOD_DAYS, -1, -1): day = today - timedelta(days=i) day_str = day.isoformat() + day_created = created_rows.get(day_str, 0) if day_str in daily_rows: r = daily_rows[day_str] daily_stats.append( LandingDailyStat( date=day_str, + created=day_created, purchases=r.purchases, revenue_kopeks=r.revenue_kopeks, gifts=r.gifts, @@ -864,6 +883,7 @@ async def get_landing_stats( daily_stats.append( LandingDailyStat( date=day_str, + created=day_created, purchases=0, revenue_kopeks=0, gifts=0, @@ -897,7 +917,7 @@ async def get_landing_stats( ] return LandingStatsResponse( - total_purchases=total_successful, + total_purchases=total_created, total_revenue_kopeks=total_revenue_kopeks, total_gifts=total_gifts, total_regular=total_regular, diff --git a/app/cabinet/routes/admin_sales_stats.py b/app/cabinet/routes/admin_sales_stats.py index 1efa91ec..71595d83 100644 --- a/app/cabinet/routes/admin_sales_stats.py +++ b/app/cabinet/routes/admin_sales_stats.py @@ -112,11 +112,11 @@ async def get_sales_summary( try: period_start, period_end = _parse_period(days, start_date, end_date) - # Total revenue (deposits with real payment methods) + # Total revenue (deposits + direct subscription payments with real payment methods) revenue_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), Transaction.created_at >= period_start, @@ -1079,7 +1079,7 @@ async def get_deposits_stats( methods_with_manual = [*REAL_PAYMENT_METHODS, PaymentMethod.MANUAL.value] base_filter = and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.payment_method.in_(methods_with_manual), Transaction.created_at >= period_start, @@ -1089,7 +1089,7 @@ async def get_deposits_stats( totals_result = await db.execute( select( func.count(Transaction.id).label('count'), - func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'), + func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'), ).where(base_filter) ) totals = totals_result.one() diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py index de489a82..30ac67d5 100644 --- a/app/cabinet/routes/admin_stats.py +++ b/app/cabinet/routes/admin_stats.py @@ -275,6 +275,14 @@ async def get_dashboard_stats( # Get tariff statistics tariff_stats = await _get_tariff_stats(db) + # Derive income_today from revenue_chart to ensure consistency with chart + today_str = now.date().isoformat() + income_today_from_chart = sum( + item.get('amount_kopeks', 0) for item in revenue_data if str(item.get('date', '')) == today_str + ) + # Use chart-derived value if available, otherwise fall back to trans_stats + income_today_kopeks = income_today_from_chart or trans_stats.get('today', {}).get('income_kopeks', 0) + # Build response return DashboardStats( nodes=nodes_data, @@ -290,8 +298,8 @@ async def get_dashboard_stats( trial_to_paid_conversion=sub_stats.get('trial_to_paid_conversion', 0.0), ), financial=FinancialStats( - income_today_kopeks=trans_stats.get('today', {}).get('income_kopeks', 0), - income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 100, + income_today_kopeks=income_today_kopeks, + income_today_rubles=income_today_kopeks / 100, income_month_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0), income_month_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100, income_total_kopeks=all_time_stats.get('totals', {}).get('income_kopeks', 0), @@ -926,9 +934,9 @@ async def get_recent_payments( total_count = total_count_result.scalar() or 0 today_total_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= today_start, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), @@ -938,9 +946,9 @@ async def get_recent_payments( total_today = today_total_result.scalar() or 0 week_total_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= week_ago, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), diff --git a/app/cabinet/routes/landing.py b/app/cabinet/routes/landing.py index 8a93ec8b..b0fd4a5d 100644 --- a/app/cabinet/routes/landing.py +++ b/app/cabinet/routes/landing.py @@ -550,7 +550,7 @@ async def create_landing_purchase( No authentication required. """ client_ip = get_client_ip(raw_request) - if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=5, window=60, fail_closed=True): + if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=30, window=60, fail_closed=True): raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many purchase attempts, please try again later', diff --git a/app/cabinet/services/email_templates.py b/app/cabinet/services/email_templates.py index a650ddc7..59f07a20 100644 --- a/app/cabinet/services/email_templates.py +++ b/app/cabinet/services/email_templates.py @@ -1373,6 +1373,8 @@ class EmailNotificationTemplates: tariff_name = html.escape(context.get('tariff_name', '')) period_days = context.get('period_days', 0) cabinet_url = html.escape(context.get('cabinet_url', '')) + cabinet_email = html.escape(context.get('cabinet_email', '')) + cabinet_password = context.get('cabinet_password', '') subjects = { 'ru': 'Ваша VPN подписка готова', @@ -1382,6 +1384,66 @@ class EmailNotificationTemplates: 'fa': 'اشتراک VPN شما آماده است', } + creds_block_ru = ( + f""" +
+

Данные для входа в личный кабинет:

+

Email: {cabinet_email}

+

Пароль: {cabinet_password}

+
+ """ + if cabinet_password + else '' + ) + + creds_block_en = ( + f""" +
+

Your cabinet login credentials:

+

Email: {cabinet_email}

+

Password: {cabinet_password}

+
+ """ + if cabinet_password + else '' + ) + + creds_block_zh = ( + f""" +
+

个人中心登录信息:

+

Email: {cabinet_email}

+

密码: {cabinet_password}

+
+ """ + if cabinet_password + else '' + ) + + creds_block_ua = ( + f""" +
+

Дані для входу в особистий кабінет:

+

Email: {cabinet_email}

+

Пароль: {cabinet_password}

+
+ """ + if cabinet_password + else '' + ) + + creds_block_fa = ( + f""" +
+

اطلاعات ورود به پنل کاربری:

+

Email: {cabinet_email}

+

رمز عبور: {cabinet_password}

+
+ """ + if cabinet_password + else '' + ) + bodies = { 'ru': f"""

Ваша VPN подписка готова!

@@ -1389,6 +1451,7 @@ class EmailNotificationTemplates:

Тариф: {tariff_name}

Период: {period_days} дней

+ {creds_block_ru}

Подписка активирована в вашем личном кабинете.

Перейти в личный кабинет

""", @@ -1398,6 +1461,7 @@ class EmailNotificationTemplates:

Plan: {tariff_name}

Period: {period_days} days

+ {creds_block_en}

Your subscription has been activated in your cabinet.

Go to Cabinet

""", @@ -1407,6 +1471,7 @@ class EmailNotificationTemplates:

套餐: {tariff_name}

期限: {period_days} 天

+ {creds_block_zh}

订阅已在您的个人中心激活。

前往个人中心

""", @@ -1416,6 +1481,7 @@ class EmailNotificationTemplates:

Тариф: {tariff_name}

Період: {period_days} днів

+ {creds_block_ua}

Підписка активована у вашому особистому кабінеті.

Перейти до кабінету

""", @@ -1425,6 +1491,7 @@ class EmailNotificationTemplates:

طرح: {tariff_name}

مدت: {period_days} روز

+ {creds_block_fa}

اشتراک شما در پنل کاربری فعال شده است.

رفتن به پنل کاربری

""", diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 0338f9ce..128ea3d8 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -51,6 +51,11 @@ async def create_transaction( else amount_kopeks ) + # Default payment_method to BALANCE for subscription/gift payments from bot (not landing) + # to avoid double-counting with DEPOSIT in revenue calculations + if payment_method is None and type in (TransactionType.SUBSCRIPTION_PAYMENT, TransactionType.GIFT_PAYMENT): + payment_method = PaymentMethod.BALANCE + transaction = Transaction( user_id=user_id, type=type.value, @@ -278,11 +283,11 @@ async def get_transactions_statistics( if not end_date: end_date = datetime.now(UTC) - # Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения) + # Доход считаем по реальным платежам + прямые покупки подписок (лендинги) income_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.created_at <= end_date, @@ -343,7 +348,7 @@ async def get_transactions_statistics( ) .where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.created_at <= end_date, @@ -363,11 +368,11 @@ async def get_transactions_statistics( ) transactions_today = today_result.scalar() - # Доход за сегодня - только реальные платежи + # Доход за сегодня — реальные платежи + прямые покупки подписок (лендинги) today_income_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= today, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), @@ -391,17 +396,17 @@ async def get_transactions_statistics( async def get_revenue_by_period(db: AsyncSession, days: int = 30) -> list[dict]: - """Доход по дням - только реальные платежи.""" + """Доход по дням — реальные платежи + прямые покупки подписок (лендинги).""" start_date = datetime.now(UTC) - timedelta(days=days) result = await db.execute( select( func.date(Transaction.created_at).label('date'), - func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'), + func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'), ) .where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index c06b989a..74d02139 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -22,7 +22,7 @@ from app.database.crud.subscription_conversion import ( ) from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance -from app.database.models import ServerSquad, Subscription, SubscriptionStatus, TransactionType, User +from app.database.models import PaymentMethod, ServerSquad, Subscription, SubscriptionStatus, TransactionType, User from app.localization.texts import get_texts from app.services.subscription_service import SubscriptionService from app.utils.pricing_utils import ( @@ -1109,6 +1109,7 @@ class MiniAppSubscriptionPurchaseService: type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=pricing.final_total, description=f'Подписка на {pricing.selection.period.days} дней ({pricing.months} мес)', + payment_method=PaymentMethod.BALANCE, ) await db.refresh(user) From 5175cccab6e75989be37cbdbe94e18cefcb486ee Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 21 Mar 2026 03:03:06 +0300 Subject: [PATCH 15/16] Revert "Revert "fix: include landing page revenue in dashboard statistics"" --- app/cabinet/routes/admin_broadcasts.py | 7 ++ app/cabinet/routes/admin_email_templates.py | 4 +- app/cabinet/routes/admin_landings.py | 22 +++++- app/cabinet/routes/admin_sales_stats.py | 10 +-- app/cabinet/routes/admin_stats.py | 20 ++++-- app/cabinet/routes/landing.py | 2 +- app/cabinet/services/email_templates.py | 67 +++++++++++++++++++ app/database/crud/transaction.py | 25 ++++--- app/services/subscription_purchase_service.py | 3 +- 9 files changed, 135 insertions(+), 25 deletions(-) diff --git a/app/cabinet/routes/admin_broadcasts.py b/app/cabinet/routes/admin_broadcasts.py index cebc4068..b72c6b8f 100644 --- a/app/cabinet/routes/admin_broadcasts.py +++ b/app/cabinet/routes/admin_broadcasts.py @@ -411,6 +411,13 @@ async def create_broadcast( media_payload = request.media + # Validate caption length for media messages (Telegram limit: 1024 chars) + if media_payload and len(message_text) > 1024: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Текст слишком длинный для сообщения с медиа. Максимум 1024 символов, сейчас {len(message_text)}. Сократите текст или уберите медиафайл.', + ) + # Create broadcast record broadcast = BroadcastHistory( target_type=request.target, diff --git a/app/cabinet/routes/admin_email_templates.py b/app/cabinet/routes/admin_email_templates.py index 099411c1..b1c133e3 100644 --- a/app/cabinet/routes/admin_email_templates.py +++ b/app/cabinet/routes/admin_email_templates.py @@ -312,7 +312,7 @@ TEMPLATE_TYPES = [ 'zh': '通过落地页成功付款后发送给买家的邮件', 'ua': 'Лист покупцю після успішної оплати через лендінг', }, - 'context_vars': ['tariff_name', 'period_days', 'cabinet_url'], + 'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'], }, { 'type': 'guest_activation_required', @@ -425,6 +425,8 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = { 'tariff_name': 'Premium', 'period_days': 30, 'cabinet_url': 'https://example.com/cabinet', + 'cabinet_email': 'user@example.com', + 'cabinet_password': 'SecurePass123', }, 'guest_activation_required': { 'tariff_name': 'Premium', diff --git a/app/cabinet/routes/admin_landings.py b/app/cabinet/routes/admin_landings.py index af8a4e70..73bb94fc 100644 --- a/app/cabinet/routes/admin_landings.py +++ b/app/cabinet/routes/admin_landings.py @@ -483,6 +483,7 @@ class OrderRequest(BaseModel): class LandingDailyStat(BaseModel): date: str # YYYY-MM-DD + created: int = 0 purchases: int revenue_kopeks: int gifts: int @@ -844,17 +845,35 @@ async def get_landing_stats( ) daily_rows = {str(r.day): r for r in daily_result.all()} + # Created per day (all statuses, by created_at) + day_created_utc = func.date(func.timezone('UTC', GuestPurchase.created_at)) + created_result = await db.execute( + select( + day_created_utc.label('day'), + func.count(GuestPurchase.id).label('created'), + ) + .where( + GuestPurchase.landing_id == landing_id, + GuestPurchase.created_at >= cutoff, + ) + .group_by(day_created_utc) + .order_by(day_created_utc) + ) + created_rows = {str(r.day): r.created for r in created_result.all()} + # Fill missing days with zeros today = now.date() daily_stats: list[LandingDailyStat] = [] for i in range(_STATS_PERIOD_DAYS, -1, -1): day = today - timedelta(days=i) day_str = day.isoformat() + day_created = created_rows.get(day_str, 0) if day_str in daily_rows: r = daily_rows[day_str] daily_stats.append( LandingDailyStat( date=day_str, + created=day_created, purchases=r.purchases, revenue_kopeks=r.revenue_kopeks, gifts=r.gifts, @@ -864,6 +883,7 @@ async def get_landing_stats( daily_stats.append( LandingDailyStat( date=day_str, + created=day_created, purchases=0, revenue_kopeks=0, gifts=0, @@ -897,7 +917,7 @@ async def get_landing_stats( ] return LandingStatsResponse( - total_purchases=total_successful, + total_purchases=total_created, total_revenue_kopeks=total_revenue_kopeks, total_gifts=total_gifts, total_regular=total_regular, diff --git a/app/cabinet/routes/admin_sales_stats.py b/app/cabinet/routes/admin_sales_stats.py index 1efa91ec..71595d83 100644 --- a/app/cabinet/routes/admin_sales_stats.py +++ b/app/cabinet/routes/admin_sales_stats.py @@ -112,11 +112,11 @@ async def get_sales_summary( try: period_start, period_end = _parse_period(days, start_date, end_date) - # Total revenue (deposits with real payment methods) + # Total revenue (deposits + direct subscription payments with real payment methods) revenue_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), Transaction.created_at >= period_start, @@ -1079,7 +1079,7 @@ async def get_deposits_stats( methods_with_manual = [*REAL_PAYMENT_METHODS, PaymentMethod.MANUAL.value] base_filter = and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.payment_method.in_(methods_with_manual), Transaction.created_at >= period_start, @@ -1089,7 +1089,7 @@ async def get_deposits_stats( totals_result = await db.execute( select( func.count(Transaction.id).label('count'), - func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'), + func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'), ).where(base_filter) ) totals = totals_result.one() diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py index de489a82..30ac67d5 100644 --- a/app/cabinet/routes/admin_stats.py +++ b/app/cabinet/routes/admin_stats.py @@ -275,6 +275,14 @@ async def get_dashboard_stats( # Get tariff statistics tariff_stats = await _get_tariff_stats(db) + # Derive income_today from revenue_chart to ensure consistency with chart + today_str = now.date().isoformat() + income_today_from_chart = sum( + item.get('amount_kopeks', 0) for item in revenue_data if str(item.get('date', '')) == today_str + ) + # Use chart-derived value if available, otherwise fall back to trans_stats + income_today_kopeks = income_today_from_chart or trans_stats.get('today', {}).get('income_kopeks', 0) + # Build response return DashboardStats( nodes=nodes_data, @@ -290,8 +298,8 @@ async def get_dashboard_stats( trial_to_paid_conversion=sub_stats.get('trial_to_paid_conversion', 0.0), ), financial=FinancialStats( - income_today_kopeks=trans_stats.get('today', {}).get('income_kopeks', 0), - income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 100, + income_today_kopeks=income_today_kopeks, + income_today_rubles=income_today_kopeks / 100, income_month_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0), income_month_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100, income_total_kopeks=all_time_stats.get('totals', {}).get('income_kopeks', 0), @@ -926,9 +934,9 @@ async def get_recent_payments( total_count = total_count_result.scalar() or 0 today_total_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= today_start, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), @@ -938,9 +946,9 @@ async def get_recent_payments( total_today = today_total_result.scalar() or 0 week_total_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= week_ago, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), diff --git a/app/cabinet/routes/landing.py b/app/cabinet/routes/landing.py index 8a93ec8b..b0fd4a5d 100644 --- a/app/cabinet/routes/landing.py +++ b/app/cabinet/routes/landing.py @@ -550,7 +550,7 @@ async def create_landing_purchase( No authentication required. """ client_ip = get_client_ip(raw_request) - if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=5, window=60, fail_closed=True): + if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=30, window=60, fail_closed=True): raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many purchase attempts, please try again later', diff --git a/app/cabinet/services/email_templates.py b/app/cabinet/services/email_templates.py index a650ddc7..59f07a20 100644 --- a/app/cabinet/services/email_templates.py +++ b/app/cabinet/services/email_templates.py @@ -1373,6 +1373,8 @@ class EmailNotificationTemplates: tariff_name = html.escape(context.get('tariff_name', '')) period_days = context.get('period_days', 0) cabinet_url = html.escape(context.get('cabinet_url', '')) + cabinet_email = html.escape(context.get('cabinet_email', '')) + cabinet_password = context.get('cabinet_password', '') subjects = { 'ru': 'Ваша VPN подписка готова', @@ -1382,6 +1384,66 @@ class EmailNotificationTemplates: 'fa': 'اشتراک VPN شما آماده است', } + creds_block_ru = ( + f""" +
+

Данные для входа в личный кабинет:

+

Email: {cabinet_email}

+

Пароль: {cabinet_password}

+
+ """ + if cabinet_password + else '' + ) + + creds_block_en = ( + f""" +
+

Your cabinet login credentials:

+

Email: {cabinet_email}

+

Password: {cabinet_password}

+
+ """ + if cabinet_password + else '' + ) + + creds_block_zh = ( + f""" +
+

个人中心登录信息:

+

Email: {cabinet_email}

+

密码: {cabinet_password}

+
+ """ + if cabinet_password + else '' + ) + + creds_block_ua = ( + f""" +
+

Дані для входу в особистий кабінет:

+

Email: {cabinet_email}

+

Пароль: {cabinet_password}

+
+ """ + if cabinet_password + else '' + ) + + creds_block_fa = ( + f""" +
+

اطلاعات ورود به پنل کاربری:

+

Email: {cabinet_email}

+

رمز عبور: {cabinet_password}

+
+ """ + if cabinet_password + else '' + ) + bodies = { 'ru': f"""

Ваша VPN подписка готова!

@@ -1389,6 +1451,7 @@ class EmailNotificationTemplates:

Тариф: {tariff_name}

Период: {period_days} дней

+ {creds_block_ru}

Подписка активирована в вашем личном кабинете.

Перейти в личный кабинет

""", @@ -1398,6 +1461,7 @@ class EmailNotificationTemplates:

Plan: {tariff_name}

Period: {period_days} days

+ {creds_block_en}

Your subscription has been activated in your cabinet.

Go to Cabinet

""", @@ -1407,6 +1471,7 @@ class EmailNotificationTemplates:

套餐: {tariff_name}

期限: {period_days} 天

+ {creds_block_zh}

订阅已在您的个人中心激活。

前往个人中心

""", @@ -1416,6 +1481,7 @@ class EmailNotificationTemplates:

Тариф: {tariff_name}

Період: {period_days} днів

+ {creds_block_ua}

Підписка активована у вашому особистому кабінеті.

Перейти до кабінету

""", @@ -1425,6 +1491,7 @@ class EmailNotificationTemplates:

طرح: {tariff_name}

مدت: {period_days} روز

+ {creds_block_fa}

اشتراک شما در پنل کاربری فعال شده است.

رفتن به پنل کاربری

""", diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 0338f9ce..128ea3d8 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -51,6 +51,11 @@ async def create_transaction( else amount_kopeks ) + # Default payment_method to BALANCE for subscription/gift payments from bot (not landing) + # to avoid double-counting with DEPOSIT in revenue calculations + if payment_method is None and type in (TransactionType.SUBSCRIPTION_PAYMENT, TransactionType.GIFT_PAYMENT): + payment_method = PaymentMethod.BALANCE + transaction = Transaction( user_id=user_id, type=type.value, @@ -278,11 +283,11 @@ async def get_transactions_statistics( if not end_date: end_date = datetime.now(UTC) - # Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения) + # Доход считаем по реальным платежам + прямые покупки подписок (лендинги) income_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.created_at <= end_date, @@ -343,7 +348,7 @@ async def get_transactions_statistics( ) .where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.created_at <= end_date, @@ -363,11 +368,11 @@ async def get_transactions_statistics( ) transactions_today = today_result.scalar() - # Доход за сегодня - только реальные платежи + # Доход за сегодня — реальные платежи + прямые покупки подписок (лендинги) today_income_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= today, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), @@ -391,17 +396,17 @@ async def get_transactions_statistics( async def get_revenue_by_period(db: AsyncSession, days: int = 30) -> list[dict]: - """Доход по дням - только реальные платежи.""" + """Доход по дням — реальные платежи + прямые покупки подписок (лендинги).""" start_date = datetime.now(UTC) - timedelta(days=days) result = await db.execute( select( func.date(Transaction.created_at).label('date'), - func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'), + func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'), ) .where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index c06b989a..74d02139 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -22,7 +22,7 @@ from app.database.crud.subscription_conversion import ( ) from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance -from app.database.models import ServerSquad, Subscription, SubscriptionStatus, TransactionType, User +from app.database.models import PaymentMethod, ServerSquad, Subscription, SubscriptionStatus, TransactionType, User from app.localization.texts import get_texts from app.services.subscription_service import SubscriptionService from app.utils.pricing_utils import ( @@ -1109,6 +1109,7 @@ class MiniAppSubscriptionPurchaseService: type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=pricing.final_total, description=f'Подписка на {pricing.selection.period.days} дней ({pricing.months} мес)', + payment_method=PaymentMethod.BALANCE, ) await db.refresh(user) From 54155b5649fc46690c0b4b0fcac2764ea2c31768 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 03:14:15 +0300 Subject: [PATCH 16/16] style: ruff format cloudpayments.py --- app/services/payment/cloudpayments.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index fd9966ca..f33100eb 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -493,7 +493,9 @@ class CloudPaymentsPaymentMixin: reply_markup=keyboard, ) except Exception as error: - logger.warning('Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error) + logger.warning( + 'Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error + ) async def _send_cloudpayments_fail_notification( self,