From 29a17a7876a38539734308622bd4fda12bd47074 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 20 Nov 2025 22:33:43 +0300 Subject: [PATCH 01/20] Add admin trial reset controls --- app/bot.py | 2 + app/database/crud/subscription.py | 84 +++++++++++++++++++++++++++++- app/handlers/admin/trials.py | 86 +++++++++++++++++++++++++++++++ app/keyboards/admin.py | 18 +++++++ app/localization/locales/en.json | 6 +++ app/localization/locales/ru.json | 6 +++ app/localization/locales/ua.json | 6 +++ app/localization/locales/zh.json | 6 +++ 8 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 app/handlers/admin/trials.py diff --git a/app/bot.py b/app/bot.py index 4d4c9d87..4ecac835 100644 --- a/app/bot.py +++ b/app/bot.py @@ -59,6 +59,7 @@ from app.handlers.admin import ( public_offer as admin_public_offer, faq as admin_faq, payments as admin_payments, + trials as admin_trials, ) from app.handlers.stars_payments import register_stars_handlers @@ -174,6 +175,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_public_offer.register_handlers(dp) admin_faq.register_handlers(dp) admin_payments.register_handlers(dp) + admin_trials.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) user_polls.register_handlers(dp) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 7e1e92d5..4162da84 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -676,10 +676,90 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict: "purchased_today": purchased_today, "purchased_week": purchased_week, "purchased_month": purchased_month, - "trial_to_paid_conversion": trial_to_paid_conversion, - "renewals_count": renewals_count + "trial_to_paid_conversion": trial_to_paid_conversion, + "renewals_count": renewals_count } + +async def get_trial_statistics(db: AsyncSession) -> dict: + now = datetime.utcnow() + + total_trials_result = await db.execute( + select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True)) + ) + total_trials = total_trials_result.scalar() or 0 + + active_trials_result = await db.execute( + select(func.count(Subscription.id)).where( + Subscription.is_trial.is_(True), + Subscription.end_date > now, + Subscription.status.in_( + [SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value] + ), + ) + ) + active_trials = active_trials_result.scalar() or 0 + + resettable_trials_result = await db.execute( + select(func.count(Subscription.id)) + .join(User, Subscription.user_id == User.id) + .where( + Subscription.is_trial.is_(True), + Subscription.end_date <= now, + User.has_had_paid_subscription.is_(False), + ) + ) + resettable_trials = resettable_trials_result.scalar() or 0 + + return { + "used_trials": total_trials, + "active_trials": active_trials, + "resettable_trials": resettable_trials, + } + + +async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int: + now = datetime.utcnow() + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .join(User, Subscription.user_id == User.id) + .where( + Subscription.is_trial.is_(True), + Subscription.end_date <= now, + User.has_had_paid_subscription.is_(False), + ) + ) + + subscriptions = result.scalars().all() + if not subscriptions: + return 0 + + reset_count = 0 + for subscription in subscriptions: + try: + await decrement_subscription_server_counts(db, subscription) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Не удалось обновить счётчики серверов при сбросе триала %s: %s", + subscription.id, + error, + ) + + await db.delete(subscription) + reset_count += 1 + + try: + await db.commit() + except Exception as error: # pragma: no cover - defensive logging + await db.rollback() + logger.error("Ошибка сохранения сброса триалов: %s", error) + raise + + logger.info("♻️ Сброшено триальных подписок: %s", reset_count) + return reset_count + async def update_subscription_usage( db: AsyncSession, subscription: Subscription, diff --git a/app/handlers/admin/trials.py b/app/handlers/admin/trials.py new file mode 100644 index 00000000..9a95f192 --- /dev/null +++ b/app/handlers/admin/trials.py @@ -0,0 +1,86 @@ +import logging + +from aiogram import Dispatcher, F, types +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.subscription import ( + get_trial_statistics, + reset_trials_for_users_without_paid_subscription, +) +from app.database.models import User +from app.keyboards.admin import get_admin_trials_keyboard +from app.localization.texts import get_texts +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_trials_panel( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + stats = await get_trial_statistics(db) + message = texts.t("ADMIN_TRIALS_TITLE", "🧪 Управление триалами") + "\n\n" + texts.t( + "ADMIN_TRIALS_STATS", + "• Использовано всего: {used}\n" + "• Активно сейчас: {active}\n" + "• Доступно к сбросу: {resettable}", + ).format( + used=stats.get("used_trials", 0), + active=stats.get("active_trials", 0), + resettable=stats.get("resettable_trials", 0), + ) + + await callback.message.edit_text( + message, + reply_markup=get_admin_trials_keyboard(db_user.language), + ) + await callback.answer() + + +@admin_required +@error_handler +async def reset_trials( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + reset_count = await reset_trials_for_users_without_paid_subscription(db) + stats = await get_trial_statistics(db) + + message = texts.t( + "ADMIN_TRIALS_RESET_RESULT", + "♻️ Сбросили {reset_count} триалов.\n\n" + "• Использовано всего: {used}\n" + "• Активно сейчас: {active}\n" + "• Доступно к сбросу: {resettable}", + ).format( + reset_count=reset_count, + used=stats.get("used_trials", 0), + active=stats.get("active_trials", 0), + resettable=stats.get("resettable_trials", 0), + ) + + await callback.message.edit_text( + message, + reply_markup=get_admin_trials_keyboard(db_user.language), + ) + await callback.answer(texts.t("ADMIN_TRIALS_RESET_TOAST", "✅ Сброс завершен")) + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register( + show_trials_panel, + F.data == "admin_trials", + ) + dp.callback_query.register( + reset_trials, + F.data == "admin_trials_reset", + ) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index db1abbc9..6272a531 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -54,6 +54,10 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ), ], [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_MAIN_TRIALS", "🧪 Триалы"), + callback_data="admin_trials", + ), InlineKeyboardButton( text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"), callback_data="admin_payments", @@ -241,6 +245,20 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar ]) +def get_admin_trials_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_TRIALS_RESET_BUTTON", "♻️ Сбросить все триалы"), + callback_data="admin_trials_reset", + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")], + ]) + + def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index a93a87c4..dc474150 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,6 +130,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Settings", "ADMIN_MAIN_SUPPORT": "🛟 Support", "ADMIN_MAIN_SYSTEM": "🛠️ System", + "ADMIN_MAIN_TRIALS": "🧪 Trials", "ADMIN_MAIN_PAYMENTS": "💳 Top-ups", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions", "ADMIN_MESSAGES": "📨 Broadcasts", @@ -168,6 +169,11 @@ "ADMIN_PAYMENTS_TITLE": "💳 Top-up verification", "ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.", "ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.", + "ADMIN_TRIALS_TITLE": "🧪 Trial management", + "ADMIN_TRIALS_STATS": "• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}", + "ADMIN_TRIALS_RESET_BUTTON": "♻️ Reset all trials", + "ADMIN_TRIALS_RESET_RESULT": "♻️ Reset {reset_count} trials.\n\n• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}", + "ADMIN_TRIALS_RESET_TOAST": "✅ Reset completed", "ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.", "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}", "ADMIN_PAYMENT_STATUS_PENDING": "Pending", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 641630ea..da8b89f9 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,6 +130,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", + "ADMIN_MAIN_TRIALS": "🧪 Триалы", "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", @@ -168,6 +169,11 @@ "ADMIN_PAYMENTS_TITLE": "💳 Проверка пополнений", "ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.", "ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.", + "ADMIN_TRIALS_TITLE": "🧪 Управление триалами", + "ADMIN_TRIALS_STATS": "• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}", + "ADMIN_TRIALS_RESET_BUTTON": "♻️ Сбросить все триалы", + "ADMIN_TRIALS_RESET_RESULT": "♻️ Сбросили {reset_count} триалов.\n\n• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}", + "ADMIN_TRIALS_RESET_TOAST": "✅ Сброс завершен", "ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.", "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}", "ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 6b9ad510..4c8fb4b9 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -129,6 +129,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Налаштування", "ADMIN_MAIN_SUPPORT": "🛟 Підтримка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", + "ADMIN_MAIN_TRIALS": "🧪 Тріали", "ADMIN_MAIN_PAYMENTS": "💳 Поповнення", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзери/Підписки", "ADMIN_MESSAGES": "📨 Розсилки", @@ -167,6 +168,11 @@ "ADMIN_PAYMENTS_TITLE": "💳 Перевірка поповнень", "ADMIN_PAYMENTS_DESCRIPTION": "Список рахунків на поповнення, створених за останні 24 години, які очікують на оплату.", "ADMIN_PAYMENTS_NOTICE": "Перевіряти можна лише рахунки, молодші 24 годин, зі статусом очікування.", + "ADMIN_TRIALS_TITLE": "🧪 Керування тріалами", + "ADMIN_TRIALS_STATS": "• Використано всього: {used}\n• Активні зараз: {active}\n• Доступні для скидання: {resettable}", + "ADMIN_TRIALS_RESET_BUTTON": "♻️ Скинути всі тріали", + "ADMIN_TRIALS_RESET_RESULT": "♻️ Скинули {reset_count} тріалів.\n\n• Використано всього: {used}\n• Активні зараз: {active}\n• Доступні для скидання: {resettable}", + "ADMIN_TRIALS_RESET_TOAST": "✅ Скидання завершено", "ADMIN_PAYMENTS_EMPTY": "За останні 24 години не знайдено рахунків на поповнення в очікуванні.", "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}", "ADMIN_PAYMENT_STATUS_PENDING": "Очікує оплати", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 4c9f765f..84210ac8 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -129,6 +129,7 @@ "ADMIN_MAIN_SETTINGS":"⚙️设置", "ADMIN_MAIN_SUPPORT":"🛟支持", "ADMIN_MAIN_SYSTEM":"🛠️系统", +"ADMIN_MAIN_TRIALS":"🧪试用", "ADMIN_MAIN_PAYMENTS":"💳充值", "ADMIN_MAIN_USERS_SUBSCRIPTIONS":"👥用户/订阅", "ADMIN_MESSAGES":"📨广播", @@ -167,6 +168,11 @@ "ADMIN_PAYMENTS_TITLE":"💳充值检查", "ADMIN_PAYMENTS_DESCRIPTION":"过去24小时内创建并等待付款的充值账单列表。", "ADMIN_PAYMENTS_NOTICE":"只能检查24小时内且状态为等待中的账单。", +"ADMIN_TRIALS_TITLE":"🧪 试用管理", +"ADMIN_TRIALS_STATS":"• 已使用试用总数: {used}\n• 当前活跃: {active}\n• 可重置: {resettable}", +"ADMIN_TRIALS_RESET_BUTTON":"♻️ 重置所有试用", +"ADMIN_TRIALS_RESET_RESULT":"♻️ 已重置 {reset_count} 个试用。\n\n• 已使用试用总数: {used}\n• 当前活跃: {active}\n• 可重置: {resettable}", +"ADMIN_TRIALS_RESET_TOAST":"✅ 重置完成", "ADMIN_PAYMENTS_EMPTY":"过去24小时内未找到等待中的充值账单。", "ADMIN_PAYMENTS_ITEM_DETAILS":"📄№{number}", "ADMIN_PAYMENT_STATUS_PENDING":"等待付款", From 11c239f46c58c7c549699e9f3c945d0ba01e90c9 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 20 Nov 2025 22:46:30 +0300 Subject: [PATCH 02/20] Revert "Add admin trial reset controls" --- app/bot.py | 2 - app/database/crud/subscription.py | 84 +----------------------------- app/handlers/admin/trials.py | 86 ------------------------------- app/keyboards/admin.py | 18 ------- app/localization/locales/en.json | 6 --- app/localization/locales/ru.json | 6 --- app/localization/locales/ua.json | 6 --- app/localization/locales/zh.json | 6 --- 8 files changed, 2 insertions(+), 212 deletions(-) delete mode 100644 app/handlers/admin/trials.py diff --git a/app/bot.py b/app/bot.py index 4ecac835..4d4c9d87 100644 --- a/app/bot.py +++ b/app/bot.py @@ -59,7 +59,6 @@ from app.handlers.admin import ( public_offer as admin_public_offer, faq as admin_faq, payments as admin_payments, - trials as admin_trials, ) from app.handlers.stars_payments import register_stars_handlers @@ -175,7 +174,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_public_offer.register_handlers(dp) admin_faq.register_handlers(dp) admin_payments.register_handlers(dp) - admin_trials.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) user_polls.register_handlers(dp) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 4162da84..7e1e92d5 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -676,90 +676,10 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict: "purchased_today": purchased_today, "purchased_week": purchased_week, "purchased_month": purchased_month, - "trial_to_paid_conversion": trial_to_paid_conversion, - "renewals_count": renewals_count + "trial_to_paid_conversion": trial_to_paid_conversion, + "renewals_count": renewals_count } - -async def get_trial_statistics(db: AsyncSession) -> dict: - now = datetime.utcnow() - - total_trials_result = await db.execute( - select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True)) - ) - total_trials = total_trials_result.scalar() or 0 - - active_trials_result = await db.execute( - select(func.count(Subscription.id)).where( - Subscription.is_trial.is_(True), - Subscription.end_date > now, - Subscription.status.in_( - [SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value] - ), - ) - ) - active_trials = active_trials_result.scalar() or 0 - - resettable_trials_result = await db.execute( - select(func.count(Subscription.id)) - .join(User, Subscription.user_id == User.id) - .where( - Subscription.is_trial.is_(True), - Subscription.end_date <= now, - User.has_had_paid_subscription.is_(False), - ) - ) - resettable_trials = resettable_trials_result.scalar() or 0 - - return { - "used_trials": total_trials, - "active_trials": active_trials, - "resettable_trials": resettable_trials, - } - - -async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int: - now = datetime.utcnow() - - result = await db.execute( - select(Subscription) - .options(selectinload(Subscription.user)) - .join(User, Subscription.user_id == User.id) - .where( - Subscription.is_trial.is_(True), - Subscription.end_date <= now, - User.has_had_paid_subscription.is_(False), - ) - ) - - subscriptions = result.scalars().all() - if not subscriptions: - return 0 - - reset_count = 0 - for subscription in subscriptions: - try: - await decrement_subscription_server_counts(db, subscription) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Не удалось обновить счётчики серверов при сбросе триала %s: %s", - subscription.id, - error, - ) - - await db.delete(subscription) - reset_count += 1 - - try: - await db.commit() - except Exception as error: # pragma: no cover - defensive logging - await db.rollback() - logger.error("Ошибка сохранения сброса триалов: %s", error) - raise - - logger.info("♻️ Сброшено триальных подписок: %s", reset_count) - return reset_count - async def update_subscription_usage( db: AsyncSession, subscription: Subscription, diff --git a/app/handlers/admin/trials.py b/app/handlers/admin/trials.py deleted file mode 100644 index 9a95f192..00000000 --- a/app/handlers/admin/trials.py +++ /dev/null @@ -1,86 +0,0 @@ -import logging - -from aiogram import Dispatcher, F, types -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.crud.subscription import ( - get_trial_statistics, - reset_trials_for_users_without_paid_subscription, -) -from app.database.models import User -from app.keyboards.admin import get_admin_trials_keyboard -from app.localization.texts import get_texts -from app.utils.decorators import admin_required, error_handler - -logger = logging.getLogger(__name__) - - -@admin_required -@error_handler -async def show_trials_panel( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - - stats = await get_trial_statistics(db) - message = texts.t("ADMIN_TRIALS_TITLE", "🧪 Управление триалами") + "\n\n" + texts.t( - "ADMIN_TRIALS_STATS", - "• Использовано всего: {used}\n" - "• Активно сейчас: {active}\n" - "• Доступно к сбросу: {resettable}", - ).format( - used=stats.get("used_trials", 0), - active=stats.get("active_trials", 0), - resettable=stats.get("resettable_trials", 0), - ) - - await callback.message.edit_text( - message, - reply_markup=get_admin_trials_keyboard(db_user.language), - ) - await callback.answer() - - -@admin_required -@error_handler -async def reset_trials( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - - reset_count = await reset_trials_for_users_without_paid_subscription(db) - stats = await get_trial_statistics(db) - - message = texts.t( - "ADMIN_TRIALS_RESET_RESULT", - "♻️ Сбросили {reset_count} триалов.\n\n" - "• Использовано всего: {used}\n" - "• Активно сейчас: {active}\n" - "• Доступно к сбросу: {resettable}", - ).format( - reset_count=reset_count, - used=stats.get("used_trials", 0), - active=stats.get("active_trials", 0), - resettable=stats.get("resettable_trials", 0), - ) - - await callback.message.edit_text( - message, - reply_markup=get_admin_trials_keyboard(db_user.language), - ) - await callback.answer(texts.t("ADMIN_TRIALS_RESET_TOAST", "✅ Сброс завершен")) - - -def register_handlers(dp: Dispatcher) -> None: - dp.callback_query.register( - show_trials_panel, - F.data == "admin_trials", - ) - dp.callback_query.register( - reset_trials, - F.data == "admin_trials_reset", - ) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 6272a531..db1abbc9 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -54,10 +54,6 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ), ], [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAIN_TRIALS", "🧪 Триалы"), - callback_data="admin_trials", - ), InlineKeyboardButton( text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"), callback_data="admin_payments", @@ -245,20 +241,6 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar ]) -def get_admin_trials_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_TRIALS_RESET_BUTTON", "♻️ Сбросить все триалы"), - callback_data="admin_trials_reset", - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")], - ]) - - def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index dc474150..a93a87c4 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,7 +130,6 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Settings", "ADMIN_MAIN_SUPPORT": "🛟 Support", "ADMIN_MAIN_SYSTEM": "🛠️ System", - "ADMIN_MAIN_TRIALS": "🧪 Trials", "ADMIN_MAIN_PAYMENTS": "💳 Top-ups", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions", "ADMIN_MESSAGES": "📨 Broadcasts", @@ -169,11 +168,6 @@ "ADMIN_PAYMENTS_TITLE": "💳 Top-up verification", "ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.", "ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.", - "ADMIN_TRIALS_TITLE": "🧪 Trial management", - "ADMIN_TRIALS_STATS": "• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}", - "ADMIN_TRIALS_RESET_BUTTON": "♻️ Reset all trials", - "ADMIN_TRIALS_RESET_RESULT": "♻️ Reset {reset_count} trials.\n\n• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}", - "ADMIN_TRIALS_RESET_TOAST": "✅ Reset completed", "ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.", "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}", "ADMIN_PAYMENT_STATUS_PENDING": "Pending", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index da8b89f9..641630ea 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,7 +130,6 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", - "ADMIN_MAIN_TRIALS": "🧪 Триалы", "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", @@ -169,11 +168,6 @@ "ADMIN_PAYMENTS_TITLE": "💳 Проверка пополнений", "ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.", "ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.", - "ADMIN_TRIALS_TITLE": "🧪 Управление триалами", - "ADMIN_TRIALS_STATS": "• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}", - "ADMIN_TRIALS_RESET_BUTTON": "♻️ Сбросить все триалы", - "ADMIN_TRIALS_RESET_RESULT": "♻️ Сбросили {reset_count} триалов.\n\n• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}", - "ADMIN_TRIALS_RESET_TOAST": "✅ Сброс завершен", "ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.", "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}", "ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 4c8fb4b9..6b9ad510 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -129,7 +129,6 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Налаштування", "ADMIN_MAIN_SUPPORT": "🛟 Підтримка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", - "ADMIN_MAIN_TRIALS": "🧪 Тріали", "ADMIN_MAIN_PAYMENTS": "💳 Поповнення", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзери/Підписки", "ADMIN_MESSAGES": "📨 Розсилки", @@ -168,11 +167,6 @@ "ADMIN_PAYMENTS_TITLE": "💳 Перевірка поповнень", "ADMIN_PAYMENTS_DESCRIPTION": "Список рахунків на поповнення, створених за останні 24 години, які очікують на оплату.", "ADMIN_PAYMENTS_NOTICE": "Перевіряти можна лише рахунки, молодші 24 годин, зі статусом очікування.", - "ADMIN_TRIALS_TITLE": "🧪 Керування тріалами", - "ADMIN_TRIALS_STATS": "• Використано всього: {used}\n• Активні зараз: {active}\n• Доступні для скидання: {resettable}", - "ADMIN_TRIALS_RESET_BUTTON": "♻️ Скинути всі тріали", - "ADMIN_TRIALS_RESET_RESULT": "♻️ Скинули {reset_count} тріалів.\n\n• Використано всього: {used}\n• Активні зараз: {active}\n• Доступні для скидання: {resettable}", - "ADMIN_TRIALS_RESET_TOAST": "✅ Скидання завершено", "ADMIN_PAYMENTS_EMPTY": "За останні 24 години не знайдено рахунків на поповнення в очікуванні.", "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}", "ADMIN_PAYMENT_STATUS_PENDING": "Очікує оплати", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 84210ac8..4c9f765f 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -129,7 +129,6 @@ "ADMIN_MAIN_SETTINGS":"⚙️设置", "ADMIN_MAIN_SUPPORT":"🛟支持", "ADMIN_MAIN_SYSTEM":"🛠️系统", -"ADMIN_MAIN_TRIALS":"🧪试用", "ADMIN_MAIN_PAYMENTS":"💳充值", "ADMIN_MAIN_USERS_SUBSCRIPTIONS":"👥用户/订阅", "ADMIN_MESSAGES":"📨广播", @@ -168,11 +167,6 @@ "ADMIN_PAYMENTS_TITLE":"💳充值检查", "ADMIN_PAYMENTS_DESCRIPTION":"过去24小时内创建并等待付款的充值账单列表。", "ADMIN_PAYMENTS_NOTICE":"只能检查24小时内且状态为等待中的账单。", -"ADMIN_TRIALS_TITLE":"🧪 试用管理", -"ADMIN_TRIALS_STATS":"• 已使用试用总数: {used}\n• 当前活跃: {active}\n• 可重置: {resettable}", -"ADMIN_TRIALS_RESET_BUTTON":"♻️ 重置所有试用", -"ADMIN_TRIALS_RESET_RESULT":"♻️ 已重置 {reset_count} 个试用。\n\n• 已使用试用总数: {used}\n• 当前活跃: {active}\n• 可重置: {resettable}", -"ADMIN_TRIALS_RESET_TOAST":"✅ 重置完成", "ADMIN_PAYMENTS_EMPTY":"过去24小时内未找到等待中的充值账单。", "ADMIN_PAYMENTS_ITEM_DETAILS":"📄№{number}", "ADMIN_PAYMENT_STATUS_PENDING":"等待付款", From fba217b87f679632ae10090af153671e143f14ed Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 20 Nov 2025 22:49:57 +0300 Subject: [PATCH 03/20] Fix trial reset by clearing server links --- app/bot.py | 2 + app/database/crud/subscription.py | 109 +++++++++++++++++++++++++++++- app/handlers/admin/trials.py | 86 +++++++++++++++++++++++ app/keyboards/admin.py | 18 +++++ app/localization/locales/en.json | 6 ++ app/localization/locales/ru.json | 6 ++ app/localization/locales/ua.json | 6 ++ app/localization/locales/zh.json | 6 ++ 8 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 app/handlers/admin/trials.py diff --git a/app/bot.py b/app/bot.py index 4d4c9d87..4ecac835 100644 --- a/app/bot.py +++ b/app/bot.py @@ -59,6 +59,7 @@ from app.handlers.admin import ( public_offer as admin_public_offer, faq as admin_faq, payments as admin_payments, + trials as admin_trials, ) from app.handlers.stars_payments import register_stars_handlers @@ -174,6 +175,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_public_offer.register_handlers(dp) admin_faq.register_handlers(dp) admin_payments.register_handlers(dp) + admin_trials.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) user_polls.register_handlers(dp) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 7e1e92d5..1744175e 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1,7 +1,7 @@ import logging from datetime import datetime, timedelta from typing import Iterable, Optional, List, Tuple -from sqlalchemy import select, and_, func +from sqlalchemy import select, and_, func, delete from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -676,10 +676,113 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict: "purchased_today": purchased_today, "purchased_week": purchased_week, "purchased_month": purchased_month, - "trial_to_paid_conversion": trial_to_paid_conversion, - "renewals_count": renewals_count + "trial_to_paid_conversion": trial_to_paid_conversion, + "renewals_count": renewals_count } + +async def get_trial_statistics(db: AsyncSession) -> dict: + now = datetime.utcnow() + + total_trials_result = await db.execute( + select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True)) + ) + total_trials = total_trials_result.scalar() or 0 + + active_trials_result = await db.execute( + select(func.count(Subscription.id)).where( + Subscription.is_trial.is_(True), + Subscription.end_date > now, + Subscription.status.in_( + [SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value] + ), + ) + ) + active_trials = active_trials_result.scalar() or 0 + + resettable_trials_result = await db.execute( + select(func.count(Subscription.id)) + .join(User, Subscription.user_id == User.id) + .where( + Subscription.is_trial.is_(True), + Subscription.end_date <= now, + User.has_had_paid_subscription.is_(False), + ) + ) + resettable_trials = resettable_trials_result.scalar() or 0 + + return { + "used_trials": total_trials, + "active_trials": active_trials, + "resettable_trials": resettable_trials, + } + + +async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int: + now = datetime.utcnow() + + result = await db.execute( + select(Subscription) + .options( + selectinload(Subscription.user), + selectinload(Subscription.subscription_servers), + ) + .join(User, Subscription.user_id == User.id) + .where( + Subscription.is_trial.is_(True), + Subscription.end_date <= now, + User.has_had_paid_subscription.is_(False), + ) + ) + + subscriptions = result.scalars().unique().all() + if not subscriptions: + return 0 + + reset_count = len(subscriptions) + for subscription in subscriptions: + try: + await decrement_subscription_server_counts( + db, + subscription, + subscription_servers=subscription.subscription_servers, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Не удалось обновить счётчики серверов при сбросе триала %s: %s", + subscription.id, + error, + ) + + subscription_ids = [subscription.id for subscription in subscriptions] + + if subscription_ids: + try: + await db.execute( + delete(SubscriptionServer).where( + SubscriptionServer.subscription_id.in_(subscription_ids) + ) + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Ошибка удаления серверных связей триалов %s: %s", + subscription_ids, + error, + ) + raise + + await db.execute(delete(Subscription).where(Subscription.id.in_(subscription_ids))) + + try: + await db.commit() + except Exception as error: # pragma: no cover - defensive logging + await db.rollback() + logger.error("Ошибка сохранения сброса триалов: %s", error) + raise + + logger.info("♻️ Сброшено триальных подписок: %s", reset_count) + return reset_count + async def update_subscription_usage( db: AsyncSession, subscription: Subscription, diff --git a/app/handlers/admin/trials.py b/app/handlers/admin/trials.py new file mode 100644 index 00000000..9a95f192 --- /dev/null +++ b/app/handlers/admin/trials.py @@ -0,0 +1,86 @@ +import logging + +from aiogram import Dispatcher, F, types +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.subscription import ( + get_trial_statistics, + reset_trials_for_users_without_paid_subscription, +) +from app.database.models import User +from app.keyboards.admin import get_admin_trials_keyboard +from app.localization.texts import get_texts +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_trials_panel( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + stats = await get_trial_statistics(db) + message = texts.t("ADMIN_TRIALS_TITLE", "🧪 Управление триалами") + "\n\n" + texts.t( + "ADMIN_TRIALS_STATS", + "• Использовано всего: {used}\n" + "• Активно сейчас: {active}\n" + "• Доступно к сбросу: {resettable}", + ).format( + used=stats.get("used_trials", 0), + active=stats.get("active_trials", 0), + resettable=stats.get("resettable_trials", 0), + ) + + await callback.message.edit_text( + message, + reply_markup=get_admin_trials_keyboard(db_user.language), + ) + await callback.answer() + + +@admin_required +@error_handler +async def reset_trials( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + reset_count = await reset_trials_for_users_without_paid_subscription(db) + stats = await get_trial_statistics(db) + + message = texts.t( + "ADMIN_TRIALS_RESET_RESULT", + "♻️ Сбросили {reset_count} триалов.\n\n" + "• Использовано всего: {used}\n" + "• Активно сейчас: {active}\n" + "• Доступно к сбросу: {resettable}", + ).format( + reset_count=reset_count, + used=stats.get("used_trials", 0), + active=stats.get("active_trials", 0), + resettable=stats.get("resettable_trials", 0), + ) + + await callback.message.edit_text( + message, + reply_markup=get_admin_trials_keyboard(db_user.language), + ) + await callback.answer(texts.t("ADMIN_TRIALS_RESET_TOAST", "✅ Сброс завершен")) + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register( + show_trials_panel, + F.data == "admin_trials", + ) + dp.callback_query.register( + reset_trials, + F.data == "admin_trials_reset", + ) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index db1abbc9..6272a531 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -54,6 +54,10 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ), ], [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_MAIN_TRIALS", "🧪 Триалы"), + callback_data="admin_trials", + ), InlineKeyboardButton( text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"), callback_data="admin_payments", @@ -241,6 +245,20 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar ]) +def get_admin_trials_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_TRIALS_RESET_BUTTON", "♻️ Сбросить все триалы"), + callback_data="admin_trials_reset", + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")], + ]) + + def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index a93a87c4..dc474150 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -130,6 +130,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Settings", "ADMIN_MAIN_SUPPORT": "🛟 Support", "ADMIN_MAIN_SYSTEM": "🛠️ System", + "ADMIN_MAIN_TRIALS": "🧪 Trials", "ADMIN_MAIN_PAYMENTS": "💳 Top-ups", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions", "ADMIN_MESSAGES": "📨 Broadcasts", @@ -168,6 +169,11 @@ "ADMIN_PAYMENTS_TITLE": "💳 Top-up verification", "ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.", "ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.", + "ADMIN_TRIALS_TITLE": "🧪 Trial management", + "ADMIN_TRIALS_STATS": "• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}", + "ADMIN_TRIALS_RESET_BUTTON": "♻️ Reset all trials", + "ADMIN_TRIALS_RESET_RESULT": "♻️ Reset {reset_count} trials.\n\n• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}", + "ADMIN_TRIALS_RESET_TOAST": "✅ Reset completed", "ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.", "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}", "ADMIN_PAYMENT_STATUS_PENDING": "Pending", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 641630ea..da8b89f9 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -130,6 +130,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", + "ADMIN_MAIN_TRIALS": "🧪 Триалы", "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", "ADMIN_MESSAGES": "📨 Рассылки", @@ -168,6 +169,11 @@ "ADMIN_PAYMENTS_TITLE": "💳 Проверка пополнений", "ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.", "ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.", + "ADMIN_TRIALS_TITLE": "🧪 Управление триалами", + "ADMIN_TRIALS_STATS": "• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}", + "ADMIN_TRIALS_RESET_BUTTON": "♻️ Сбросить все триалы", + "ADMIN_TRIALS_RESET_RESULT": "♻️ Сбросили {reset_count} триалов.\n\n• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}", + "ADMIN_TRIALS_RESET_TOAST": "✅ Сброс завершен", "ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.", "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}", "ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 6b9ad510..4c8fb4b9 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -129,6 +129,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Налаштування", "ADMIN_MAIN_SUPPORT": "🛟 Підтримка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", + "ADMIN_MAIN_TRIALS": "🧪 Тріали", "ADMIN_MAIN_PAYMENTS": "💳 Поповнення", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзери/Підписки", "ADMIN_MESSAGES": "📨 Розсилки", @@ -167,6 +168,11 @@ "ADMIN_PAYMENTS_TITLE": "💳 Перевірка поповнень", "ADMIN_PAYMENTS_DESCRIPTION": "Список рахунків на поповнення, створених за останні 24 години, які очікують на оплату.", "ADMIN_PAYMENTS_NOTICE": "Перевіряти можна лише рахунки, молодші 24 годин, зі статусом очікування.", + "ADMIN_TRIALS_TITLE": "🧪 Керування тріалами", + "ADMIN_TRIALS_STATS": "• Використано всього: {used}\n• Активні зараз: {active}\n• Доступні для скидання: {resettable}", + "ADMIN_TRIALS_RESET_BUTTON": "♻️ Скинути всі тріали", + "ADMIN_TRIALS_RESET_RESULT": "♻️ Скинули {reset_count} тріалів.\n\n• Використано всього: {used}\n• Активні зараз: {active}\n• Доступні для скидання: {resettable}", + "ADMIN_TRIALS_RESET_TOAST": "✅ Скидання завершено", "ADMIN_PAYMENTS_EMPTY": "За останні 24 години не знайдено рахунків на поповнення в очікуванні.", "ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}", "ADMIN_PAYMENT_STATUS_PENDING": "Очікує оплати", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 4c9f765f..84210ac8 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -129,6 +129,7 @@ "ADMIN_MAIN_SETTINGS":"⚙️设置", "ADMIN_MAIN_SUPPORT":"🛟支持", "ADMIN_MAIN_SYSTEM":"🛠️系统", +"ADMIN_MAIN_TRIALS":"🧪试用", "ADMIN_MAIN_PAYMENTS":"💳充值", "ADMIN_MAIN_USERS_SUBSCRIPTIONS":"👥用户/订阅", "ADMIN_MESSAGES":"📨广播", @@ -167,6 +168,11 @@ "ADMIN_PAYMENTS_TITLE":"💳充值检查", "ADMIN_PAYMENTS_DESCRIPTION":"过去24小时内创建并等待付款的充值账单列表。", "ADMIN_PAYMENTS_NOTICE":"只能检查24小时内且状态为等待中的账单。", +"ADMIN_TRIALS_TITLE":"🧪 试用管理", +"ADMIN_TRIALS_STATS":"• 已使用试用总数: {used}\n• 当前活跃: {active}\n• 可重置: {resettable}", +"ADMIN_TRIALS_RESET_BUTTON":"♻️ 重置所有试用", +"ADMIN_TRIALS_RESET_RESULT":"♻️ 已重置 {reset_count} 个试用。\n\n• 已使用试用总数: {used}\n• 当前活跃: {active}\n• 可重置: {resettable}", +"ADMIN_TRIALS_RESET_TOAST":"✅ 重置完成", "ADMIN_PAYMENTS_EMPTY":"过去24小时内未找到等待中的充值账单。", "ADMIN_PAYMENTS_ITEM_DETAILS":"📄№{number}", "ADMIN_PAYMENT_STATUS_PENDING":"等待付款", From 1fdf1e49a3a4406dd8a0aebd7138781719aac4d0 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 20 Nov 2025 23:06:34 +0300 Subject: [PATCH 04/20] Handle missing channel link in subscription check --- app/keyboards/inline.py | 27 ++++++++++------ app/middlewares/channel_checker.py | 51 ++++++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 5e3f30f3..cb34fdd7 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -128,27 +128,34 @@ def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup ]) def get_channel_sub_keyboard( - channel_link: str, + channel_link: Optional[str], language: str = DEFAULT_LANGUAGE, ) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup( - inline_keyboard=[ + + buttons: List[List[InlineKeyboardButton]] = [] + + if channel_link: + buttons.append( [ InlineKeyboardButton( text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"), url=channel_link, ) - ], - [ - InlineKeyboardButton( - text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"), - callback_data="sub_channel_check", - ) - ], + ] + ) + + buttons.append( + [ + InlineKeyboardButton( + text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"), + callback_data="sub_channel_check", + ) ] ) + return InlineKeyboardMarkup(inline_keyboard=buttons) + def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index cad6e00d..9ec22d56 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -82,7 +82,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): bot: Bot = data["bot"] channel_id = settings.CHANNEL_SUB_ID - + if not channel_id: logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку") return await handler(event, data) @@ -93,7 +93,12 @@ class ChannelCheckerMiddleware(BaseMiddleware): logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку") return await handler(event, data) - channel_link = settings.CHANNEL_LINK + channel_link = self._normalize_channel_link(settings.CHANNEL_LINK, channel_id) + + if not channel_link: + logger.warning( + "⚠️ CHANNEL_LINK не задан или невалиден, кнопка подписки будет скрыта" + ) try: member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id) @@ -112,16 +117,16 @@ class ChannelCheckerMiddleware(BaseMiddleware): await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True) return - return await self._deny_message(event, bot, channel_link) + return await self._deny_message(event, bot, channel_link, channel_id) else: logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}") await self._capture_start_payload(state, event, bot) - return await self._deny_message(event, bot, channel_link) + return await self._deny_message(event, bot, channel_link, channel_id) except TelegramForbiddenError as e: logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}") await self._capture_start_payload(state, event, bot) - return await self._deny_message(event, bot, channel_link) + return await self._deny_message(event, bot, channel_link, channel_id) except TelegramBadRequest as e: if "chat not found" in str(e).lower(): logger.error(f"❌ Канал {channel_id} не найден: {e}") @@ -130,11 +135,29 @@ class ChannelCheckerMiddleware(BaseMiddleware): else: logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}") await self._capture_start_payload(state, event, bot) - return await self._deny_message(event, bot, channel_link) + return await self._deny_message(event, bot, channel_link, channel_id) except Exception as e: logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}") return await handler(event, data) + @staticmethod + def _normalize_channel_link(channel_link: Optional[str], channel_id: Optional[str]) -> Optional[str]: + link = (channel_link or "").strip() + + if link.startswith("@"): # raw username + return f"https://t.me/{link.lstrip('@')}" + + if link and not link.lower().startswith(("http://", "https://", "tg://")): + return f"https://{link}" + + if link: + return link + + if channel_id and str(channel_id).startswith("@"): + return f"https://t.me/{str(channel_id).lstrip('@')}" + + return None + async def _capture_start_payload( self, state: Optional[FSMContext], @@ -278,7 +301,12 @@ class ChannelCheckerMiddleware(BaseMiddleware): break @staticmethod - async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str): + async def _deny_message( + event: TelegramObject, + bot: Bot, + channel_link: Optional[str], + channel_id: Optional[str], + ): logger.debug("🚫 Отправляем сообщение о необходимости подписки") user = None @@ -301,6 +329,15 @@ class ChannelCheckerMiddleware(BaseMiddleware): "🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!", ) + if not channel_link and channel_id: + channel_hint = None + + if str(channel_id).startswith("@"): # username-based channel id + channel_hint = f"@{str(channel_id).lstrip('@')}" + + if channel_hint: + text = f"{text}\n\n{channel_hint}" + try: if isinstance(event, Message): return await event.answer(text, reply_markup=channel_sub_kb) From 545c5fd7493464b9a7250e4704e6b6f8eb516de2 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 20 Nov 2025 23:12:49 +0300 Subject: [PATCH 05/20] Eager load promo groups for autopay renewals --- app/services/monitoring_service.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 78675f50..9a0f6f95 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -912,12 +912,17 @@ class MonitoringService: result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user).options( + selectinload(User.promo_group), + selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), + ) + ) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.autopay_enabled == True, - Subscription.is_trial == False + Subscription.is_trial == False ) ) ) From e287adcb8484115452d46252395a15b1101103c0 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 03:14:25 +0300 Subject: [PATCH 06/20] Clean up Telegram Stars payment messages --- app/handlers/balance/stars.py | 110 ++++++++++++++++++++++++++------- app/handlers/stars_payments.py | 16 ++++- app/services/payment/stars.py | 30 --------- 3 files changed, 104 insertions(+), 52 deletions(-) diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index 0dcf0031..cd94cb8b 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -15,6 +15,36 @@ from app.external.telegram_stars import TelegramStarsService logger = logging.getLogger(__name__) +_user_stars_messages: dict[int, dict[str, int]] = {} + + +def _remember_user_message(user_id: int, key: str, message_id: int) -> None: + """Сохраняет ID служебного сообщения Stars для последующего удаления.""" + + store = _user_stars_messages.setdefault(user_id, {}) + store[key] = message_id + + +def _pop_user_message(user_id: int, key: str) -> int | None: + """Извлекает и удаляет сохранённый ID сообщения пользователя.""" + + store = _user_stars_messages.get(user_id) + if not store: + return None + + message_id = store.pop(key, None) + if not store: + _user_stars_messages.pop(user_id, None) + + return message_id + + +def pop_stars_invoice_message(user_id: int) -> int | None: + """Возвращает сохранённое сообщение с invoice Stars для удаления.""" + + return _pop_user_message(user_id, "stars_invoice_message_id") + + @error_handler async def start_stars_payment( callback: types.CallbackQuery, @@ -51,9 +81,12 @@ async def start_stars_payment( message_text, reply_markup=keyboard ) - + await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="stars") + await state.update_data( + payment_method="stars", + stars_prompt_message_id=callback.message.message_id, + ) await callback.answer() @@ -65,40 +98,75 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] - ]) - - await message.answer( - f"⭐ Оплата через Telegram Stars\n\n" - f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" - f"⭐ К оплате: {stars_amount} звезд\n" - f"📊 Курс: {stars_rate}₽ за звезду\n\n" + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], + [ + types.InlineKeyboardButton( + text=texts.BACK, callback_data="balance_topup" + ) + ], + ] + ) + + prompt_data = await state.get_data() + prompt_message_id = prompt_data.get("stars_prompt_message_id") + + if prompt_message_id: + try: + await message.bot.delete_message( + chat_id=message.chat.id, + message_id=prompt_message_id, + ) + except Exception: + logger.warning( + "Не удалось удалить сообщение с запросом суммы Stars", + exc_info=True, + ) + + try: + await message.delete() + except Exception: + logger.warning( + "Не удалось удалить сообщение пользователя с суммой Stars", + exc_info=True, + ) + + invoice_message = await message.answer( + f"⭐ Оплата через Telegram Stars\n\n", + f"💰 Сумма: {texts.format_price(amount_kopeks)}\n", + f"⭐ К оплате: {stars_amount} звезд\n", + f"📊 Курс: {stars_rate}₽ за звезду\n\n", f"Нажмите кнопку ниже для оплаты:", reply_markup=keyboard, - parse_mode="HTML" + parse_mode="HTML", ) - + + _remember_user_message( + db_user.telegram_id, + "stars_invoice_message_id", + invoice_message.message_id, + ) + await state.clear() - + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file + await message.answer("⚠️ Ошибка создания платежа") diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 4b49f51f..12c5eebf 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -10,6 +10,7 @@ from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts +from app.handlers.balance.stars import pop_stars_invoice_message logger = logging.getLogger(__name__) @@ -113,8 +114,21 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - + if success: + invoice_message_id = pop_stars_invoice_message(user.telegram_id) + if invoice_message_id: + try: + await message.bot.delete_message( + chat_id=message.chat.id, + message_id=invoice_message_id, + ) + except Exception: + logger.warning( + "Не удалось удалить сообщение с invoice Stars", + exc_info=True, + ) + rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) amount_text = settings.format_price(amount_kopeks).replace(" ₽", "") diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index b7fd5942..3f1022d7 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,36 +515,6 @@ class TelegramStarsMixin: exc_info=True, ) - if getattr(self, "bot", None): - try: - keyboard = await self.build_topup_success_keyboard(user) - - charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] - - await self.bot.send_message( - user.telegram_id, - ( - "✅ Пополнение успешно!\n\n" - f"⭐ Звезд: {stars_amount}\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - "🦊 Способ: Telegram Stars\n" - f"🆔 Транзакция: {charge_id_short}...\n\n" - "Баланс пополнен автоматически!" - ), - parse_mode="HTML", - reply_markup=keyboard, - ) - logger.info( - "✅ Отправлено уведомление пользователю %s о пополнении на %s", - user.telegram_id, - settings.format_price(amount_kopeks), - ) - except Exception as error: # pragma: no cover - диагностический лог - logger.error( - "Ошибка отправки уведомления о пополнении Stars: %s", - error, - ) - # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types From 70d50e9c474451d39eb9cb17baf71c5a16f5e58b Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 03:16:04 +0300 Subject: [PATCH 07/20] Revert "Clean up Telegram Stars payment messages" --- app/handlers/balance/stars.py | 110 +++++++-------------------------- app/handlers/stars_payments.py | 16 +---- app/services/payment/stars.py | 30 +++++++++ 3 files changed, 52 insertions(+), 104 deletions(-) diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index cd94cb8b..0dcf0031 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -15,36 +15,6 @@ from app.external.telegram_stars import TelegramStarsService logger = logging.getLogger(__name__) -_user_stars_messages: dict[int, dict[str, int]] = {} - - -def _remember_user_message(user_id: int, key: str, message_id: int) -> None: - """Сохраняет ID служебного сообщения Stars для последующего удаления.""" - - store = _user_stars_messages.setdefault(user_id, {}) - store[key] = message_id - - -def _pop_user_message(user_id: int, key: str) -> int | None: - """Извлекает и удаляет сохранённый ID сообщения пользователя.""" - - store = _user_stars_messages.get(user_id) - if not store: - return None - - message_id = store.pop(key, None) - if not store: - _user_stars_messages.pop(user_id, None) - - return message_id - - -def pop_stars_invoice_message(user_id: int) -> int | None: - """Возвращает сохранённое сообщение с invoice Stars для удаления.""" - - return _pop_user_message(user_id, "stars_invoice_message_id") - - @error_handler async def start_stars_payment( callback: types.CallbackQuery, @@ -81,12 +51,9 @@ async def start_stars_payment( message_text, reply_markup=keyboard ) - + await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data( - payment_method="stars", - stars_prompt_message_id=callback.message.message_id, - ) + await state.update_data(payment_method="stars") await callback.answer() @@ -98,75 +65,40 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], - [ - types.InlineKeyboardButton( - text=texts.BACK, callback_data="balance_topup" - ) - ], - ] - ) - - prompt_data = await state.get_data() - prompt_message_id = prompt_data.get("stars_prompt_message_id") - - if prompt_message_id: - try: - await message.bot.delete_message( - chat_id=message.chat.id, - message_id=prompt_message_id, - ) - except Exception: - logger.warning( - "Не удалось удалить сообщение с запросом суммы Stars", - exc_info=True, - ) - - try: - await message.delete() - except Exception: - logger.warning( - "Не удалось удалить сообщение пользователя с суммой Stars", - exc_info=True, - ) - - invoice_message = await message.answer( - f"⭐ Оплата через Telegram Stars\n\n", - f"💰 Сумма: {texts.format_price(amount_kopeks)}\n", - f"⭐ К оплате: {stars_amount} звезд\n", - f"📊 Курс: {stars_rate}₽ за звезду\n\n", + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] + ]) + + await message.answer( + f"⭐ Оплата через Telegram Stars\n\n" + f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" + f"⭐ К оплате: {stars_amount} звезд\n" + f"📊 Курс: {stars_rate}₽ за звезду\n\n" f"Нажмите кнопку ниже для оплаты:", reply_markup=keyboard, - parse_mode="HTML", + parse_mode="HTML" ) - - _remember_user_message( - db_user.telegram_id, - "stars_invoice_message_id", - invoice_message.message_id, - ) - + await state.clear() - + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") + await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 12c5eebf..4b49f51f 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -10,7 +10,6 @@ from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts -from app.handlers.balance.stars import pop_stars_invoice_message logger = logging.getLogger(__name__) @@ -114,21 +113,8 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - + if success: - invoice_message_id = pop_stars_invoice_message(user.telegram_id) - if invoice_message_id: - try: - await message.bot.delete_message( - chat_id=message.chat.id, - message_id=invoice_message_id, - ) - except Exception: - logger.warning( - "Не удалось удалить сообщение с invoice Stars", - exc_info=True, - ) - rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) amount_text = settings.format_price(amount_kopeks).replace(" ₽", "") diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index 3f1022d7..b7fd5942 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,6 +515,36 @@ class TelegramStarsMixin: exc_info=True, ) + if getattr(self, "bot", None): + try: + keyboard = await self.build_topup_success_keyboard(user) + + charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] + + await self.bot.send_message( + user.telegram_id, + ( + "✅ Пополнение успешно!\n\n" + f"⭐ Звезд: {stars_amount}\n" + f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" + "🦊 Способ: Telegram Stars\n" + f"🆔 Транзакция: {charge_id_short}...\n\n" + "Баланс пополнен автоматически!" + ), + parse_mode="HTML", + reply_markup=keyboard, + ) + logger.info( + "✅ Отправлено уведомление пользователю %s о пополнении на %s", + user.telegram_id, + settings.format_price(amount_kopeks), + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка отправки уведомления о пополнении Stars: %s", + error, + ) + # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types From af42377c3bbea7b311f6a25cc165b56ed413ad84 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 03:16:24 +0300 Subject: [PATCH 08/20] Clean up Telegram Stars top-up messages --- app/handlers/balance/stars.py | 72 +++++++++++++++++++-------- app/handlers/stars_payment_cleanup.py | 19 +++++++ app/handlers/stars_payments.py | 11 +++- app/services/payment/stars.py | 30 ----------- 4 files changed, 80 insertions(+), 52 deletions(-) create mode 100644 app/handlers/stars_payment_cleanup.py diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index 0dcf0031..4bd99483 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -22,11 +22,11 @@ async def start_stars_payment( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) return - + # Формируем текст сообщения в зависимости от настройки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: message_text = ( @@ -35,10 +35,10 @@ async def start_stars_payment( ) else: message_text = texts.TOP_UP_AMOUNT - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons @@ -46,14 +46,18 @@ async def start_stars_payment( if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard ) - + await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="stars") + await state.update_data( + payment_method="stars", + prompt_message_id=callback.message.message_id, + prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -65,40 +69,66 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - - await message.answer( - f"⭐ Оплата через Telegram Stars\n\n" - f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" - f"⭐ К оплате: {stars_amount} звезд\n" - f"📊 Курс: {stars_rate}₽ за звезду\n\n" + + invoice_message = await message.answer( + f"⭐ Оплата через Telegram Stars\n\n", + f"💰 Сумма: {texts.format_price(amount_kopeks)}\n", + f"⭐ К оплате: {stars_amount} звезд\n", + f"📊 Курс: {stars_rate}₽ за звезду\n\n", f"Нажмите кнопку ниже для оплаты:", reply_markup=keyboard, parse_mode="HTML" ) - + + data = await state.get_data() + prompt_message_id = data.get("prompt_message_id") + prompt_chat_id = data.get("prompt_chat_id") + + if prompt_message_id and prompt_chat_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception: + logger.debug("Не удалось удалить сообщение с запросом суммы Stars", exc_info=True) + + try: + await message.delete() + except Exception: + logger.debug("Не удалось удалить сообщение пользователя с суммой Stars", exc_info=True) + + try: + from app.handlers.stars_payment_cleanup import remember_stars_invoice_message + + remember_stars_invoice_message( + payload=f"balance_{db_user.id}_{amount_kopeks}", + chat_id=message.chat.id, + message_id=invoice_message.message_id, + ) + except Exception: + logger.debug("Не удалось сохранить данные сообщения инвойса Stars", exc_info=True) + await state.clear() - + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file + await message.answer("⚠️ Ошибка создания платежа") diff --git a/app/handlers/stars_payment_cleanup.py b/app/handlers/stars_payment_cleanup.py new file mode 100644 index 00000000..573f441f --- /dev/null +++ b/app/handlers/stars_payment_cleanup.py @@ -0,0 +1,19 @@ +"""Вспомогательные функции для удаления служебных сообщений Stars после оплаты.""" + +from __future__ import annotations + +from typing import Dict, Optional, Tuple + +_stars_invoice_messages: Dict[str, Tuple[int, int]] = {} + + +def remember_stars_invoice_message(payload: str, chat_id: int, message_id: int) -> None: + """Сохраняет информацию о сообщении с инвойсом Stars для последующего удаления.""" + + _stars_invoice_messages[payload] = (chat_id, message_id) + + +def pop_stars_invoice_message(payload: str) -> Optional[Tuple[int, int]]: + """Возвращает и удаляет сохранённое сообщение инвойса Stars, если оно есть.""" + + return _stars_invoice_messages.pop(payload, None) diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 4b49f51f..95bf10de 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User +from app.handlers.stars_payment_cleanup import pop_stars_invoice_message from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id @@ -113,7 +114,7 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - + if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) @@ -123,6 +124,14 @@ async def handle_successful_payment( transaction_id_short = payment.telegram_payment_charge_id[:8] + invoice_message = pop_stars_invoice_message(payment.invoice_payload) + if invoice_message: + chat_id, message_id = invoice_message + try: + await message.bot.delete_message(chat_id, message_id) + except Exception: + logger.debug("Не удалось удалить сообщение со счетом Stars", exc_info=True) + await message.answer( texts.t( "STARS_PAYMENT_SUCCESS", diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index b7fd5942..3f1022d7 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,36 +515,6 @@ class TelegramStarsMixin: exc_info=True, ) - if getattr(self, "bot", None): - try: - keyboard = await self.build_topup_success_keyboard(user) - - charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] - - await self.bot.send_message( - user.telegram_id, - ( - "✅ Пополнение успешно!\n\n" - f"⭐ Звезд: {stars_amount}\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - "🦊 Способ: Telegram Stars\n" - f"🆔 Транзакция: {charge_id_short}...\n\n" - "Баланс пополнен автоматически!" - ), - parse_mode="HTML", - reply_markup=keyboard, - ) - logger.info( - "✅ Отправлено уведомление пользователю %s о пополнении на %s", - user.telegram_id, - settings.format_price(amount_kopeks), - ) - except Exception as error: # pragma: no cover - диагностический лог - logger.error( - "Ошибка отправки уведомления о пополнении Stars: %s", - error, - ) - # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types From b6e333127cf3e3aab85bd5a6b78c3f253bcd03d3 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 03:18:10 +0300 Subject: [PATCH 09/20] Revert "Clean up Telegram Stars top-up messages" --- app/handlers/balance/stars.py | 72 ++++++++------------------- app/handlers/stars_payment_cleanup.py | 19 ------- app/handlers/stars_payments.py | 11 +--- app/services/payment/stars.py | 30 +++++++++++ 4 files changed, 52 insertions(+), 80 deletions(-) delete mode 100644 app/handlers/stars_payment_cleanup.py diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index 4bd99483..0dcf0031 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -22,11 +22,11 @@ async def start_stars_payment( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) return - + # Формируем текст сообщения в зависимости от настройки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: message_text = ( @@ -35,10 +35,10 @@ async def start_stars_payment( ) else: message_text = texts.TOP_UP_AMOUNT - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons @@ -46,18 +46,14 @@ async def start_stars_payment( if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard ) - + await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data( - payment_method="stars", - prompt_message_id=callback.message.message_id, - prompt_chat_id=callback.message.chat.id, - ) + await state.update_data(payment_method="stars") await callback.answer() @@ -69,66 +65,40 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - - invoice_message = await message.answer( - f"⭐ Оплата через Telegram Stars\n\n", - f"💰 Сумма: {texts.format_price(amount_kopeks)}\n", - f"⭐ К оплате: {stars_amount} звезд\n", - f"📊 Курс: {stars_rate}₽ за звезду\n\n", + + await message.answer( + f"⭐ Оплата через Telegram Stars\n\n" + f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" + f"⭐ К оплате: {stars_amount} звезд\n" + f"📊 Курс: {stars_rate}₽ за звезду\n\n" f"Нажмите кнопку ниже для оплаты:", reply_markup=keyboard, parse_mode="HTML" ) - - data = await state.get_data() - prompt_message_id = data.get("prompt_message_id") - prompt_chat_id = data.get("prompt_chat_id") - - if prompt_message_id and prompt_chat_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception: - logger.debug("Не удалось удалить сообщение с запросом суммы Stars", exc_info=True) - - try: - await message.delete() - except Exception: - logger.debug("Не удалось удалить сообщение пользователя с суммой Stars", exc_info=True) - - try: - from app.handlers.stars_payment_cleanup import remember_stars_invoice_message - - remember_stars_invoice_message( - payload=f"balance_{db_user.id}_{amount_kopeks}", - chat_id=message.chat.id, - message_id=invoice_message.message_id, - ) - except Exception: - logger.debug("Не удалось сохранить данные сообщения инвойса Stars", exc_info=True) - + await state.clear() - + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") + await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file diff --git a/app/handlers/stars_payment_cleanup.py b/app/handlers/stars_payment_cleanup.py deleted file mode 100644 index 573f441f..00000000 --- a/app/handlers/stars_payment_cleanup.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Вспомогательные функции для удаления служебных сообщений Stars после оплаты.""" - -from __future__ import annotations - -from typing import Dict, Optional, Tuple - -_stars_invoice_messages: Dict[str, Tuple[int, int]] = {} - - -def remember_stars_invoice_message(payload: str, chat_id: int, message_id: int) -> None: - """Сохраняет информацию о сообщении с инвойсом Stars для последующего удаления.""" - - _stars_invoice_messages[payload] = (chat_id, message_id) - - -def pop_stars_invoice_message(payload: str) -> Optional[Tuple[int, int]]: - """Возвращает и удаляет сохранённое сообщение инвойса Stars, если оно есть.""" - - return _stars_invoice_messages.pop(payload, None) diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 95bf10de..4b49f51f 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -5,7 +5,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User -from app.handlers.stars_payment_cleanup import pop_stars_invoice_message from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id @@ -114,7 +113,7 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - + if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) @@ -124,14 +123,6 @@ async def handle_successful_payment( transaction_id_short = payment.telegram_payment_charge_id[:8] - invoice_message = pop_stars_invoice_message(payment.invoice_payload) - if invoice_message: - chat_id, message_id = invoice_message - try: - await message.bot.delete_message(chat_id, message_id) - except Exception: - logger.debug("Не удалось удалить сообщение со счетом Stars", exc_info=True) - await message.answer( texts.t( "STARS_PAYMENT_SUCCESS", diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index 3f1022d7..b7fd5942 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,6 +515,36 @@ class TelegramStarsMixin: exc_info=True, ) + if getattr(self, "bot", None): + try: + keyboard = await self.build_topup_success_keyboard(user) + + charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] + + await self.bot.send_message( + user.telegram_id, + ( + "✅ Пополнение успешно!\n\n" + f"⭐ Звезд: {stars_amount}\n" + f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" + "🦊 Способ: Telegram Stars\n" + f"🆔 Транзакция: {charge_id_short}...\n\n" + "Баланс пополнен автоматически!" + ), + parse_mode="HTML", + reply_markup=keyboard, + ) + logger.info( + "✅ Отправлено уведомление пользователю %s о пополнении на %s", + user.telegram_id, + settings.format_price(amount_kopeks), + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка отправки уведомления о пополнении Stars: %s", + error, + ) + # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types From c7114ec3593e98824986e3ae3c5dafe6547dadcc Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 03:24:17 +0300 Subject: [PATCH 10/20] Clean up Telegram Stars top-up messages --- app/handlers/balance/stars.py | 81 ++++++++++++++++++++++-------- app/handlers/stars_payments.py | 25 ++++++++- app/services/payment/stars.py | 30 ----------- app/utils/stars_message_cleanup.py | 35 +++++++++++++ 4 files changed, 118 insertions(+), 53 deletions(-) create mode 100644 app/utils/stars_message_cleanup.py diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index 0dcf0031..15bf5789 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,7 +1,6 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext -from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User @@ -22,11 +21,11 @@ async def start_stars_payment( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) return - + # Формируем текст сообщения в зависимости от настройки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: message_text = ( @@ -35,10 +34,10 @@ async def start_stars_payment( ) else: message_text = texts.TOP_UP_AMOUNT - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons @@ -46,14 +45,17 @@ async def start_stars_payment( if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard ) - + await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="stars") + await state.update_data( + payment_method="stars", + prompt_message_id=callback.message.message_id, + ) await callback.answer() @@ -65,40 +67,75 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - - await message.answer( - f"⭐ Оплата через Telegram Stars\n\n" - f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" - f"⭐ К оплате: {stars_amount} звезд\n" - f"📊 Курс: {stars_rate}₽ за звезду\n\n" + + prompt_message_id = (await state.get_data()).get("prompt_message_id") + + if prompt_message_id: + try: + await message.bot.delete_message( + chat_id=message.chat.id, + message_id=prompt_message_id, + ) + except Exception: + logger.warning( + "Не удалось удалить сообщение с вводом суммы Stars", + exc_info=True, + ) + + try: + await message.delete() + except Exception: + logger.warning( + "Не удалось удалить сообщение пользователя с суммой", + exc_info=True, + ) + + invoice_message = await message.answer( + f"⭐ Оплата через Telegram Stars\n\n", + f"💰 Сумма: {texts.format_price(amount_kopeks)}\n", + f"⭐ К оплате: {stars_amount} звезд\n", + f"📊 Курс: {stars_rate}₽ за звезду\n\n", f"Нажмите кнопку ниже для оплаты:", reply_markup=keyboard, parse_mode="HTML" ) - + + try: + from app.utils.stars_message_cleanup import register_stars_payment_messages + + await register_stars_payment_messages( + user_id=message.from_user.id, + message_ids=[invoice_message.message_id], + ) + except Exception: + logger.warning( + "Не удалось зарегистрировать сообщения Stars для очистки", + exc_info=True, + ) + await state.clear() - + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file + await message.answer("⚠️ Ошибка создания платежа") diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 4b49f51f..d6e81760 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -10,6 +10,7 @@ from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts +from app.utils.stars_message_cleanup import consume_stars_payment_messages logger = logging.getLogger(__name__) @@ -113,8 +114,30 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - + if success: + cleanup_message_ids = set() + + try: + cleanup_message_ids = await consume_stars_payment_messages(user_id) + except Exception: + logger.warning( + "Не удалось получить сообщения Stars для удаления", + exc_info=True, + ) + + for message_id in cleanup_message_ids: + try: + await message.bot.delete_message( + chat_id=message.chat.id, + message_id=message_id, + ) + except Exception: + logger.warning( + "Не удалось удалить промежуточное сообщение Stars", + exc_info=True, + ) + rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) amount_text = settings.format_price(amount_kopeks).replace(" ₽", "") diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index b7fd5942..3f1022d7 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,36 +515,6 @@ class TelegramStarsMixin: exc_info=True, ) - if getattr(self, "bot", None): - try: - keyboard = await self.build_topup_success_keyboard(user) - - charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] - - await self.bot.send_message( - user.telegram_id, - ( - "✅ Пополнение успешно!\n\n" - f"⭐ Звезд: {stars_amount}\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - "🦊 Способ: Telegram Stars\n" - f"🆔 Транзакция: {charge_id_short}...\n\n" - "Баланс пополнен автоматически!" - ), - parse_mode="HTML", - reply_markup=keyboard, - ) - logger.info( - "✅ Отправлено уведомление пользователю %s о пополнении на %s", - user.telegram_id, - settings.format_price(amount_kopeks), - ) - except Exception as error: # pragma: no cover - диагностический лог - logger.error( - "Ошибка отправки уведомления о пополнении Stars: %s", - error, - ) - # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types diff --git a/app/utils/stars_message_cleanup.py b/app/utils/stars_message_cleanup.py new file mode 100644 index 00000000..932ed8e5 --- /dev/null +++ b/app/utils/stars_message_cleanup.py @@ -0,0 +1,35 @@ +"""Вспомогательные функции для очистки служебных сообщений Telegram Stars.""" + +from __future__ import annotations + +import asyncio +from typing import Iterable, Set + +__all__ = [ + "register_stars_payment_messages", + "consume_stars_payment_messages", +] + + +_messages_lock = asyncio.Lock() +_messages_to_cleanup: dict[int, set[int]] = {} + + +async def register_stars_payment_messages(user_id: int, message_ids: Iterable[int]) -> None: + """Сохраняет идентификаторы сообщений для последующего удаления. + + Args: + user_id: Telegram ID пользователя. + message_ids: Сообщения, которые нужно удалить после успешной оплаты. + """ + + async with _messages_lock: + existing = _messages_to_cleanup.setdefault(user_id, set()) + existing.update(message_ids) + + +async def consume_stars_payment_messages(user_id: int) -> Set[int]: + """Возвращает и удаляет накопленные сообщения для пользователя.""" + + async with _messages_lock: + return _messages_to_cleanup.pop(user_id, set()) From 77c217f9ad3cc5f832c24010fd66498b9c0a6b89 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 03:25:55 +0300 Subject: [PATCH 11/20] Revert "Clean up Telegram Stars top-up messages" --- app/handlers/balance/stars.py | 81 ++++++++---------------------- app/handlers/stars_payments.py | 25 +-------- app/services/payment/stars.py | 30 +++++++++++ app/utils/stars_message_cleanup.py | 35 ------------- 4 files changed, 53 insertions(+), 118 deletions(-) delete mode 100644 app/utils/stars_message_cleanup.py diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index 15bf5789..0dcf0031 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,6 +1,7 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User @@ -21,11 +22,11 @@ async def start_stars_payment( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) return - + # Формируем текст сообщения в зависимости от настройки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: message_text = ( @@ -34,10 +35,10 @@ async def start_stars_payment( ) else: message_text = texts.TOP_UP_AMOUNT - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons @@ -45,17 +46,14 @@ async def start_stars_payment( if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard ) - + await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data( - payment_method="stars", - prompt_message_id=callback.message.message_id, - ) + await state.update_data(payment_method="stars") await callback.answer() @@ -67,75 +65,40 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - - prompt_message_id = (await state.get_data()).get("prompt_message_id") - - if prompt_message_id: - try: - await message.bot.delete_message( - chat_id=message.chat.id, - message_id=prompt_message_id, - ) - except Exception: - logger.warning( - "Не удалось удалить сообщение с вводом суммы Stars", - exc_info=True, - ) - - try: - await message.delete() - except Exception: - logger.warning( - "Не удалось удалить сообщение пользователя с суммой", - exc_info=True, - ) - - invoice_message = await message.answer( - f"⭐ Оплата через Telegram Stars\n\n", - f"💰 Сумма: {texts.format_price(amount_kopeks)}\n", - f"⭐ К оплате: {stars_amount} звезд\n", - f"📊 Курс: {stars_rate}₽ за звезду\n\n", + + await message.answer( + f"⭐ Оплата через Telegram Stars\n\n" + f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" + f"⭐ К оплате: {stars_amount} звезд\n" + f"📊 Курс: {stars_rate}₽ за звезду\n\n" f"Нажмите кнопку ниже для оплаты:", reply_markup=keyboard, parse_mode="HTML" ) - - try: - from app.utils.stars_message_cleanup import register_stars_payment_messages - - await register_stars_payment_messages( - user_id=message.from_user.id, - message_ids=[invoice_message.message_id], - ) - except Exception: - logger.warning( - "Не удалось зарегистрировать сообщения Stars для очистки", - exc_info=True, - ) - + await state.clear() - + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") + await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index d6e81760..4b49f51f 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -10,7 +10,6 @@ from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts -from app.utils.stars_message_cleanup import consume_stars_payment_messages logger = logging.getLogger(__name__) @@ -114,30 +113,8 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - + if success: - cleanup_message_ids = set() - - try: - cleanup_message_ids = await consume_stars_payment_messages(user_id) - except Exception: - logger.warning( - "Не удалось получить сообщения Stars для удаления", - exc_info=True, - ) - - for message_id in cleanup_message_ids: - try: - await message.bot.delete_message( - chat_id=message.chat.id, - message_id=message_id, - ) - except Exception: - logger.warning( - "Не удалось удалить промежуточное сообщение Stars", - exc_info=True, - ) - rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) amount_text = settings.format_price(amount_kopeks).replace(" ₽", "") diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index 3f1022d7..b7fd5942 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,6 +515,36 @@ class TelegramStarsMixin: exc_info=True, ) + if getattr(self, "bot", None): + try: + keyboard = await self.build_topup_success_keyboard(user) + + charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] + + await self.bot.send_message( + user.telegram_id, + ( + "✅ Пополнение успешно!\n\n" + f"⭐ Звезд: {stars_amount}\n" + f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" + "🦊 Способ: Telegram Stars\n" + f"🆔 Транзакция: {charge_id_short}...\n\n" + "Баланс пополнен автоматически!" + ), + parse_mode="HTML", + reply_markup=keyboard, + ) + logger.info( + "✅ Отправлено уведомление пользователю %s о пополнении на %s", + user.telegram_id, + settings.format_price(amount_kopeks), + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка отправки уведомления о пополнении Stars: %s", + error, + ) + # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types diff --git a/app/utils/stars_message_cleanup.py b/app/utils/stars_message_cleanup.py deleted file mode 100644 index 932ed8e5..00000000 --- a/app/utils/stars_message_cleanup.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Вспомогательные функции для очистки служебных сообщений Telegram Stars.""" - -from __future__ import annotations - -import asyncio -from typing import Iterable, Set - -__all__ = [ - "register_stars_payment_messages", - "consume_stars_payment_messages", -] - - -_messages_lock = asyncio.Lock() -_messages_to_cleanup: dict[int, set[int]] = {} - - -async def register_stars_payment_messages(user_id: int, message_ids: Iterable[int]) -> None: - """Сохраняет идентификаторы сообщений для последующего удаления. - - Args: - user_id: Telegram ID пользователя. - message_ids: Сообщения, которые нужно удалить после успешной оплаты. - """ - - async with _messages_lock: - existing = _messages_to_cleanup.setdefault(user_id, set()) - existing.update(message_ids) - - -async def consume_stars_payment_messages(user_id: int) -> Set[int]: - """Возвращает и удаляет накопленные сообщения для пользователя.""" - - async with _messages_lock: - return _messages_to_cleanup.pop(user_id, set()) From 29fe2f2016b1c38032eaf2cae5de8f4a6301bda9 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 03:26:34 +0300 Subject: [PATCH 12/20] Clean up Telegram Stars payment flow --- app/handlers/balance/stars.py | 66 ++++++++++++++++++++++++---------- app/handlers/stars_payments.py | 46 ++++++++++++++++++++---- app/services/payment/stars.py | 30 ---------------- 3 files changed, 86 insertions(+), 56 deletions(-) diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index 0dcf0031..a8cac0f8 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,11 +1,10 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext -from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User -from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard +from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts from app.services.payment_service import PaymentService from app.states import BalanceStates @@ -22,11 +21,11 @@ async def start_stars_payment( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) return - + # Формируем текст сообщения в зависимости от настройки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: message_text = ( @@ -35,10 +34,10 @@ async def start_stars_payment( ) else: message_text = texts.TOP_UP_AMOUNT - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons @@ -46,12 +45,17 @@ async def start_stars_payment( if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard ) - + + await state.update_data( + stars_prompt_message_id=callback.message.message_id, + stars_prompt_chat_id=callback.message.chat.id, + ) + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="stars") await callback.answer() @@ -65,29 +69,48 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - - await message.answer( + + state_data = await state.get_data() + + prompt_message_id = state_data.get("stars_prompt_message_id") + prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы Stars: %s", + delete_error, + ) + + invoice_message = await message.answer( f"⭐ Оплата через Telegram Stars\n\n" f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" f"⭐ К оплате: {stars_amount} звезд\n" @@ -96,9 +119,14 @@ async def process_stars_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - - await state.clear() - + + await state.update_data( + stars_invoice_message_id=invoice_message.message_id, + stars_invoice_chat_id=invoice_message.chat.id, + ) + + await state.set_state(None) + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file + await message.answer("⚠️ Ошибка создания платежа") diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 4b49f51f..48356450 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,10 +1,10 @@ import logging from decimal import Decimal, ROUND_HALF_UP from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import User from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id @@ -18,7 +18,9 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): texts = get_texts(DEFAULT_LANGUAGE) try: - logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}") + logger.info( + f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}" + ) allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_") @@ -35,6 +37,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): try: from app.database.database import get_db + async for db in get_db(): user = await get_user_by_telegram_id(db, query.from_user.id) if not user: @@ -77,6 +80,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): async def handle_successful_payment( message: types.Message, db: AsyncSession, + state: FSMContext, **kwargs ): texts = get_texts(DEFAULT_LANGUAGE) @@ -106,6 +110,27 @@ async def handle_successful_payment( return payment_service = PaymentService(message.bot) + + state_data = await state.get_data() + prompt_message_id = state_data.get("stars_prompt_message_id") + prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) + invoice_message_id = state_data.get("stars_invoice_message_id") + invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id) + + for chat_id, message_id, label in [ + (prompt_chat_id, prompt_message_id, "запрос суммы"), + (invoice_chat_id, invoice_message_id, "инвойс Stars"), + ]: + if message_id: + try: + await message.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning( + "Не удалось удалить сообщение %s после оплаты Stars: %s", + label, + delete_error, + ) + success = await payment_service.process_stars_payment( db=db, user_id=user.id, @@ -113,7 +138,14 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - + + await state.update_data( + stars_prompt_message_id=None, + stars_prompt_chat_id=None, + stars_invoice_message_id=None, + stars_invoice_chat_id=None, + ) + if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) @@ -172,15 +204,15 @@ async def handle_successful_payment( def register_stars_handlers(dp: Dispatcher): - + dp.pre_checkout_query.register( handle_pre_checkout_query, - F.currency == "XTR" + F.currency == "XTR" ) - + dp.message.register( handle_successful_payment, F.successful_payment ) - + logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index b7fd5942..3f1022d7 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,36 +515,6 @@ class TelegramStarsMixin: exc_info=True, ) - if getattr(self, "bot", None): - try: - keyboard = await self.build_topup_success_keyboard(user) - - charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] - - await self.bot.send_message( - user.telegram_id, - ( - "✅ Пополнение успешно!\n\n" - f"⭐ Звезд: {stars_amount}\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - "🦊 Способ: Telegram Stars\n" - f"🆔 Транзакция: {charge_id_short}...\n\n" - "Баланс пополнен автоматически!" - ), - parse_mode="HTML", - reply_markup=keyboard, - ) - logger.info( - "✅ Отправлено уведомление пользователю %s о пополнении на %s", - user.telegram_id, - settings.format_price(amount_kopeks), - ) - except Exception as error: # pragma: no cover - диагностический лог - logger.error( - "Ошибка отправки уведомления о пополнении Stars: %s", - error, - ) - # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types From 473c3704cf5e3213b1b9719779a7e66742a94fee Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 04:02:22 +0300 Subject: [PATCH 13/20] Clean up Platega and YooKassa prompts --- app/handlers/balance/platega.py | 29 ++++++++++- app/handlers/balance/stars.py | 66 +++++++++++++++++-------- app/handlers/balance/yookassa.py | 84 ++++++++++++++++++++++++++------ app/handlers/stars_payments.py | 46 ++++++++++++++--- app/services/payment/stars.py | 30 ------------ 5 files changed, 183 insertions(+), 72 deletions(-) diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py index 40e8c45f..7792a225 100644 --- a/app/handlers/balance/platega.py +++ b/app/handlers/balance/platega.py @@ -98,6 +98,10 @@ async def _prompt_amount( ) await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data( + platega_prompt_message_id=message.message_id, + platega_prompt_chat_id=message.chat.id, + ) @error_handler @@ -300,7 +304,25 @@ async def process_platega_payment_amount( ), ) - await message.answer( + state_data = await state.get_data() + prompt_message_id = state_data.get("platega_prompt_message_id") + prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы Platega: %s", + delete_error, + ) + + invoice_message = await message.answer( instructions_template.format( method=method_title, amount=settings.format_price(amount_kopeks), @@ -311,6 +333,11 @@ async def process_platega_payment_amount( parse_mode="HTML", ) + await state.update_data( + platega_invoice_message_id=invoice_message.message_id, + platega_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index 0dcf0031..a8cac0f8 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,11 +1,10 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext -from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User -from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard +from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts from app.services.payment_service import PaymentService from app.states import BalanceStates @@ -22,11 +21,11 @@ async def start_stars_payment( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) return - + # Формируем текст сообщения в зависимости от настройки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: message_text = ( @@ -35,10 +34,10 @@ async def start_stars_payment( ) else: message_text = texts.TOP_UP_AMOUNT - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons @@ -46,12 +45,17 @@ async def start_stars_payment( if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard ) - + + await state.update_data( + stars_prompt_message_id=callback.message.message_id, + stars_prompt_chat_id=callback.message.chat.id, + ) + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="stars") await callback.answer() @@ -65,29 +69,48 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - - await message.answer( + + state_data = await state.get_data() + + prompt_message_id = state_data.get("stars_prompt_message_id") + prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы Stars: %s", + delete_error, + ) + + invoice_message = await message.answer( f"⭐ Оплата через Telegram Stars\n\n" f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" f"⭐ К оплате: {stars_amount} звезд\n" @@ -96,9 +119,14 @@ async def process_stars_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - - await state.clear() - + + await state.update_data( + stars_invoice_message_id=invoice_message.message_id, + stars_invoice_chat_id=invoice_message.chat.id, + ) + + await state.set_state(None) + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file + await message.answer("⚠️ Ошибка создания платежа") diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py index 607f62bd..aad6acad 100644 --- a/app/handlers/balance/yookassa.py +++ b/app/handlers/balance/yookassa.py @@ -58,9 +58,13 @@ async def start_yookassa_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa") + await state.update_data( + yookassa_prompt_message_id=callback.message.message_id, + yookassa_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -108,9 +112,13 @@ async def start_yookassa_sbp_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa_sbp") + await state.update_data( + yookassa_prompt_message_id=callback.message.message_id, + yookassa_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -172,7 +180,25 @@ async def process_yookassa_payment_amount( [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - await message.answer( + state_data = await state.get_data() + prompt_message_id = state_data.get("yookassa_prompt_message_id") + prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы YooKassa: %s", + delete_error, + ) + + invoice_message = await message.answer( f"💳 Оплата банковской картой\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" @@ -187,9 +213,13 @@ async def process_yookassa_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - + + await state.update_data( + yookassa_invoice_message_id=invoice_message.message_id, + yookassa_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() - logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") @@ -310,27 +340,45 @@ async def process_yookassa_sbp_payment_amount( # Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса keyboard_buttons = [] - + # Добавляем кнопку оплаты, если доступна ссылка if confirmation_url: keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)]) else: # Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")]) - + # Добавляем общие кнопки keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")]) keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) - + + state_data = await state.get_data() + prompt_message_id = state_data.get("yookassa_prompt_message_id") + prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s", + delete_error, + ) + # Подготавливаем текст сообщения message_text = ( f"🔗 Оплата через СБП\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" ) - + # Добавляем инструкции в зависимости от доступных способов оплаты if not confirmation_url: message_text += ( @@ -341,18 +389,18 @@ async def process_yookassa_sbp_payment_amount( f"4. Подтвердите платеж в приложении банка\n" f"5. Деньги поступят на баланс автоматически\n\n" ) - + message_text += ( f"🔒 Оплата происходит через защищенную систему YooKassa\n" f"✅ Принимаем СБП от всех банков-участников\n\n" f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}" ) - + # Отправляем сообщение с инструкциями и клавиатурой # Если есть QR-код, отправляем его как медиа-сообщение if qr_photo: # Используем метод отправки медиа-группы или фото с описанием - await message.answer_photo( + invoice_message = await message.answer_photo( photo=qr_photo, caption=message_text, reply_markup=keyboard, @@ -360,12 +408,18 @@ async def process_yookassa_sbp_payment_amount( ) else: # Если QR-код недоступен, отправляем обычное текстовое сообщение - await message.answer( + invoice_message = await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML" ) - + + await state.update_data( + yookassa_invoice_message_id=invoice_message.message_id, + yookassa_invoice_chat_id=invoice_message.chat.id, + ) + + await state.clear() logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 4b49f51f..48356450 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,10 +1,10 @@ import logging from decimal import Decimal, ROUND_HALF_UP from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import User from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id @@ -18,7 +18,9 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): texts = get_texts(DEFAULT_LANGUAGE) try: - logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}") + logger.info( + f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}" + ) allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_") @@ -35,6 +37,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): try: from app.database.database import get_db + async for db in get_db(): user = await get_user_by_telegram_id(db, query.from_user.id) if not user: @@ -77,6 +80,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): async def handle_successful_payment( message: types.Message, db: AsyncSession, + state: FSMContext, **kwargs ): texts = get_texts(DEFAULT_LANGUAGE) @@ -106,6 +110,27 @@ async def handle_successful_payment( return payment_service = PaymentService(message.bot) + + state_data = await state.get_data() + prompt_message_id = state_data.get("stars_prompt_message_id") + prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) + invoice_message_id = state_data.get("stars_invoice_message_id") + invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id) + + for chat_id, message_id, label in [ + (prompt_chat_id, prompt_message_id, "запрос суммы"), + (invoice_chat_id, invoice_message_id, "инвойс Stars"), + ]: + if message_id: + try: + await message.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning( + "Не удалось удалить сообщение %s после оплаты Stars: %s", + label, + delete_error, + ) + success = await payment_service.process_stars_payment( db=db, user_id=user.id, @@ -113,7 +138,14 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - + + await state.update_data( + stars_prompt_message_id=None, + stars_prompt_chat_id=None, + stars_invoice_message_id=None, + stars_invoice_chat_id=None, + ) + if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) @@ -172,15 +204,15 @@ async def handle_successful_payment( def register_stars_handlers(dp: Dispatcher): - + dp.pre_checkout_query.register( handle_pre_checkout_query, - F.currency == "XTR" + F.currency == "XTR" ) - + dp.message.register( handle_successful_payment, F.successful_payment ) - + logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index b7fd5942..3f1022d7 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,36 +515,6 @@ class TelegramStarsMixin: exc_info=True, ) - if getattr(self, "bot", None): - try: - keyboard = await self.build_topup_success_keyboard(user) - - charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] - - await self.bot.send_message( - user.telegram_id, - ( - "✅ Пополнение успешно!\n\n" - f"⭐ Звезд: {stars_amount}\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - "🦊 Способ: Telegram Stars\n" - f"🆔 Транзакция: {charge_id_short}...\n\n" - "Баланс пополнен автоматически!" - ), - parse_mode="HTML", - reply_markup=keyboard, - ) - logger.info( - "✅ Отправлено уведомление пользователю %s о пополнении на %s", - user.telegram_id, - settings.format_price(amount_kopeks), - ) - except Exception as error: # pragma: no cover - диагностический лог - logger.error( - "Ошибка отправки уведомления о пополнении Stars: %s", - error, - ) - # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types From 840091cb051a3ba86637ed1d15f7169c3bbac07e Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 04:02:28 +0300 Subject: [PATCH 14/20] Revert "Clean up Telegram Stars payment flow" --- app/handlers/balance/stars.py | 66 ++++++++++------------------------ app/handlers/stars_payments.py | 46 ++++-------------------- app/services/payment/stars.py | 30 ++++++++++++++++ 3 files changed, 56 insertions(+), 86 deletions(-) diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index a8cac0f8..0dcf0031 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,10 +1,11 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User -from app.keyboards.inline import get_back_keyboard +from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard from app.localization.texts import get_texts from app.services.payment_service import PaymentService from app.states import BalanceStates @@ -21,11 +22,11 @@ async def start_stars_payment( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) return - + # Формируем текст сообщения в зависимости от настройки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: message_text = ( @@ -34,10 +35,10 @@ async def start_stars_payment( ) else: message_text = texts.TOP_UP_AMOUNT - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons @@ -45,17 +46,12 @@ async def start_stars_payment( if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard ) - - await state.update_data( - stars_prompt_message_id=callback.message.message_id, - stars_prompt_chat_id=callback.message.chat.id, - ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="stars") await callback.answer() @@ -69,48 +65,29 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - - state_data = await state.get_data() - - prompt_message_id = state_data.get("stars_prompt_message_id") - prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось удалить сообщение с запросом суммы Stars: %s", - delete_error, - ) - - invoice_message = await message.answer( + + await message.answer( f"⭐ Оплата через Telegram Stars\n\n" f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" f"⭐ К оплате: {stars_amount} звезд\n" @@ -119,14 +96,9 @@ async def process_stars_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - - await state.update_data( - stars_invoice_message_id=invoice_message.message_id, - stars_invoice_chat_id=invoice_message.chat.id, - ) - - await state.set_state(None) - + + await state.clear() + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") + await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 48356450..4b49f51f 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,10 +1,10 @@ import logging from decimal import Decimal, ROUND_HALF_UP from aiogram import Dispatcher, types, F -from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.database.models import User from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id @@ -18,9 +18,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): texts = get_texts(DEFAULT_LANGUAGE) try: - logger.info( - f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}" - ) + logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}") allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_") @@ -37,7 +35,6 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): try: from app.database.database import get_db - async for db in get_db(): user = await get_user_by_telegram_id(db, query.from_user.id) if not user: @@ -80,7 +77,6 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): async def handle_successful_payment( message: types.Message, db: AsyncSession, - state: FSMContext, **kwargs ): texts = get_texts(DEFAULT_LANGUAGE) @@ -110,27 +106,6 @@ async def handle_successful_payment( return payment_service = PaymentService(message.bot) - - state_data = await state.get_data() - prompt_message_id = state_data.get("stars_prompt_message_id") - prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) - invoice_message_id = state_data.get("stars_invoice_message_id") - invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id) - - for chat_id, message_id, label in [ - (prompt_chat_id, prompt_message_id, "запрос суммы"), - (invoice_chat_id, invoice_message_id, "инвойс Stars"), - ]: - if message_id: - try: - await message.bot.delete_message(chat_id, message_id) - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning( - "Не удалось удалить сообщение %s после оплаты Stars: %s", - label, - delete_error, - ) - success = await payment_service.process_stars_payment( db=db, user_id=user.id, @@ -138,14 +113,7 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - - await state.update_data( - stars_prompt_message_id=None, - stars_prompt_chat_id=None, - stars_invoice_message_id=None, - stars_invoice_chat_id=None, - ) - + if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) @@ -204,15 +172,15 @@ async def handle_successful_payment( def register_stars_handlers(dp: Dispatcher): - + dp.pre_checkout_query.register( handle_pre_checkout_query, - F.currency == "XTR" + F.currency == "XTR" ) - + dp.message.register( handle_successful_payment, F.successful_payment ) - + logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index 3f1022d7..b7fd5942 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,6 +515,36 @@ class TelegramStarsMixin: exc_info=True, ) + if getattr(self, "bot", None): + try: + keyboard = await self.build_topup_success_keyboard(user) + + charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] + + await self.bot.send_message( + user.telegram_id, + ( + "✅ Пополнение успешно!\n\n" + f"⭐ Звезд: {stars_amount}\n" + f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" + "🦊 Способ: Telegram Stars\n" + f"🆔 Транзакция: {charge_id_short}...\n\n" + "Баланс пополнен автоматически!" + ), + parse_mode="HTML", + reply_markup=keyboard, + ) + logger.info( + "✅ Отправлено уведомление пользователю %s о пополнении на %s", + user.telegram_id, + settings.format_price(amount_kopeks), + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка отправки уведомления о пополнении Stars: %s", + error, + ) + # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types From 29d0b54b04ce10368b111b7bccf1855a1fc5e21b Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 04:17:13 +0300 Subject: [PATCH 15/20] Revert "Clean up Platega and YooKassa prompts" --- app/handlers/balance/platega.py | 29 +---------- app/handlers/balance/stars.py | 66 ++++++++----------------- app/handlers/balance/yookassa.py | 84 ++++++-------------------------- app/handlers/stars_payments.py | 46 +++-------------- app/services/payment/stars.py | 30 ++++++++++++ 5 files changed, 72 insertions(+), 183 deletions(-) diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py index 7792a225..40e8c45f 100644 --- a/app/handlers/balance/platega.py +++ b/app/handlers/balance/platega.py @@ -98,10 +98,6 @@ async def _prompt_amount( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data( - platega_prompt_message_id=message.message_id, - platega_prompt_chat_id=message.chat.id, - ) @error_handler @@ -304,25 +300,7 @@ async def process_platega_payment_amount( ), ) - state_data = await state.get_data() - prompt_message_id = state_data.get("platega_prompt_message_id") - prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось удалить сообщение с запросом суммы Platega: %s", - delete_error, - ) - - invoice_message = await message.answer( + await message.answer( instructions_template.format( method=method_title, amount=settings.format_price(amount_kopeks), @@ -333,11 +311,6 @@ async def process_platega_payment_amount( parse_mode="HTML", ) - await state.update_data( - platega_invoice_message_id=invoice_message.message_id, - platega_invoice_chat_id=invoice_message.chat.id, - ) - await state.clear() diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index a8cac0f8..0dcf0031 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,10 +1,11 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User -from app.keyboards.inline import get_back_keyboard +from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard from app.localization.texts import get_texts from app.services.payment_service import PaymentService from app.states import BalanceStates @@ -21,11 +22,11 @@ async def start_stars_payment( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) return - + # Формируем текст сообщения в зависимости от настройки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: message_text = ( @@ -34,10 +35,10 @@ async def start_stars_payment( ) else: message_text = texts.TOP_UP_AMOUNT - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons @@ -45,17 +46,12 @@ async def start_stars_payment( if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard ) - - await state.update_data( - stars_prompt_message_id=callback.message.message_id, - stars_prompt_chat_id=callback.message.chat.id, - ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="stars") await callback.answer() @@ -69,48 +65,29 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - - state_data = await state.get_data() - - prompt_message_id = state_data.get("stars_prompt_message_id") - prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось удалить сообщение с запросом суммы Stars: %s", - delete_error, - ) - - invoice_message = await message.answer( + + await message.answer( f"⭐ Оплата через Telegram Stars\n\n" f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" f"⭐ К оплате: {stars_amount} звезд\n" @@ -119,14 +96,9 @@ async def process_stars_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - - await state.update_data( - stars_invoice_message_id=invoice_message.message_id, - stars_invoice_chat_id=invoice_message.chat.id, - ) - - await state.set_state(None) - + + await state.clear() + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") + await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py index aad6acad..607f62bd 100644 --- a/app/handlers/balance/yookassa.py +++ b/app/handlers/balance/yookassa.py @@ -58,13 +58,9 @@ async def start_yookassa_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa") - await state.update_data( - yookassa_prompt_message_id=callback.message.message_id, - yookassa_prompt_chat_id=callback.message.chat.id, - ) await callback.answer() @@ -112,13 +108,9 @@ async def start_yookassa_sbp_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa_sbp") - await state.update_data( - yookassa_prompt_message_id=callback.message.message_id, - yookassa_prompt_chat_id=callback.message.chat.id, - ) await callback.answer() @@ -180,25 +172,7 @@ async def process_yookassa_payment_amount( [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - state_data = await state.get_data() - prompt_message_id = state_data.get("yookassa_prompt_message_id") - prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось удалить сообщение с запросом суммы YooKassa: %s", - delete_error, - ) - - invoice_message = await message.answer( + await message.answer( f"💳 Оплата банковской картой\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" @@ -213,13 +187,9 @@ async def process_yookassa_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - - await state.update_data( - yookassa_invoice_message_id=invoice_message.message_id, - yookassa_invoice_chat_id=invoice_message.chat.id, - ) - + await state.clear() + logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") @@ -340,45 +310,27 @@ async def process_yookassa_sbp_payment_amount( # Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса keyboard_buttons = [] - + # Добавляем кнопку оплаты, если доступна ссылка if confirmation_url: keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)]) else: # Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")]) - + # Добавляем общие кнопки keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")]) keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) - - state_data = await state.get_data() - prompt_message_id = state_data.get("yookassa_prompt_message_id") - prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s", - delete_error, - ) - + # Подготавливаем текст сообщения message_text = ( f"🔗 Оплата через СБП\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" ) - + # Добавляем инструкции в зависимости от доступных способов оплаты if not confirmation_url: message_text += ( @@ -389,18 +341,18 @@ async def process_yookassa_sbp_payment_amount( f"4. Подтвердите платеж в приложении банка\n" f"5. Деньги поступят на баланс автоматически\n\n" ) - + message_text += ( f"🔒 Оплата происходит через защищенную систему YooKassa\n" f"✅ Принимаем СБП от всех банков-участников\n\n" f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}" ) - + # Отправляем сообщение с инструкциями и клавиатурой # Если есть QR-код, отправляем его как медиа-сообщение if qr_photo: # Используем метод отправки медиа-группы или фото с описанием - invoice_message = await message.answer_photo( + await message.answer_photo( photo=qr_photo, caption=message_text, reply_markup=keyboard, @@ -408,18 +360,12 @@ async def process_yookassa_sbp_payment_amount( ) else: # Если QR-код недоступен, отправляем обычное текстовое сообщение - invoice_message = await message.answer( + await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML" ) - - await state.update_data( - yookassa_invoice_message_id=invoice_message.message_id, - yookassa_invoice_chat_id=invoice_message.chat.id, - ) - - await state.clear() + logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 48356450..4b49f51f 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,10 +1,10 @@ import logging from decimal import Decimal, ROUND_HALF_UP from aiogram import Dispatcher, types, F -from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.database.models import User from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id @@ -18,9 +18,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): texts = get_texts(DEFAULT_LANGUAGE) try: - logger.info( - f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}" - ) + logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}") allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_") @@ -37,7 +35,6 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): try: from app.database.database import get_db - async for db in get_db(): user = await get_user_by_telegram_id(db, query.from_user.id) if not user: @@ -80,7 +77,6 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): async def handle_successful_payment( message: types.Message, db: AsyncSession, - state: FSMContext, **kwargs ): texts = get_texts(DEFAULT_LANGUAGE) @@ -110,27 +106,6 @@ async def handle_successful_payment( return payment_service = PaymentService(message.bot) - - state_data = await state.get_data() - prompt_message_id = state_data.get("stars_prompt_message_id") - prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) - invoice_message_id = state_data.get("stars_invoice_message_id") - invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id) - - for chat_id, message_id, label in [ - (prompt_chat_id, prompt_message_id, "запрос суммы"), - (invoice_chat_id, invoice_message_id, "инвойс Stars"), - ]: - if message_id: - try: - await message.bot.delete_message(chat_id, message_id) - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning( - "Не удалось удалить сообщение %s после оплаты Stars: %s", - label, - delete_error, - ) - success = await payment_service.process_stars_payment( db=db, user_id=user.id, @@ -138,14 +113,7 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - - await state.update_data( - stars_prompt_message_id=None, - stars_prompt_chat_id=None, - stars_invoice_message_id=None, - stars_invoice_chat_id=None, - ) - + if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) @@ -204,15 +172,15 @@ async def handle_successful_payment( def register_stars_handlers(dp: Dispatcher): - + dp.pre_checkout_query.register( handle_pre_checkout_query, - F.currency == "XTR" + F.currency == "XTR" ) - + dp.message.register( handle_successful_payment, F.successful_payment ) - + logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index 3f1022d7..b7fd5942 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,6 +515,36 @@ class TelegramStarsMixin: exc_info=True, ) + if getattr(self, "bot", None): + try: + keyboard = await self.build_topup_success_keyboard(user) + + charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] + + await self.bot.send_message( + user.telegram_id, + ( + "✅ Пополнение успешно!\n\n" + f"⭐ Звезд: {stars_amount}\n" + f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" + "🦊 Способ: Telegram Stars\n" + f"🆔 Транзакция: {charge_id_short}...\n\n" + "Баланс пополнен автоматически!" + ), + parse_mode="HTML", + reply_markup=keyboard, + ) + logger.info( + "✅ Отправлено уведомление пользователю %s о пополнении на %s", + user.telegram_id, + settings.format_price(amount_kopeks), + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка отправки уведомления о пополнении Stars: %s", + error, + ) + # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types From 2a10e7d2c71f2154ddfd3578b281589f4a1dc80f Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 04:18:03 +0300 Subject: [PATCH 16/20] Clean up Platega and YooKassa invoices after payment --- app/handlers/balance/platega.py | 47 ++++++++++- app/handlers/balance/stars.py | 66 +++++++++++----- app/handlers/balance/yookassa.py | 129 +++++++++++++++++++++++++++---- app/handlers/stars_payments.py | 46 +++++++++-- app/services/payment/platega.py | 16 ++++ app/services/payment/stars.py | 30 ------- app/services/payment/yookassa.py | 16 ++++ 7 files changed, 278 insertions(+), 72 deletions(-) diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py index 40e8c45f..def18f65 100644 --- a/app/handlers/balance/platega.py +++ b/app/handlers/balance/platega.py @@ -98,6 +98,10 @@ async def _prompt_amount( ) await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data( + platega_prompt_message_id=message.message_id, + platega_prompt_chat_id=message.chat.id, + ) @error_handler @@ -300,7 +304,25 @@ async def process_platega_payment_amount( ), ) - await message.answer( + state_data = await state.get_data() + prompt_message_id = state_data.get("platega_prompt_message_id") + prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы Platega: %s", + delete_error, + ) + + invoice_message = await message.answer( instructions_template.format( method=method_title, amount=settings.format_price(amount_kopeks), @@ -311,6 +333,29 @@ async def process_platega_payment_amount( parse_mode="HTML", ) + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_platega_payment_by_id(db, local_payment_id) + if payment: + payment_metadata = dict(getattr(payment, "metadata_json", {}) or {}) + payment_metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await payment_module.update_platega_payment( + db, + payment=payment, + metadata=payment_metadata, + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.warning("Не удалось сохранить данные сообщения Platega: %s", error) + + await state.update_data( + platega_invoice_message_id=invoice_message.message_id, + platega_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index 0dcf0031..a8cac0f8 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,11 +1,10 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext -from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User -from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard +from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts from app.services.payment_service import PaymentService from app.states import BalanceStates @@ -22,11 +21,11 @@ async def start_stars_payment( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) return - + # Формируем текст сообщения в зависимости от настройки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: message_text = ( @@ -35,10 +34,10 @@ async def start_stars_payment( ) else: message_text = texts.TOP_UP_AMOUNT - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons @@ -46,12 +45,17 @@ async def start_stars_payment( if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard ) - + + await state.update_data( + stars_prompt_message_id=callback.message.message_id, + stars_prompt_chat_id=callback.message.chat.id, + ) + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="stars") await callback.answer() @@ -65,29 +69,48 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - - await message.answer( + + state_data = await state.get_data() + + prompt_message_id = state_data.get("stars_prompt_message_id") + prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы Stars: %s", + delete_error, + ) + + invoice_message = await message.answer( f"⭐ Оплата через Telegram Stars\n\n" f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" f"⭐ К оплате: {stars_amount} звезд\n" @@ -96,9 +119,14 @@ async def process_stars_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - - await state.clear() - + + await state.update_data( + stars_invoice_message_id=invoice_message.message_id, + stars_invoice_chat_id=invoice_message.chat.id, + ) + + await state.set_state(None) + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file + await message.answer("⚠️ Ошибка создания платежа") diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py index 607f62bd..fb6a345c 100644 --- a/app/handlers/balance/yookassa.py +++ b/app/handlers/balance/yookassa.py @@ -1,6 +1,9 @@ import logging +from datetime import datetime + from aiogram import types from aiogram.fsm.context import FSMContext +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -58,9 +61,13 @@ async def start_yookassa_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa") + await state.update_data( + yookassa_prompt_message_id=callback.message.message_id, + yookassa_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -108,9 +115,13 @@ async def start_yookassa_sbp_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa_sbp") + await state.update_data( + yookassa_prompt_message_id=callback.message.message_id, + yookassa_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -172,7 +183,25 @@ async def process_yookassa_payment_amount( [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - await message.answer( + state_data = await state.get_data() + prompt_message_id = state_data.get("yookassa_prompt_message_id") + prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы YooKassa: %s", + delete_error, + ) + + invoice_message = await message.answer( f"💳 Оплата банковской картой\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" @@ -187,9 +216,34 @@ async def process_yookassa_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - + + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_yookassa_payment_by_local_id( + db, payment_result["local_payment_id"] + ) + if payment: + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await db.execute( + update(payment.__class__) + .where(payment.__class__.id == payment.id) + .values(metadata_json=metadata, updated_at=datetime.utcnow()) + ) + await db.commit() + except Exception as error: # pragma: no cover - диагностический лог + logger.warning("Не удалось сохранить сообщение YooKassa: %s", error) + + await state.update_data( + yookassa_invoice_message_id=invoice_message.message_id, + yookassa_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() - logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") @@ -310,27 +364,45 @@ async def process_yookassa_sbp_payment_amount( # Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса keyboard_buttons = [] - + # Добавляем кнопку оплаты, если доступна ссылка if confirmation_url: keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)]) else: # Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")]) - + # Добавляем общие кнопки keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")]) keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) - + + state_data = await state.get_data() + prompt_message_id = state_data.get("yookassa_prompt_message_id") + prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s", + delete_error, + ) + # Подготавливаем текст сообщения message_text = ( f"🔗 Оплата через СБП\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" ) - + # Добавляем инструкции в зависимости от доступных способов оплаты if not confirmation_url: message_text += ( @@ -341,18 +413,18 @@ async def process_yookassa_sbp_payment_amount( f"4. Подтвердите платеж в приложении банка\n" f"5. Деньги поступят на баланс автоматически\n\n" ) - + message_text += ( f"🔒 Оплата происходит через защищенную систему YooKassa\n" f"✅ Принимаем СБП от всех банков-участников\n\n" f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}" ) - + # Отправляем сообщение с инструкциями и клавиатурой # Если есть QR-код, отправляем его как медиа-сообщение if qr_photo: # Используем метод отправки медиа-группы или фото с описанием - await message.answer_photo( + invoice_message = await message.answer_photo( photo=qr_photo, caption=message_text, reply_markup=keyboard, @@ -360,12 +432,39 @@ async def process_yookassa_sbp_payment_amount( ) else: # Если QR-код недоступен, отправляем обычное текстовое сообщение - await message.answer( + invoice_message = await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML" ) - + + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_yookassa_payment_by_local_id( + db, payment_result["local_payment_id"] + ) + if payment: + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await db.execute( + update(payment.__class__) + .where(payment.__class__.id == payment.id) + .values(metadata_json=metadata, updated_at=datetime.utcnow()) + ) + await db.commit() + except Exception as error: # pragma: no cover - диагностический лог + logger.warning("Не удалось сохранить сообщение YooKassa (СБП): %s", error) + + await state.update_data( + yookassa_invoice_message_id=invoice_message.message_id, + yookassa_invoice_chat_id=invoice_message.chat.id, + ) + + await state.clear() logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 4b49f51f..48356450 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,10 +1,10 @@ import logging from decimal import Decimal, ROUND_HALF_UP from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import User from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id @@ -18,7 +18,9 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): texts = get_texts(DEFAULT_LANGUAGE) try: - logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}") + logger.info( + f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}" + ) allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_") @@ -35,6 +37,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): try: from app.database.database import get_db + async for db in get_db(): user = await get_user_by_telegram_id(db, query.from_user.id) if not user: @@ -77,6 +80,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): async def handle_successful_payment( message: types.Message, db: AsyncSession, + state: FSMContext, **kwargs ): texts = get_texts(DEFAULT_LANGUAGE) @@ -106,6 +110,27 @@ async def handle_successful_payment( return payment_service = PaymentService(message.bot) + + state_data = await state.get_data() + prompt_message_id = state_data.get("stars_prompt_message_id") + prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) + invoice_message_id = state_data.get("stars_invoice_message_id") + invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id) + + for chat_id, message_id, label in [ + (prompt_chat_id, prompt_message_id, "запрос суммы"), + (invoice_chat_id, invoice_message_id, "инвойс Stars"), + ]: + if message_id: + try: + await message.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning( + "Не удалось удалить сообщение %s после оплаты Stars: %s", + label, + delete_error, + ) + success = await payment_service.process_stars_payment( db=db, user_id=user.id, @@ -113,7 +138,14 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - + + await state.update_data( + stars_prompt_message_id=None, + stars_prompt_chat_id=None, + stars_invoice_message_id=None, + stars_invoice_chat_id=None, + ) + if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) @@ -172,15 +204,15 @@ async def handle_successful_payment( def register_stars_handlers(dp: Dispatcher): - + dp.pre_checkout_query.register( handle_pre_checkout_query, - F.currency == "XTR" + F.currency == "XTR" ) - + dp.message.register( handle_successful_payment, F.successful_payment ) - + logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index fc7fe4d8..2d08997a 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -306,6 +306,22 @@ class PlategaPaymentMixin: metadata = dict(getattr(payment, "metadata_json", {}) or {}) balance_already_credited = bool(metadata.get("balance_credited")) + invoice_message = metadata.get("invoice_message") or {} + if getattr(self, "bot", None): + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning( + "Не удалось удалить Platega счёт %s: %s", + message_id, + delete_error, + ) + else: + metadata.pop("invoice_message", None) + if payment.transaction_id: logger.info( "Platega платеж %s уже связан с транзакцией %s", diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index b7fd5942..3f1022d7 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,36 +515,6 @@ class TelegramStarsMixin: exc_info=True, ) - if getattr(self, "bot", None): - try: - keyboard = await self.build_topup_success_keyboard(user) - - charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] - - await self.bot.send_message( - user.telegram_id, - ( - "✅ Пополнение успешно!\n\n" - f"⭐ Звезд: {stars_amount}\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - "🦊 Способ: Telegram Stars\n" - f"🆔 Транзакция: {charge_id_short}...\n\n" - "Баланс пополнен автоматически!" - ), - parse_mode="HTML", - reply_markup=keyboard, - ) - logger.info( - "✅ Отправлено уведомление пользователю %s о пополнении на %s", - user.telegram_id, - settings.format_price(amount_kopeks), - ) - except Exception as error: # pragma: no cover - диагностический лог - logger.error( - "Ошибка отправки уведомления о пополнении Stars: %s", - error, - ) - # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 181ab94d..e9ccf082 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -437,6 +437,22 @@ class YooKassaPaymentMixin: except Exception as parse_error: logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}") + invoice_message = payment_metadata.get("invoice_message") or {} + if getattr(self, "bot", None): + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning( + "Не удалось удалить сообщение YooKassa %s: %s", + message_id, + delete_error, + ) + else: + payment_metadata.pop("invoice_message", None) + processing_completed = bool(payment_metadata.get("processing_completed")) transaction = None From 0137321d245659b6a7c4087d9f5fa8d1768b380c Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 04:49:17 +0300 Subject: [PATCH 17/20] Revert "Clean up Platega and YooKassa invoices after payment" --- app/handlers/balance/platega.py | 47 +---------- app/handlers/balance/stars.py | 66 +++++----------- app/handlers/balance/yookassa.py | 129 ++++--------------------------- app/handlers/stars_payments.py | 46 ++--------- app/services/payment/platega.py | 16 ---- app/services/payment/stars.py | 30 +++++++ app/services/payment/yookassa.py | 16 ---- 7 files changed, 72 insertions(+), 278 deletions(-) diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py index def18f65..40e8c45f 100644 --- a/app/handlers/balance/platega.py +++ b/app/handlers/balance/platega.py @@ -98,10 +98,6 @@ async def _prompt_amount( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data( - platega_prompt_message_id=message.message_id, - platega_prompt_chat_id=message.chat.id, - ) @error_handler @@ -304,25 +300,7 @@ async def process_platega_payment_amount( ), ) - state_data = await state.get_data() - prompt_message_id = state_data.get("platega_prompt_message_id") - prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось удалить сообщение с запросом суммы Platega: %s", - delete_error, - ) - - invoice_message = await message.answer( + await message.answer( instructions_template.format( method=method_title, amount=settings.format_price(amount_kopeks), @@ -333,29 +311,6 @@ async def process_platega_payment_amount( parse_mode="HTML", ) - try: - from app.services import payment_service as payment_module - - payment = await payment_module.get_platega_payment_by_id(db, local_payment_id) - if payment: - payment_metadata = dict(getattr(payment, "metadata_json", {}) or {}) - payment_metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, - } - await payment_module.update_platega_payment( - db, - payment=payment, - metadata=payment_metadata, - ) - except Exception as error: # pragma: no cover - диагностический лог - logger.warning("Не удалось сохранить данные сообщения Platega: %s", error) - - await state.update_data( - platega_invoice_message_id=invoice_message.message_id, - platega_invoice_chat_id=invoice_message.chat.id, - ) - await state.clear() diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index a8cac0f8..0dcf0031 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,10 +1,11 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User -from app.keyboards.inline import get_back_keyboard +from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard from app.localization.texts import get_texts from app.services.payment_service import PaymentService from app.states import BalanceStates @@ -21,11 +22,11 @@ async def start_stars_payment( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) return - + # Формируем текст сообщения в зависимости от настройки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: message_text = ( @@ -34,10 +35,10 @@ async def start_stars_payment( ) else: message_text = texts.TOP_UP_AMOUNT - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons @@ -45,17 +46,12 @@ async def start_stars_payment( if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard ) - - await state.update_data( - stars_prompt_message_id=callback.message.message_id, - stars_prompt_chat_id=callback.message.chat.id, - ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="stars") await callback.answer() @@ -69,48 +65,29 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - - state_data = await state.get_data() - - prompt_message_id = state_data.get("stars_prompt_message_id") - prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось удалить сообщение с запросом суммы Stars: %s", - delete_error, - ) - - invoice_message = await message.answer( + + await message.answer( f"⭐ Оплата через Telegram Stars\n\n" f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" f"⭐ К оплате: {stars_amount} звезд\n" @@ -119,14 +96,9 @@ async def process_stars_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - - await state.update_data( - stars_invoice_message_id=invoice_message.message_id, - stars_invoice_chat_id=invoice_message.chat.id, - ) - - await state.set_state(None) - + + await state.clear() + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") + await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py index fb6a345c..607f62bd 100644 --- a/app/handlers/balance/yookassa.py +++ b/app/handlers/balance/yookassa.py @@ -1,9 +1,6 @@ import logging -from datetime import datetime - from aiogram import types from aiogram.fsm.context import FSMContext -from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -61,13 +58,9 @@ async def start_yookassa_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa") - await state.update_data( - yookassa_prompt_message_id=callback.message.message_id, - yookassa_prompt_chat_id=callback.message.chat.id, - ) await callback.answer() @@ -115,13 +108,9 @@ async def start_yookassa_sbp_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa_sbp") - await state.update_data( - yookassa_prompt_message_id=callback.message.message_id, - yookassa_prompt_chat_id=callback.message.chat.id, - ) await callback.answer() @@ -183,25 +172,7 @@ async def process_yookassa_payment_amount( [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - state_data = await state.get_data() - prompt_message_id = state_data.get("yookassa_prompt_message_id") - prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось удалить сообщение с запросом суммы YooKassa: %s", - delete_error, - ) - - invoice_message = await message.answer( + await message.answer( f"💳 Оплата банковской картой\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" @@ -216,34 +187,9 @@ async def process_yookassa_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - - try: - from app.services import payment_service as payment_module - - payment = await payment_module.get_yookassa_payment_by_local_id( - db, payment_result["local_payment_id"] - ) - if payment: - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, - } - await db.execute( - update(payment.__class__) - .where(payment.__class__.id == payment.id) - .values(metadata_json=metadata, updated_at=datetime.utcnow()) - ) - await db.commit() - except Exception as error: # pragma: no cover - диагностический лог - logger.warning("Не удалось сохранить сообщение YooKassa: %s", error) - - await state.update_data( - yookassa_invoice_message_id=invoice_message.message_id, - yookassa_invoice_chat_id=invoice_message.chat.id, - ) - + await state.clear() + logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") @@ -364,45 +310,27 @@ async def process_yookassa_sbp_payment_amount( # Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса keyboard_buttons = [] - + # Добавляем кнопку оплаты, если доступна ссылка if confirmation_url: keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)]) else: # Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")]) - + # Добавляем общие кнопки keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")]) keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) - - state_data = await state.get_data() - prompt_message_id = state_data.get("yookassa_prompt_message_id") - prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s", - delete_error, - ) - + # Подготавливаем текст сообщения message_text = ( f"🔗 Оплата через СБП\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" ) - + # Добавляем инструкции в зависимости от доступных способов оплаты if not confirmation_url: message_text += ( @@ -413,18 +341,18 @@ async def process_yookassa_sbp_payment_amount( f"4. Подтвердите платеж в приложении банка\n" f"5. Деньги поступят на баланс автоматически\n\n" ) - + message_text += ( f"🔒 Оплата происходит через защищенную систему YooKassa\n" f"✅ Принимаем СБП от всех банков-участников\n\n" f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}" ) - + # Отправляем сообщение с инструкциями и клавиатурой # Если есть QR-код, отправляем его как медиа-сообщение if qr_photo: # Используем метод отправки медиа-группы или фото с описанием - invoice_message = await message.answer_photo( + await message.answer_photo( photo=qr_photo, caption=message_text, reply_markup=keyboard, @@ -432,39 +360,12 @@ async def process_yookassa_sbp_payment_amount( ) else: # Если QR-код недоступен, отправляем обычное текстовое сообщение - invoice_message = await message.answer( + await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML" ) - - try: - from app.services import payment_service as payment_module - - payment = await payment_module.get_yookassa_payment_by_local_id( - db, payment_result["local_payment_id"] - ) - if payment: - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, - } - await db.execute( - update(payment.__class__) - .where(payment.__class__.id == payment.id) - .values(metadata_json=metadata, updated_at=datetime.utcnow()) - ) - await db.commit() - except Exception as error: # pragma: no cover - диагностический лог - logger.warning("Не удалось сохранить сообщение YooKassa (СБП): %s", error) - - await state.update_data( - yookassa_invoice_message_id=invoice_message.message_id, - yookassa_invoice_chat_id=invoice_message.chat.id, - ) - - await state.clear() + logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 48356450..4b49f51f 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,10 +1,10 @@ import logging from decimal import Decimal, ROUND_HALF_UP from aiogram import Dispatcher, types, F -from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.database.models import User from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id @@ -18,9 +18,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): texts = get_texts(DEFAULT_LANGUAGE) try: - logger.info( - f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}" - ) + logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}") allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_") @@ -37,7 +35,6 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): try: from app.database.database import get_db - async for db in get_db(): user = await get_user_by_telegram_id(db, query.from_user.id) if not user: @@ -80,7 +77,6 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): async def handle_successful_payment( message: types.Message, db: AsyncSession, - state: FSMContext, **kwargs ): texts = get_texts(DEFAULT_LANGUAGE) @@ -110,27 +106,6 @@ async def handle_successful_payment( return payment_service = PaymentService(message.bot) - - state_data = await state.get_data() - prompt_message_id = state_data.get("stars_prompt_message_id") - prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) - invoice_message_id = state_data.get("stars_invoice_message_id") - invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id) - - for chat_id, message_id, label in [ - (prompt_chat_id, prompt_message_id, "запрос суммы"), - (invoice_chat_id, invoice_message_id, "инвойс Stars"), - ]: - if message_id: - try: - await message.bot.delete_message(chat_id, message_id) - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning( - "Не удалось удалить сообщение %s после оплаты Stars: %s", - label, - delete_error, - ) - success = await payment_service.process_stars_payment( db=db, user_id=user.id, @@ -138,14 +113,7 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - - await state.update_data( - stars_prompt_message_id=None, - stars_prompt_chat_id=None, - stars_invoice_message_id=None, - stars_invoice_chat_id=None, - ) - + if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) @@ -204,15 +172,15 @@ async def handle_successful_payment( def register_stars_handlers(dp: Dispatcher): - + dp.pre_checkout_query.register( handle_pre_checkout_query, - F.currency == "XTR" + F.currency == "XTR" ) - + dp.message.register( handle_successful_payment, F.successful_payment ) - + logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index 2d08997a..fc7fe4d8 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -306,22 +306,6 @@ class PlategaPaymentMixin: metadata = dict(getattr(payment, "metadata_json", {}) or {}) balance_already_credited = bool(metadata.get("balance_credited")) - invoice_message = metadata.get("invoice_message") or {} - if getattr(self, "bot", None): - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") - if chat_id and message_id: - try: - await self.bot.delete_message(chat_id, message_id) - except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning( - "Не удалось удалить Platega счёт %s: %s", - message_id, - delete_error, - ) - else: - metadata.pop("invoice_message", None) - if payment.transaction_id: logger.info( "Platega платеж %s уже связан с транзакцией %s", diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index 3f1022d7..b7fd5942 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,6 +515,36 @@ class TelegramStarsMixin: exc_info=True, ) + if getattr(self, "bot", None): + try: + keyboard = await self.build_topup_success_keyboard(user) + + charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] + + await self.bot.send_message( + user.telegram_id, + ( + "✅ Пополнение успешно!\n\n" + f"⭐ Звезд: {stars_amount}\n" + f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" + "🦊 Способ: Telegram Stars\n" + f"🆔 Транзакция: {charge_id_short}...\n\n" + "Баланс пополнен автоматически!" + ), + parse_mode="HTML", + reply_markup=keyboard, + ) + logger.info( + "✅ Отправлено уведомление пользователю %s о пополнении на %s", + user.telegram_id, + settings.format_price(amount_kopeks), + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка отправки уведомления о пополнении Stars: %s", + error, + ) + # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index e9ccf082..181ab94d 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -437,22 +437,6 @@ class YooKassaPaymentMixin: except Exception as parse_error: logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}") - invoice_message = payment_metadata.get("invoice_message") or {} - if getattr(self, "bot", None): - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") - if chat_id and message_id: - try: - await self.bot.delete_message(chat_id, message_id) - except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning( - "Не удалось удалить сообщение YooKassa %s: %s", - message_id, - delete_error, - ) - else: - payment_metadata.pop("invoice_message", None) - processing_completed = bool(payment_metadata.get("processing_completed")) transaction = None From 6a29765ddfd6f1854b5b694ade5d219ead1e4c0f Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 05:17:09 +0300 Subject: [PATCH 18/20] Handle YooKassa webhooks without payment ids --- app/database/crud/mulenpay.py | 17 ++++ app/database/crud/pal24.py | 3 + app/external/yookassa_webhook.py | 17 ++-- app/handlers/balance/heleket.py | 55 ++++++++++++- app/handlers/balance/mulenpay.py | 55 ++++++++++++- app/handlers/balance/pal24.py | 52 ++++++++++++- app/handlers/balance/platega.py | 47 ++++++++++- app/handlers/balance/stars.py | 66 +++++++++++----- app/handlers/balance/tribute.py | 52 +++++++------ app/handlers/balance/wata.py | 52 ++++++++++++- app/handlers/balance/yookassa.py | 129 +++++++++++++++++++++++++++---- app/handlers/stars_payments.py | 46 +++++++++-- app/localization/locales/en.json | 7 -- app/localization/locales/ru.json | 7 -- app/localization/locales/ua.json | 33 ++++---- app/localization/locales/zh.json | 7 -- app/services/payment/heleket.py | 36 +++++++++ app/services/payment/mulenpay.py | 37 +++++++++ app/services/payment/pal24.py | 35 +++++++++ app/services/payment/platega.py | 16 ++++ app/services/payment/stars.py | 30 ------- app/services/payment/wata.py | 19 +++++ app/services/payment/yookassa.py | 49 ++++++++++-- app/services/payment_service.py | 5 ++ app/services/tribute_service.py | 23 +++++- 25 files changed, 735 insertions(+), 160 deletions(-) diff --git a/app/database/crud/mulenpay.py b/app/database/crud/mulenpay.py index 17648f4a..062dcaab 100644 --- a/app/database/crud/mulenpay.py +++ b/app/database/crud/mulenpay.py @@ -3,6 +3,7 @@ from datetime import datetime from typing import Optional from sqlalchemy import select +from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -90,6 +91,7 @@ async def update_mulenpay_payment_status( paid_at: Optional[datetime] = None, callback_payload: Optional[dict] = None, mulen_payment_id: Optional[int] = None, + metadata: Optional[dict] = None, ) -> MulenPayPayment: payment.status = status if is_paid is not None: @@ -100,6 +102,8 @@ async def update_mulenpay_payment_status( payment.callback_payload = callback_payload if mulen_payment_id is not None and not payment.mulen_payment_id: payment.mulen_payment_id = mulen_payment_id + if metadata is not None: + payment.metadata_json = metadata payment.updated_at = datetime.utcnow() await db.commit() @@ -107,6 +111,19 @@ async def update_mulenpay_payment_status( return payment +async def update_mulenpay_payment_metadata( + db: AsyncSession, + *, + payment: MulenPayPayment, + metadata: dict, +) -> MulenPayPayment: + payment.metadata_json = metadata + payment.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(payment) + return payment + + async def link_mulenpay_payment_to_transaction( db: AsyncSession, *, diff --git a/app/database/crud/pal24.py b/app/database/crud/pal24.py index a88c46b4..35cffa61 100644 --- a/app/database/crud/pal24.py +++ b/app/database/crud/pal24.py @@ -96,6 +96,7 @@ async def update_pal24_payment_status( balance_currency: Optional[str] = None, payer_account: Optional[str] = None, callback_payload: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> Pal24Payment: update_values: Dict[str, Any] = { "status": status, @@ -121,6 +122,8 @@ async def update_pal24_payment_status( update_values["payer_account"] = payer_account if callback_payload is not None: update_values["callback_payload"] = callback_payload + if metadata is not None: + update_values["metadata_json"] = metadata update_values["last_status"] = status diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index 0c6485b5..21091332 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -254,17 +254,16 @@ class YooKassaWebhookHandler: logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}") logger.debug(f"🔍 Полные данные webhook: {webhook_data}") - # Извлекаем ID платежа из вебхука для предотвращения дублирования - yookassa_payment_id = webhook_data.get("object", {}).get("id") - if not yookassa_payment_id: - logger.warning("⚠️ Webhook YooKassa без ID платежа") - return web.Response(status=400, text="No payment ID") - event_type = webhook_data.get("event") if not event_type: logger.warning("⚠️ Webhook YooKassa без типа события") return web.Response(status=400, text="No event type") + # Извлекаем ID платежа из вебхука для предотвращения дублирования + yookassa_payment_id = webhook_data.get("object", {}).get("id") + if not yookassa_payment_id: + logger.warning("⚠️ Webhook YooKassa без ID платежа") + if event_type not in YOOKASSA_ALLOWED_EVENTS: logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}") return web.Response(status=200, text="OK") @@ -274,8 +273,10 @@ class YooKassaWebhookHandler: # Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования) from app.database.models import PaymentMethod from app.database.crud.transaction import get_transaction_by_external_id - existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA) - + existing_transaction = None + if yookassa_payment_id and hasattr(db, "execute"): + existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA) + if existing_transaction and event_type == "payment.succeeded": logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.") return web.Response(status=200, text="OK") diff --git a/app/handlers/balance/heleket.py b/app/handlers/balance/heleket.py index 37ac4b53..f97aa1b2 100644 --- a/app/handlers/balance/heleket.py +++ b/app/handlers/balance/heleket.py @@ -1,8 +1,10 @@ import logging +from datetime import datetime from typing import Optional from aiogram import types from aiogram.fsm.context import FSMContext +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -66,7 +68,11 @@ async def start_heleket_payment( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="heleket") + await state.update_data( + payment_method="heleket", + heleket_prompt_message_id=callback.message.message_id, + heleket_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -181,7 +187,52 @@ async def process_heleket_payment_amount( [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], ]) - await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard) + state_data = await state.get_data() + prompt_message_id = state_data.get("heleket_prompt_message_id") + prompt_chat_id = state_data.get("heleket_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning("Не удалось удалить сообщение с суммой Heleket: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - diagnostic + logger.warning( + "Не удалось удалить сообщение с запросом суммы Heleket: %s", + delete_error, + ) + + invoice_message = await message.answer( + "\n".join(details), parse_mode="HTML", reply_markup=keyboard + ) + + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_heleket_payment_by_id(db, result["local_payment_id"]) + if payment: + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await db.execute( + update(payment.__class__) + .where(payment.__class__.id == payment.id) + .values(metadata_json=metadata, updated_at=datetime.utcnow()) + ) + await db.commit() + except Exception as error: # pragma: no cover - diagnostics + logger.warning("Не удалось сохранить сообщение Heleket: %s", error) + + await state.update_data( + heleket_invoice_message_id=invoice_message.message_id, + heleket_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() diff --git a/app/handlers/balance/mulenpay.py b/app/handlers/balance/mulenpay.py index 840ac469..f345abeb 100644 --- a/app/handlers/balance/mulenpay.py +++ b/app/handlers/balance/mulenpay.py @@ -59,7 +59,11 @@ async def start_mulenpay_payment( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="mulenpay") + await state.update_data( + payment_method="mulenpay", + mulenpay_prompt_message_id=callback.message.message_id, + mulenpay_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -93,6 +97,26 @@ async def process_mulenpay_payment_amount( amount_rubles = amount_kopeks / 100 + state_data = await state.get_data() + prompt_message_id = state_data.get("mulenpay_prompt_message_id") + prompt_chat_id = state_data.get("mulenpay_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - depends on bot permissions + logger.warning( + "Не удалось удалить сообщение с суммой MulenPay: %s", delete_error + ) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - diagnostic + logger.warning( + "Не удалось удалить сообщение с запросом суммы MulenPay: %s", + delete_error, + ) + try: payment_service = PaymentService(message.bot) payment_result = await payment_service.create_mulenpay_payment( @@ -163,12 +187,39 @@ async def process_mulenpay_payment_amount( mulenpay_name_html=mulenpay_name_html, ) - await message.answer( + invoice_message = await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML", ) + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_mulenpay_payment_by_local_id( + db, local_payment_id + ) + if payment: + payment_metadata = dict( + getattr(payment, "metadata_json", {}) or {} + ) + payment_metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await payment_module.update_mulenpay_payment_metadata( + db, + payment=payment, + metadata=payment_metadata, + ) + except Exception as error: # pragma: no cover - diagnostic logging only + logger.warning("Не удалось сохранить данные сообщения MulenPay: %s", error) + + await state.update_data( + mulenpay_invoice_message_id=invoice_message.message_id, + mulenpay_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() logger.info( diff --git a/app/handlers/balance/pal24.py b/app/handlers/balance/pal24.py index 0a66b20f..28eda7f9 100644 --- a/app/handlers/balance/pal24.py +++ b/app/handlers/balance/pal24.py @@ -1,10 +1,12 @@ import html import logging +from datetime import datetime from typing import Any, Optional from aiogram import types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -204,12 +206,36 @@ async def _send_pal24_payment_message( support=settings.get_support_contact_display_html(), ) - await message.answer( + invoice_message = await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML", ) + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id) + if payment: + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await db.execute( + update(payment.__class__) + .where(payment.__class__.id == payment.id) + .values(metadata_json=metadata, updated_at=datetime.utcnow()) + ) + await db.commit() + except Exception as error: # pragma: no cover - diagnostics + logger.warning("Не удалось сохранить сообщение PayPalych: %s", error) + + await state.update_data( + pal24_invoice_message_id=invoice_message.message_id, + pal24_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() logger.info( @@ -277,7 +303,11 @@ async def start_pal24_payment( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="pal24") + await state.update_data( + payment_method="pal24", + pal24_prompt_message_id=callback.message.message_id, + pal24_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -309,6 +339,24 @@ async def process_pal24_payment_amount( available_methods = _get_available_pal24_methods() + state_data = await state.get_data() + prompt_message_id = state_data.get("pal24_prompt_message_id") + prompt_chat_id = state_data.get("pal24_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning("Не удалось удалить сообщение с суммой PayPalych: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - diagnostic + logger.warning( + "Не удалось удалить сообщение с запросом суммы PayPalych: %s", + delete_error, + ) + if len(available_methods) == 1: await _send_pal24_payment_message( message, diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py index 40e8c45f..def18f65 100644 --- a/app/handlers/balance/platega.py +++ b/app/handlers/balance/platega.py @@ -98,6 +98,10 @@ async def _prompt_amount( ) await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data( + platega_prompt_message_id=message.message_id, + platega_prompt_chat_id=message.chat.id, + ) @error_handler @@ -300,7 +304,25 @@ async def process_platega_payment_amount( ), ) - await message.answer( + state_data = await state.get_data() + prompt_message_id = state_data.get("platega_prompt_message_id") + prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы Platega: %s", + delete_error, + ) + + invoice_message = await message.answer( instructions_template.format( method=method_title, amount=settings.format_price(amount_kopeks), @@ -311,6 +333,29 @@ async def process_platega_payment_amount( parse_mode="HTML", ) + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_platega_payment_by_id(db, local_payment_id) + if payment: + payment_metadata = dict(getattr(payment, "metadata_json", {}) or {}) + payment_metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await payment_module.update_platega_payment( + db, + payment=payment, + metadata=payment_metadata, + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.warning("Не удалось сохранить данные сообщения Platega: %s", error) + + await state.update_data( + platega_invoice_message_id=invoice_message.message_id, + platega_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index 0dcf0031..a8cac0f8 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,11 +1,10 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext -from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User -from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard +from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts from app.services.payment_service import PaymentService from app.states import BalanceStates @@ -22,11 +21,11 @@ async def start_stars_payment( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) return - + # Формируем текст сообщения в зависимости от настройки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: message_text = ( @@ -35,10 +34,10 @@ async def start_stars_payment( ) else: message_text = texts.TOP_UP_AMOUNT - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons @@ -46,12 +45,17 @@ async def start_stars_payment( if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard ) - + + await state.update_data( + stars_prompt_message_id=callback.message.message_id, + stars_prompt_chat_id=callback.message.chat.id, + ) + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="stars") await callback.answer() @@ -65,29 +69,48 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - - await message.answer( + + state_data = await state.get_data() + + prompt_message_id = state_data.get("stars_prompt_message_id") + prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы Stars: %s", + delete_error, + ) + + invoice_message = await message.answer( f"⭐ Оплата через Telegram Stars\n\n" f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" f"⭐ К оплате: {stars_amount} звезд\n" @@ -96,9 +119,14 @@ async def process_stars_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - - await state.clear() - + + await state.update_data( + stars_invoice_message_id=invoice_message.message_id, + stars_invoice_chat_id=invoice_message.chat.id, + ) + + await state.set_state(None) + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file + await message.answer("⚠️ Ошибка создания платежа") diff --git a/app/handlers/balance/tribute.py b/app/handlers/balance/tribute.py index e6d24015..f96538a5 100644 --- a/app/handlers/balance/tribute.py +++ b/app/handlers/balance/tribute.py @@ -13,47 +13,55 @@ logger = logging.getLogger(__name__) @error_handler async def start_tribute_payment( callback: types.CallbackQuery, - db_user: User + db_user: User, ): texts = get_texts(db_user.language) - + if not settings.TRIBUTE_ENABLED: await callback.answer("❌ Оплата картой временно недоступна", show_alert=True) return - + try: from app.services.tribute_service import TributeService - + tribute_service = TributeService(callback.bot) payment_url = await tribute_service.create_payment_link( user_id=db_user.telegram_id, amount_kopeks=0, - description="Пополнение баланса VPN" + description="Пополнение баланса VPN", ) - + if not payment_url: await callback.answer("❌ Ошибка создания платежа", show_alert=True) return - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + ] + ) + await callback.message.edit_text( - f"💳 Пополнение банковской картой\n\n" - f"• Введите любую сумму от 100₽\n" - f"• Безопасная оплата через Tribute\n" - f"• Мгновенное зачисление на баланс\n" - f"• Принимаем карты Visa, MasterCard, МИР\n\n" - f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n" + f"💳 Пополнение банковской картой\n\n", + f"• Введите любую сумму от 100₽\n", + f"• Безопасная оплата через Tribute\n", + f"• Мгновенное зачисление на баланс\n", + f"• Принимаем карты Visa, MasterCard, МИР\n\n", + f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n", f"Нажмите кнопку для перехода к оплате:", reply_markup=keyboard, - parse_mode="HTML" + parse_mode="HTML", ) - + + TributeService.remember_invoice_message( + db_user.telegram_id, + callback.message.chat.id, + callback.message.message_id, + ) + except Exception as e: logger.error(f"Ошибка создания Tribute платежа: {e}") await callback.answer("❌ Ошибка создания платежа", show_alert=True) - - await callback.answer() \ No newline at end of file + + await callback.answer() diff --git a/app/handlers/balance/wata.py b/app/handlers/balance/wata.py index 259d559e..fdd2327e 100644 --- a/app/handlers/balance/wata.py +++ b/app/handlers/balance/wata.py @@ -1,8 +1,10 @@ import logging +from datetime import datetime from typing import Dict from aiogram import types from aiogram.fsm.context import FSMContext +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -56,7 +58,11 @@ async def start_wata_payment( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="wata") + await state.update_data( + payment_method="wata", + wata_prompt_message_id=callback.message.message_id, + wata_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -159,12 +165,54 @@ async def process_wata_payment_amount( support=settings.get_support_contact_display_html(), ) - await message.answer( + state_data = await state.get_data() + prompt_message_id = state_data.get("wata_prompt_message_id") + prompt_chat_id = state_data.get("wata_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning("Не удалось удалить сообщение с суммой WATA: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - diagnostic + logger.warning( + "Не удалось удалить сообщение с запросом суммы WATA: %s", + delete_error, + ) + + invoice_message = await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML", ) + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_wata_payment_by_local_id(db, local_payment_id) + if payment: + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await db.execute( + update(payment.__class__) + .where(payment.__class__.id == payment.id) + .values(metadata_json=metadata, updated_at=datetime.utcnow()) + ) + await db.commit() + except Exception as error: # pragma: no cover - diagnostics + logger.warning("Не удалось сохранить сообщение WATA: %s", error) + + await state.update_data( + wata_invoice_message_id=invoice_message.message_id, + wata_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() logger.info( diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py index 607f62bd..fb6a345c 100644 --- a/app/handlers/balance/yookassa.py +++ b/app/handlers/balance/yookassa.py @@ -1,6 +1,9 @@ import logging +from datetime import datetime + from aiogram import types from aiogram.fsm.context import FSMContext +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -58,9 +61,13 @@ async def start_yookassa_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa") + await state.update_data( + yookassa_prompt_message_id=callback.message.message_id, + yookassa_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -108,9 +115,13 @@ async def start_yookassa_sbp_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa_sbp") + await state.update_data( + yookassa_prompt_message_id=callback.message.message_id, + yookassa_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -172,7 +183,25 @@ async def process_yookassa_payment_amount( [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - await message.answer( + state_data = await state.get_data() + prompt_message_id = state_data.get("yookassa_prompt_message_id") + prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы YooKassa: %s", + delete_error, + ) + + invoice_message = await message.answer( f"💳 Оплата банковской картой\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" @@ -187,9 +216,34 @@ async def process_yookassa_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - + + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_yookassa_payment_by_local_id( + db, payment_result["local_payment_id"] + ) + if payment: + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await db.execute( + update(payment.__class__) + .where(payment.__class__.id == payment.id) + .values(metadata_json=metadata, updated_at=datetime.utcnow()) + ) + await db.commit() + except Exception as error: # pragma: no cover - диагностический лог + logger.warning("Не удалось сохранить сообщение YooKassa: %s", error) + + await state.update_data( + yookassa_invoice_message_id=invoice_message.message_id, + yookassa_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() - logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") @@ -310,27 +364,45 @@ async def process_yookassa_sbp_payment_amount( # Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса keyboard_buttons = [] - + # Добавляем кнопку оплаты, если доступна ссылка if confirmation_url: keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)]) else: # Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")]) - + # Добавляем общие кнопки keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")]) keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) - + + state_data = await state.get_data() + prompt_message_id = state_data.get("yookassa_prompt_message_id") + prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s", + delete_error, + ) + # Подготавливаем текст сообщения message_text = ( f"🔗 Оплата через СБП\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" ) - + # Добавляем инструкции в зависимости от доступных способов оплаты if not confirmation_url: message_text += ( @@ -341,18 +413,18 @@ async def process_yookassa_sbp_payment_amount( f"4. Подтвердите платеж в приложении банка\n" f"5. Деньги поступят на баланс автоматически\n\n" ) - + message_text += ( f"🔒 Оплата происходит через защищенную систему YooKassa\n" f"✅ Принимаем СБП от всех банков-участников\n\n" f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}" ) - + # Отправляем сообщение с инструкциями и клавиатурой # Если есть QR-код, отправляем его как медиа-сообщение if qr_photo: # Используем метод отправки медиа-группы или фото с описанием - await message.answer_photo( + invoice_message = await message.answer_photo( photo=qr_photo, caption=message_text, reply_markup=keyboard, @@ -360,12 +432,39 @@ async def process_yookassa_sbp_payment_amount( ) else: # Если QR-код недоступен, отправляем обычное текстовое сообщение - await message.answer( + invoice_message = await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML" ) - + + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_yookassa_payment_by_local_id( + db, payment_result["local_payment_id"] + ) + if payment: + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await db.execute( + update(payment.__class__) + .where(payment.__class__.id == payment.id) + .values(metadata_json=metadata, updated_at=datetime.utcnow()) + ) + await db.commit() + except Exception as error: # pragma: no cover - диагностический лог + logger.warning("Не удалось сохранить сообщение YooKassa (СБП): %s", error) + + await state.update_data( + yookassa_invoice_message_id=invoice_message.message_id, + yookassa_invoice_chat_id=invoice_message.chat.id, + ) + + await state.clear() logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 4b49f51f..48356450 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,10 +1,10 @@ import logging from decimal import Decimal, ROUND_HALF_UP from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import User from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id @@ -18,7 +18,9 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): texts = get_texts(DEFAULT_LANGUAGE) try: - logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}") + logger.info( + f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}" + ) allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_") @@ -35,6 +37,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): try: from app.database.database import get_db + async for db in get_db(): user = await get_user_by_telegram_id(db, query.from_user.id) if not user: @@ -77,6 +80,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): async def handle_successful_payment( message: types.Message, db: AsyncSession, + state: FSMContext, **kwargs ): texts = get_texts(DEFAULT_LANGUAGE) @@ -106,6 +110,27 @@ async def handle_successful_payment( return payment_service = PaymentService(message.bot) + + state_data = await state.get_data() + prompt_message_id = state_data.get("stars_prompt_message_id") + prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) + invoice_message_id = state_data.get("stars_invoice_message_id") + invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id) + + for chat_id, message_id, label in [ + (prompt_chat_id, prompt_message_id, "запрос суммы"), + (invoice_chat_id, invoice_message_id, "инвойс Stars"), + ]: + if message_id: + try: + await message.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning( + "Не удалось удалить сообщение %s после оплаты Stars: %s", + label, + delete_error, + ) + success = await payment_service.process_stars_payment( db=db, user_id=user.id, @@ -113,7 +138,14 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - + + await state.update_data( + stars_prompt_message_id=None, + stars_prompt_chat_id=None, + stars_invoice_message_id=None, + stars_invoice_chat_id=None, + ) + if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) @@ -172,15 +204,15 @@ async def handle_successful_payment( def register_stars_handlers(dp: Dispatcher): - + dp.pre_checkout_query.register( handle_pre_checkout_query, - F.currency == "XTR" + F.currency == "XTR" ) - + dp.message.register( handle_successful_payment, F.successful_payment ) - + logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index dc474150..c347cddf 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1023,7 +1023,6 @@ "PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions", "PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.", "PAL24_PAYMENT_INSTRUCTIONS": "🏦 PayPalych (SBP) payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", - "PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)", "PAL24_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)", "PAL24_SELECT_PAYMENT_METHOD": "Choose a PayPalych payment method:", "PAL24_TOPUP_PROMPT": "🏦 PayPalych (SBP) payment\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.", @@ -1061,12 +1060,8 @@ "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", - "PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket", - "PAYMENT_METHOD_HELEKET_NAME": "🪙 Cryptocurrency (Heleket)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}", "PAYMENT_METHOD_MULENPAY_NAME": "💳 Bank card ({mulenpay_name})", - "PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System", - "PAYMENT_METHOD_PAL24_NAME": "🏦 SBP (PayPalych)", "PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)", "PAYMENT_METHOD_PLATEGA_NAME": "💳 Bank card (Platega)", "PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient", @@ -1075,8 +1070,6 @@ "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", - "PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA", - "PAYMENT_METHOD_WATA_NAME": "💳 Bank card (WATA)", "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Bank card", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index da8b89f9..a77f47fb 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1043,7 +1043,6 @@ "PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы", "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych (СБП)\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", - "PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)", "PAL24_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)", "PAL24_SELECT_PAYMENT_METHOD": "Выберите способ оплаты PayPalych:", "PAL24_TOPUP_PROMPT": "🏦 Оплата через PayPalych (СБП)\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.", @@ -1081,12 +1080,8 @@ "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", - "PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket", - "PAYMENT_METHOD_HELEKET_NAME": "🪙 Криптовалюта (Heleket)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}", "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банковская карта ({mulenpay_name})", - "PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей", - "PAYMENT_METHOD_PAL24_NAME": "🏦 СБП (PayPalych)", "PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)", "PAYMENT_METHOD_PLATEGA_NAME": "💳 Банковская карта (Platega)", "PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно", @@ -1095,8 +1090,6 @@ "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", - "PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA", - "PAYMENT_METHOD_WATA_NAME": "💳 Банковская карта (WATA)", "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банковская карта", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 4c8fb4b9..68897290 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -1039,12 +1039,11 @@ "PAL24_INSTRUCTION_BUTTON": "{step}. Натисніть кнопку «{button}»", "PAL24_INSTRUCTION_COMPLETE": "{step}. Кошти зарахуються автоматично", "PAL24_INSTRUCTION_CONFIRM": "{step}. Підтвердіть переказ", - "PAL24_INSTRUCTION_FOLLOW": "{step}. Дотримуйтесь підказок платіжної системи", - "PAL24_PAYMENT_ERROR": "❌ Помилка створення платежу PayPalych. Спробуйте пізніше або зверніться до підтримки.", - "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych (СБП)\n\n💰 Сума: {amount}\n🆔 ID рахунку: {bill_id}\n\n📱 Інструкція:\n1. Натисніть кнопку ‘Оплатити через PayPalych (СБП)’\n2. Дотримуйтесь підказок платіжної системи\n3. Підтвердіть переказ\n4. Кошти зарахуються автоматично\n\n❓ Якщо виникнуть проблеми, зверніться до {support}", - "PAL24_PAY_BUTTON": "🏦 Оплатити через PayPalych (СБП)", - "PAL24_SBP_PAY_BUTTON": "🏦 Оплатити через PayPalych (СБП)", - "PAL24_SELECT_PAYMENT_METHOD": "Оберіть спосіб оплати PayPalych:", + "PAL24_INSTRUCTION_FOLLOW": "{step}. Дотримуйтесь підказок платіжної системи", + "PAL24_PAYMENT_ERROR": "❌ Помилка створення платежу PayPalych. Спробуйте пізніше або зверніться до підтримки.", + "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych (СБП)\n\n💰 Сума: {amount}\n🆔 ID рахунку: {bill_id}\n\n📱 Інструкція:\n1. Натисніть кнопку ‘Оплатити через PayPalych (СБП)’\n2. Дотримуйтесь підказок платіжної системи\n3. Підтвердіть переказ\n4. Кошти зарахуються автоматично\n\n❓ Якщо виникнуть проблеми, зверніться до {support}", + "PAL24_SBP_PAY_BUTTON": "🏦 Оплатити через PayPalych (СБП)", + "PAL24_SELECT_PAYMENT_METHOD": "Оберіть спосіб оплати PayPalych:", "PAL24_TOPUP_PROMPT": "🏦 Оплата через PayPalych (СБП)\n\nВведіть суму для поповнення від 100 до 1 000 000 ₽.\nОплата проходить через систему швидких платежів PayPalych.", "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способи оплати тимчасово недоступні", "PAYMENT_CARD_MULENPAY": "💳 Банківська картка ({mulenpay_name})", @@ -1080,24 +1079,18 @@ "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ На даний момент автоматичні способи оплати тимчасово недоступні. Для поповнення балансу зверніться до техпідтримки.", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", - "PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket", - "PAYMENT_METHOD_HELEKET_NAME": "🪙 Криптовалюта (Heleket)", - "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}", - "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банківська картка ({mulenpay_name})", - "PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему швидких платежів", - "PAYMENT_METHOD_PAL24_NAME": "🏦 СБП (PayPalych)", - "PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (картки + СБП)", - "PAYMENT_METHOD_PLATEGA_NAME": "💳 Банківська картка (Platega)", +"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}", +"PAYMENT_METHOD_MULENPAY_NAME": "💳 Банківська картка ({mulenpay_name})", +"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (картки + СБП)", +"PAYMENT_METHOD_PLATEGA_NAME": "💳 Банківська картка (Platega)", "PAYMENT_METHOD_STARS_DESCRIPTION": "швидко та зручно", "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "інші способи", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через підтримку", - "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", - "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банківська картка", - "PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA", - "PAYMENT_METHOD_WATA_NAME": "💳 Банківська картка (WATA)", - "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", - "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банківська картка", +"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", +"PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банківська картка", +"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", +"PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банківська картка", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему швидких платежів YooKassa", "PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 СБП (YooKassa)", "PAYMENT_HELEKET_MARKUP_LABEL": "Націнка провайдера", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 84210ac8..3c0b906f 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -1042,7 +1042,6 @@ "PAL24_INSTRUCTION_FOLLOW":"{step}.按照支付系统提示操作", "PAL24_PAYMENT_ERROR":"❌创建PayPalych付款失败。请稍后再试或联系支持。", "PAL24_PAYMENT_INSTRUCTIONS":"🏦通过PayPalych(SBP)付款\n\n💰金额:{amount}\n🆔账单ID:{bill_id}\n\n📱说明:\n1.点击“通过PayPalych(SBP)付款”按钮\n2.按照支付系统提示操作\n3.确认转账\n4.资金将自动到账\n\n❓如果遇到问题,请联系{support}", -"PAL24_PAY_BUTTON":"🏦通过PayPalych(SBP)付款", "PAL24_SBP_PAY_BUTTON":"🏦通过PayPalych(SBP)付款", "PAL24_SELECT_PAYMENT_METHOD":"请选择PayPalych支付方式:", "PAL24_TOPUP_PROMPT":"🏦通过PayPalych(SBP)付款\n\n请输入充值金额,范围100至1000000₽。\n付款通过PayPalych快速支付系统进行。", @@ -1080,12 +1079,8 @@ "PAYMENT_METHODS_UNAVAILABLE_ALERT":"⚠️目前自动支付方式暂时不可用。如需充值余额,请联系技术支持。", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION":"通过CryptoBot", "PAYMENT_METHOD_CRYPTOBOT_NAME":"🪙加密货币", -"PAYMENT_METHOD_HELEKET_DESCRIPTION":"通过Heleket", -"PAYMENT_METHOD_HELEKET_NAME":"🪙加密货币(Heleket)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION":"通过{mulenpay_name}", "PAYMENT_METHOD_MULENPAY_NAME":"💳银行卡({mulenpay_name})", -"PAYMENT_METHOD_PAL24_DESCRIPTION":"通过快速支付系统", -"PAYMENT_METHOD_PAL24_NAME":"🏦SBP(PayPalych)", "PAYMENT_METHOD_PLATEGA_DESCRIPTION":"通过Platega(银行卡+SBP)", "PAYMENT_METHOD_PLATEGA_NAME":"💳银行卡(Platega)", "PAYMENT_METHOD_STARS_DESCRIPTION":"快速便捷", @@ -1094,8 +1089,6 @@ "PAYMENT_METHOD_SUPPORT_NAME":"🛠️通过支持", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION":"通过Tribute", "PAYMENT_METHOD_TRIBUTE_NAME":"💳银行卡", -"PAYMENT_METHOD_WATA_DESCRIPTION":"通过WATA", -"PAYMENT_METHOD_WATA_NAME":"💳银行卡(WATA)", "PAYMENT_METHOD_YOOKASSA_DESCRIPTION":"通过YooKassa", "PAYMENT_METHOD_YOOKASSA_NAME":"💳银行卡", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION":"通过YooKassa快速支付系统", diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index c032f990..27d24a2b 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -255,6 +255,42 @@ class HeleketPaymentMixin: if updated_payment is None: return None + metadata = dict(getattr(updated_payment, "metadata_json", {}) or {}) + invoice_message = metadata.get("invoice_message") or {} + invoice_message_removed = False + + if getattr(self, "bot", None) and invoice_message: + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on rights + logger.warning( + "Не удалось удалить счёт Heleket %s: %s", + message_id, + delete_error, + ) + else: + metadata.pop("invoice_message", None) + invoice_message_removed = True + + if invoice_message_removed: + try: + from app.database.crud import heleket as heleket_crud + + await heleket_crud.update_heleket_payment( + db, + updated_payment.uuid, + metadata=metadata, + ) + updated_payment.metadata_json = metadata + except Exception as error: # pragma: no cover - diagnostics + logger.warning( + "Не удалось обновить метаданные Heleket после удаления счёта: %s", + error, + ) + if updated_payment.transaction_id: logger.info( "Heleket платеж %s уже связан с транзакцией %s", diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 7b4ffcf3..27dea36d 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -182,7 +182,43 @@ class MulenPayPaymentMixin: ) return False + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + invoice_message = metadata.get("invoice_message") or {} + + invoice_message_removed = False + + if getattr(self, "bot", None): + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning( + "Не удалось удалить %s счёт %s: %s", + display_name, + message_id, + delete_error, + ) + else: + metadata.pop("invoice_message", None) + invoice_message_removed = True + if payment.is_paid: + if invoice_message_removed: + try: + await payment_module.update_mulenpay_payment_metadata( + db, + payment=payment, + metadata=metadata, + ) + except Exception as error: # pragma: no cover - diagnostics + logger.warning( + "Не удалось обновить метаданные %s после удаления счёта: %s", + display_name, + error, + ) + logger.info( "%s платеж %s уже обработан, игнорируем повторный callback", display_name, @@ -197,6 +233,7 @@ class MulenPayPaymentMixin: status="success", callback_payload=callback_data, mulen_payment_id=mulen_payment_id_int, + metadata=metadata, ) if payment.transaction_id: diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 6dbe3dc5..460e0e31 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -333,6 +333,41 @@ class Pal24PaymentMixin: payment_module = import_module("app.services.payment_service") + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + invoice_message = metadata.get("invoice_message") or {} + invoice_message_removed = False + + if getattr(self, "bot", None) and invoice_message: + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on rights + logger.warning( + "Не удалось удалить счёт PayPalych %s: %s", + message_id, + delete_error, + ) + else: + metadata.pop("invoice_message", None) + invoice_message_removed = True + + if invoice_message_removed: + try: + await payment_module.update_pal24_payment_status( + db, + payment, + status=payment.status, + metadata=metadata, + ) + payment.metadata_json = metadata + except Exception as error: # pragma: no cover - diagnostics + logger.warning( + "Не удалось обновить метаданные PayPalych после удаления счёта: %s", + error, + ) + if payment.transaction_id: logger.info( "Pal24 платеж %s уже привязан к транзакции (trigger=%s)", diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index fc7fe4d8..2d08997a 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -306,6 +306,22 @@ class PlategaPaymentMixin: metadata = dict(getattr(payment, "metadata_json", {}) or {}) balance_already_credited = bool(metadata.get("balance_credited")) + invoice_message = metadata.get("invoice_message") or {} + if getattr(self, "bot", None): + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning( + "Не удалось удалить Platega счёт %s: %s", + message_id, + delete_error, + ) + else: + metadata.pop("invoice_message", None) + if payment.transaction_id: logger.info( "Platega платеж %s уже связан с транзакцией %s", diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index b7fd5942..3f1022d7 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,36 +515,6 @@ class TelegramStarsMixin: exc_info=True, ) - if getattr(self, "bot", None): - try: - keyboard = await self.build_topup_success_keyboard(user) - - charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] - - await self.bot.send_message( - user.telegram_id, - ( - "✅ Пополнение успешно!\n\n" - f"⭐ Звезд: {stars_amount}\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - "🦊 Способ: Telegram Stars\n" - f"🆔 Транзакция: {charge_id_short}...\n\n" - "Баланс пополнен автоматически!" - ), - parse_mode="HTML", - reply_markup=keyboard, - ) - logger.info( - "✅ Отправлено уведомление пользователю %s о пополнении на %s", - user.telegram_id, - settings.format_price(amount_kopeks), - ) - except Exception as error: # pragma: no cover - диагностический лог - logger.error( - "Ошибка отправки уведомления о пополнении Stars: %s", - error, - ) - # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index d8bc789a..7072d096 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -415,6 +415,25 @@ class WataPaymentMixin: if not paid_at and getattr(payment, "paid_at", None): paid_at = payment.paid_at existing_metadata = dict(getattr(payment, "metadata_json", {}) or {}) + + invoice_message = existing_metadata.get("invoice_message") or {} + invoice_message_removed = False + if getattr(self, "bot", None) and invoice_message: + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on rights + logger.warning( + "Не удалось удалить счёт WATA %s: %s", + message_id, + delete_error, + ) + else: + invoice_message_removed = True + existing_metadata.pop("invoice_message", None) + existing_metadata["transaction"] = transaction_payload await payment_module.update_wata_payment_status( diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 181ab94d..465664a6 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -383,11 +383,19 @@ class YooKassaPaymentMixin: payment_module = import_module("app.services.payment_service") # Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования) - existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] - db, - payment.yookassa_payment_id, - PaymentMethod.YOOKASSA, + get_transaction_by_external_id = getattr( + payment_module, "get_transaction_by_external_id", None ) + existing_transaction = None + if get_transaction_by_external_id: + try: + existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined] + db, + payment.yookassa_payment_id, + PaymentMethod.YOOKASSA, + ) + except AttributeError: + logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов") if existing_transaction: # Если транзакция уже существует, просто завершаем обработку @@ -437,6 +445,22 @@ class YooKassaPaymentMixin: except Exception as parse_error: logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}") + invoice_message = payment_metadata.get("invoice_message") or {} + if getattr(self, "bot", None): + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning( + "Не удалось удалить сообщение YooKassa %s: %s", + message_id, + delete_error, + ) + else: + payment_metadata.pop("invoice_message", None) + processing_completed = bool(payment_metadata.get("processing_completed")) transaction = None @@ -472,11 +496,20 @@ class YooKassaPaymentMixin: ) if transaction is None: - existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] - db, - payment.yookassa_payment_id, - PaymentMethod.YOOKASSA, + get_transaction_by_external_id = getattr( + payment_module, "get_transaction_by_external_id", None ) + existing_transaction = None + + if get_transaction_by_external_id: + try: + existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined] + db, + payment.yookassa_payment_id, + PaymentMethod.YOOKASSA, + ) + except AttributeError: + logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов") if existing_transaction: # Если транзакция уже существует, пропускаем обработку diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 9f417d73..949f7b23 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -112,6 +112,11 @@ async def update_mulenpay_payment_status(*args, **kwargs): return await mulenpay_crud.update_mulenpay_payment_status(*args, **kwargs) +async def update_mulenpay_payment_metadata(*args, **kwargs): + mulenpay_crud = import_module("app.database.crud.mulenpay") + return await mulenpay_crud.update_mulenpay_payment_metadata(*args, **kwargs) + + async def link_mulenpay_payment_to_transaction(*args, **kwargs): mulenpay_crud = import_module("app.database.crud.mulenpay") return await mulenpay_crud.link_mulenpay_payment_to_transaction(*args, **kwargs) diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index bef09a91..3a6d8c07 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -23,10 +23,30 @@ logger = logging.getLogger(__name__) class TributeService: + _invoice_messages: Dict[int, Dict[str, int]] = {} def __init__(self, bot: Bot): self.bot = bot self.tribute_api = TributeAPI() + + @classmethod + def remember_invoice_message(cls, user_id: int, chat_id: int, message_id: int) -> None: + cls._invoice_messages[user_id] = {"chat_id": chat_id, "message_id": message_id} + + async def _cleanup_invoice_message(self, user_id: int) -> None: + invoice_message = self._invoice_messages.pop(user_id, None) + if not invoice_message or not getattr(self, "bot", None): + return + + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if not chat_id or not message_id: + return + + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as error: # pragma: no cover - depends on bot rights + logger.warning("Не удалось удалить Tribute счёт %s: %s", message_id, error) async def create_payment_link( self, @@ -174,7 +194,8 @@ class TributeService: ) except Exception as e: logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}") - + + await self._cleanup_invoice_message(user_telegram_id) await self._send_success_notification(user_telegram_id, amount_kopeks) logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}") From 78db3a1e77e24f443c25a1ede904e82c8f6683c1 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 05:26:21 +0300 Subject: [PATCH 19/20] Revert "Handle YooKassa webhook payloads without payment ids" --- app/database/crud/mulenpay.py | 17 ---- app/database/crud/pal24.py | 3 - app/external/yookassa_webhook.py | 17 ++-- app/handlers/balance/heleket.py | 55 +------------ app/handlers/balance/mulenpay.py | 55 +------------ app/handlers/balance/pal24.py | 52 +------------ app/handlers/balance/platega.py | 47 +---------- app/handlers/balance/stars.py | 66 +++++----------- app/handlers/balance/tribute.py | 52 ++++++------- app/handlers/balance/wata.py | 52 +------------ app/handlers/balance/yookassa.py | 129 ++++--------------------------- app/handlers/stars_payments.py | 46 ++--------- app/localization/locales/en.json | 7 ++ app/localization/locales/ru.json | 7 ++ app/localization/locales/ua.json | 33 ++++---- app/localization/locales/zh.json | 7 ++ app/services/payment/heleket.py | 36 --------- app/services/payment/mulenpay.py | 37 --------- app/services/payment/pal24.py | 35 --------- app/services/payment/platega.py | 16 ---- app/services/payment/stars.py | 30 +++++++ app/services/payment/wata.py | 19 ----- app/services/payment/yookassa.py | 49 ++---------- app/services/payment_service.py | 5 -- app/services/tribute_service.py | 23 +----- 25 files changed, 160 insertions(+), 735 deletions(-) diff --git a/app/database/crud/mulenpay.py b/app/database/crud/mulenpay.py index 062dcaab..17648f4a 100644 --- a/app/database/crud/mulenpay.py +++ b/app/database/crud/mulenpay.py @@ -3,7 +3,6 @@ from datetime import datetime from typing import Optional from sqlalchemy import select -from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -91,7 +90,6 @@ async def update_mulenpay_payment_status( paid_at: Optional[datetime] = None, callback_payload: Optional[dict] = None, mulen_payment_id: Optional[int] = None, - metadata: Optional[dict] = None, ) -> MulenPayPayment: payment.status = status if is_paid is not None: @@ -102,8 +100,6 @@ async def update_mulenpay_payment_status( payment.callback_payload = callback_payload if mulen_payment_id is not None and not payment.mulen_payment_id: payment.mulen_payment_id = mulen_payment_id - if metadata is not None: - payment.metadata_json = metadata payment.updated_at = datetime.utcnow() await db.commit() @@ -111,19 +107,6 @@ async def update_mulenpay_payment_status( return payment -async def update_mulenpay_payment_metadata( - db: AsyncSession, - *, - payment: MulenPayPayment, - metadata: dict, -) -> MulenPayPayment: - payment.metadata_json = metadata - payment.updated_at = datetime.utcnow() - await db.commit() - await db.refresh(payment) - return payment - - async def link_mulenpay_payment_to_transaction( db: AsyncSession, *, diff --git a/app/database/crud/pal24.py b/app/database/crud/pal24.py index 35cffa61..a88c46b4 100644 --- a/app/database/crud/pal24.py +++ b/app/database/crud/pal24.py @@ -96,7 +96,6 @@ async def update_pal24_payment_status( balance_currency: Optional[str] = None, payer_account: Optional[str] = None, callback_payload: Optional[Dict[str, Any]] = None, - metadata: Optional[Dict[str, Any]] = None, ) -> Pal24Payment: update_values: Dict[str, Any] = { "status": status, @@ -122,8 +121,6 @@ async def update_pal24_payment_status( update_values["payer_account"] = payer_account if callback_payload is not None: update_values["callback_payload"] = callback_payload - if metadata is not None: - update_values["metadata_json"] = metadata update_values["last_status"] = status diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index 21091332..0c6485b5 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -254,15 +254,16 @@ class YooKassaWebhookHandler: logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}") logger.debug(f"🔍 Полные данные webhook: {webhook_data}") - event_type = webhook_data.get("event") - if not event_type: - logger.warning("⚠️ Webhook YooKassa без типа события") - return web.Response(status=400, text="No event type") - # Извлекаем ID платежа из вебхука для предотвращения дублирования yookassa_payment_id = webhook_data.get("object", {}).get("id") if not yookassa_payment_id: logger.warning("⚠️ Webhook YooKassa без ID платежа") + return web.Response(status=400, text="No payment ID") + + event_type = webhook_data.get("event") + if not event_type: + logger.warning("⚠️ Webhook YooKassa без типа события") + return web.Response(status=400, text="No event type") if event_type not in YOOKASSA_ALLOWED_EVENTS: logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}") @@ -273,10 +274,8 @@ class YooKassaWebhookHandler: # Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования) from app.database.models import PaymentMethod from app.database.crud.transaction import get_transaction_by_external_id - existing_transaction = None - if yookassa_payment_id and hasattr(db, "execute"): - existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA) - + existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA) + if existing_transaction and event_type == "payment.succeeded": logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.") return web.Response(status=200, text="OK") diff --git a/app/handlers/balance/heleket.py b/app/handlers/balance/heleket.py index f97aa1b2..37ac4b53 100644 --- a/app/handlers/balance/heleket.py +++ b/app/handlers/balance/heleket.py @@ -1,10 +1,8 @@ import logging -from datetime import datetime from typing import Optional from aiogram import types from aiogram.fsm.context import FSMContext -from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -68,11 +66,7 @@ async def start_heleket_payment( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data( - payment_method="heleket", - heleket_prompt_message_id=callback.message.message_id, - heleket_prompt_chat_id=callback.message.chat.id, - ) + await state.update_data(payment_method="heleket") await callback.answer() @@ -187,52 +181,7 @@ async def process_heleket_payment_amount( [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], ]) - state_data = await state.get_data() - prompt_message_id = state_data.get("heleket_prompt_message_id") - prompt_chat_id = state_data.get("heleket_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning("Не удалось удалить сообщение с суммой Heleket: %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - diagnostic - logger.warning( - "Не удалось удалить сообщение с запросом суммы Heleket: %s", - delete_error, - ) - - invoice_message = await message.answer( - "\n".join(details), parse_mode="HTML", reply_markup=keyboard - ) - - try: - from app.services import payment_service as payment_module - - payment = await payment_module.get_heleket_payment_by_id(db, result["local_payment_id"]) - if payment: - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, - } - await db.execute( - update(payment.__class__) - .where(payment.__class__.id == payment.id) - .values(metadata_json=metadata, updated_at=datetime.utcnow()) - ) - await db.commit() - except Exception as error: # pragma: no cover - diagnostics - logger.warning("Не удалось сохранить сообщение Heleket: %s", error) - - await state.update_data( - heleket_invoice_message_id=invoice_message.message_id, - heleket_invoice_chat_id=invoice_message.chat.id, - ) - + await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard) await state.clear() diff --git a/app/handlers/balance/mulenpay.py b/app/handlers/balance/mulenpay.py index f345abeb..840ac469 100644 --- a/app/handlers/balance/mulenpay.py +++ b/app/handlers/balance/mulenpay.py @@ -59,11 +59,7 @@ async def start_mulenpay_payment( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data( - payment_method="mulenpay", - mulenpay_prompt_message_id=callback.message.message_id, - mulenpay_prompt_chat_id=callback.message.chat.id, - ) + await state.update_data(payment_method="mulenpay") await callback.answer() @@ -97,26 +93,6 @@ async def process_mulenpay_payment_amount( amount_rubles = amount_kopeks / 100 - state_data = await state.get_data() - prompt_message_id = state_data.get("mulenpay_prompt_message_id") - prompt_chat_id = state_data.get("mulenpay_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - depends on bot permissions - logger.warning( - "Не удалось удалить сообщение с суммой MulenPay: %s", delete_error - ) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - diagnostic - logger.warning( - "Не удалось удалить сообщение с запросом суммы MulenPay: %s", - delete_error, - ) - try: payment_service = PaymentService(message.bot) payment_result = await payment_service.create_mulenpay_payment( @@ -187,39 +163,12 @@ async def process_mulenpay_payment_amount( mulenpay_name_html=mulenpay_name_html, ) - invoice_message = await message.answer( + await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML", ) - try: - from app.services import payment_service as payment_module - - payment = await payment_module.get_mulenpay_payment_by_local_id( - db, local_payment_id - ) - if payment: - payment_metadata = dict( - getattr(payment, "metadata_json", {}) or {} - ) - payment_metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, - } - await payment_module.update_mulenpay_payment_metadata( - db, - payment=payment, - metadata=payment_metadata, - ) - except Exception as error: # pragma: no cover - diagnostic logging only - logger.warning("Не удалось сохранить данные сообщения MulenPay: %s", error) - - await state.update_data( - mulenpay_invoice_message_id=invoice_message.message_id, - mulenpay_invoice_chat_id=invoice_message.chat.id, - ) - await state.clear() logger.info( diff --git a/app/handlers/balance/pal24.py b/app/handlers/balance/pal24.py index 28eda7f9..0a66b20f 100644 --- a/app/handlers/balance/pal24.py +++ b/app/handlers/balance/pal24.py @@ -1,12 +1,10 @@ import html import logging -from datetime import datetime from typing import Any, Optional from aiogram import types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext -from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -206,36 +204,12 @@ async def _send_pal24_payment_message( support=settings.get_support_contact_display_html(), ) - invoice_message = await message.answer( + await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML", ) - try: - from app.services import payment_service as payment_module - - payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id) - if payment: - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, - } - await db.execute( - update(payment.__class__) - .where(payment.__class__.id == payment.id) - .values(metadata_json=metadata, updated_at=datetime.utcnow()) - ) - await db.commit() - except Exception as error: # pragma: no cover - diagnostics - logger.warning("Не удалось сохранить сообщение PayPalych: %s", error) - - await state.update_data( - pal24_invoice_message_id=invoice_message.message_id, - pal24_invoice_chat_id=invoice_message.chat.id, - ) - await state.clear() logger.info( @@ -303,11 +277,7 @@ async def start_pal24_payment( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data( - payment_method="pal24", - pal24_prompt_message_id=callback.message.message_id, - pal24_prompt_chat_id=callback.message.chat.id, - ) + await state.update_data(payment_method="pal24") await callback.answer() @@ -339,24 +309,6 @@ async def process_pal24_payment_amount( available_methods = _get_available_pal24_methods() - state_data = await state.get_data() - prompt_message_id = state_data.get("pal24_prompt_message_id") - prompt_chat_id = state_data.get("pal24_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning("Не удалось удалить сообщение с суммой PayPalych: %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - diagnostic - logger.warning( - "Не удалось удалить сообщение с запросом суммы PayPalych: %s", - delete_error, - ) - if len(available_methods) == 1: await _send_pal24_payment_message( message, diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py index def18f65..40e8c45f 100644 --- a/app/handlers/balance/platega.py +++ b/app/handlers/balance/platega.py @@ -98,10 +98,6 @@ async def _prompt_amount( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data( - platega_prompt_message_id=message.message_id, - platega_prompt_chat_id=message.chat.id, - ) @error_handler @@ -304,25 +300,7 @@ async def process_platega_payment_amount( ), ) - state_data = await state.get_data() - prompt_message_id = state_data.get("platega_prompt_message_id") - prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось удалить сообщение с запросом суммы Platega: %s", - delete_error, - ) - - invoice_message = await message.answer( + await message.answer( instructions_template.format( method=method_title, amount=settings.format_price(amount_kopeks), @@ -333,29 +311,6 @@ async def process_platega_payment_amount( parse_mode="HTML", ) - try: - from app.services import payment_service as payment_module - - payment = await payment_module.get_platega_payment_by_id(db, local_payment_id) - if payment: - payment_metadata = dict(getattr(payment, "metadata_json", {}) or {}) - payment_metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, - } - await payment_module.update_platega_payment( - db, - payment=payment, - metadata=payment_metadata, - ) - except Exception as error: # pragma: no cover - диагностический лог - logger.warning("Не удалось сохранить данные сообщения Platega: %s", error) - - await state.update_data( - platega_invoice_message_id=invoice_message.message_id, - platega_invoice_chat_id=invoice_message.chat.id, - ) - await state.clear() diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index a8cac0f8..0dcf0031 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,10 +1,11 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User -from app.keyboards.inline import get_back_keyboard +from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard from app.localization.texts import get_texts from app.services.payment_service import PaymentService from app.states import BalanceStates @@ -21,11 +22,11 @@ async def start_stars_payment( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) return - + # Формируем текст сообщения в зависимости от настройки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: message_text = ( @@ -34,10 +35,10 @@ async def start_stars_payment( ) else: message_text = texts.TOP_UP_AMOUNT - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons @@ -45,17 +46,12 @@ async def start_stars_payment( if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard ) - - await state.update_data( - stars_prompt_message_id=callback.message.message_id, - stars_prompt_chat_id=callback.message.chat.id, - ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="stars") await callback.answer() @@ -69,48 +65,29 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - - state_data = await state.get_data() - - prompt_message_id = state_data.get("stars_prompt_message_id") - prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось удалить сообщение с запросом суммы Stars: %s", - delete_error, - ) - - invoice_message = await message.answer( + + await message.answer( f"⭐ Оплата через Telegram Stars\n\n" f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" f"⭐ К оплате: {stars_amount} звезд\n" @@ -119,14 +96,9 @@ async def process_stars_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - - await state.update_data( - stars_invoice_message_id=invoice_message.message_id, - stars_invoice_chat_id=invoice_message.chat.id, - ) - - await state.set_state(None) - + + await state.clear() + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") + await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file diff --git a/app/handlers/balance/tribute.py b/app/handlers/balance/tribute.py index f96538a5..e6d24015 100644 --- a/app/handlers/balance/tribute.py +++ b/app/handlers/balance/tribute.py @@ -13,55 +13,47 @@ logger = logging.getLogger(__name__) @error_handler async def start_tribute_payment( callback: types.CallbackQuery, - db_user: User, + db_user: User ): texts = get_texts(db_user.language) - + if not settings.TRIBUTE_ENABLED: await callback.answer("❌ Оплата картой временно недоступна", show_alert=True) return - + try: from app.services.tribute_service import TributeService - + tribute_service = TributeService(callback.bot) payment_url = await tribute_service.create_payment_link( user_id=db_user.telegram_id, amount_kopeks=0, - description="Пополнение баланса VPN", + description="Пополнение баланса VPN" ) - + if not payment_url: await callback.answer("❌ Ошибка создания платежа", show_alert=True) return - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], - ] - ) - + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] + ]) + await callback.message.edit_text( - f"💳 Пополнение банковской картой\n\n", - f"• Введите любую сумму от 100₽\n", - f"• Безопасная оплата через Tribute\n", - f"• Мгновенное зачисление на баланс\n", - f"• Принимаем карты Visa, MasterCard, МИР\n\n", - f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n", + f"💳 Пополнение банковской картой\n\n" + f"• Введите любую сумму от 100₽\n" + f"• Безопасная оплата через Tribute\n" + f"• Мгновенное зачисление на баланс\n" + f"• Принимаем карты Visa, MasterCard, МИР\n\n" + f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n" f"Нажмите кнопку для перехода к оплате:", reply_markup=keyboard, - parse_mode="HTML", + parse_mode="HTML" ) - - TributeService.remember_invoice_message( - db_user.telegram_id, - callback.message.chat.id, - callback.message.message_id, - ) - + except Exception as e: logger.error(f"Ошибка создания Tribute платежа: {e}") await callback.answer("❌ Ошибка создания платежа", show_alert=True) - - await callback.answer() + + await callback.answer() \ No newline at end of file diff --git a/app/handlers/balance/wata.py b/app/handlers/balance/wata.py index fdd2327e..259d559e 100644 --- a/app/handlers/balance/wata.py +++ b/app/handlers/balance/wata.py @@ -1,10 +1,8 @@ import logging -from datetime import datetime from typing import Dict from aiogram import types from aiogram.fsm.context import FSMContext -from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -58,11 +56,7 @@ async def start_wata_payment( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data( - payment_method="wata", - wata_prompt_message_id=callback.message.message_id, - wata_prompt_chat_id=callback.message.chat.id, - ) + await state.update_data(payment_method="wata") await callback.answer() @@ -165,54 +159,12 @@ async def process_wata_payment_amount( support=settings.get_support_contact_display_html(), ) - state_data = await state.get_data() - prompt_message_id = state_data.get("wata_prompt_message_id") - prompt_chat_id = state_data.get("wata_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning("Не удалось удалить сообщение с суммой WATA: %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - diagnostic - logger.warning( - "Не удалось удалить сообщение с запросом суммы WATA: %s", - delete_error, - ) - - invoice_message = await message.answer( + await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML", ) - try: - from app.services import payment_service as payment_module - - payment = await payment_module.get_wata_payment_by_local_id(db, local_payment_id) - if payment: - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, - } - await db.execute( - update(payment.__class__) - .where(payment.__class__.id == payment.id) - .values(metadata_json=metadata, updated_at=datetime.utcnow()) - ) - await db.commit() - except Exception as error: # pragma: no cover - diagnostics - logger.warning("Не удалось сохранить сообщение WATA: %s", error) - - await state.update_data( - wata_invoice_message_id=invoice_message.message_id, - wata_invoice_chat_id=invoice_message.chat.id, - ) - await state.clear() logger.info( diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py index fb6a345c..607f62bd 100644 --- a/app/handlers/balance/yookassa.py +++ b/app/handlers/balance/yookassa.py @@ -1,9 +1,6 @@ import logging -from datetime import datetime - from aiogram import types from aiogram.fsm.context import FSMContext -from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -61,13 +58,9 @@ async def start_yookassa_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa") - await state.update_data( - yookassa_prompt_message_id=callback.message.message_id, - yookassa_prompt_chat_id=callback.message.chat.id, - ) await callback.answer() @@ -115,13 +108,9 @@ async def start_yookassa_sbp_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa_sbp") - await state.update_data( - yookassa_prompt_message_id=callback.message.message_id, - yookassa_prompt_chat_id=callback.message.chat.id, - ) await callback.answer() @@ -183,25 +172,7 @@ async def process_yookassa_payment_amount( [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - state_data = await state.get_data() - prompt_message_id = state_data.get("yookassa_prompt_message_id") - prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось удалить сообщение с запросом суммы YooKassa: %s", - delete_error, - ) - - invoice_message = await message.answer( + await message.answer( f"💳 Оплата банковской картой\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" @@ -216,34 +187,9 @@ async def process_yookassa_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - - try: - from app.services import payment_service as payment_module - - payment = await payment_module.get_yookassa_payment_by_local_id( - db, payment_result["local_payment_id"] - ) - if payment: - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, - } - await db.execute( - update(payment.__class__) - .where(payment.__class__.id == payment.id) - .values(metadata_json=metadata, updated_at=datetime.utcnow()) - ) - await db.commit() - except Exception as error: # pragma: no cover - диагностический лог - logger.warning("Не удалось сохранить сообщение YooKassa: %s", error) - - await state.update_data( - yookassa_invoice_message_id=invoice_message.message_id, - yookassa_invoice_chat_id=invoice_message.chat.id, - ) - + await state.clear() + logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") @@ -364,45 +310,27 @@ async def process_yookassa_sbp_payment_amount( # Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса keyboard_buttons = [] - + # Добавляем кнопку оплаты, если доступна ссылка if confirmation_url: keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)]) else: # Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")]) - + # Добавляем общие кнопки keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")]) keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) - - state_data = await state.get_data() - prompt_message_id = state_data.get("yookassa_prompt_message_id") - prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) - - try: - await message.delete() - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error) - - if prompt_message_id: - try: - await message.bot.delete_message(prompt_chat_id, prompt_message_id) - except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s", - delete_error, - ) - + # Подготавливаем текст сообщения message_text = ( f"🔗 Оплата через СБП\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" ) - + # Добавляем инструкции в зависимости от доступных способов оплаты if not confirmation_url: message_text += ( @@ -413,18 +341,18 @@ async def process_yookassa_sbp_payment_amount( f"4. Подтвердите платеж в приложении банка\n" f"5. Деньги поступят на баланс автоматически\n\n" ) - + message_text += ( f"🔒 Оплата происходит через защищенную систему YooKassa\n" f"✅ Принимаем СБП от всех банков-участников\n\n" f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}" ) - + # Отправляем сообщение с инструкциями и клавиатурой # Если есть QR-код, отправляем его как медиа-сообщение if qr_photo: # Используем метод отправки медиа-группы или фото с описанием - invoice_message = await message.answer_photo( + await message.answer_photo( photo=qr_photo, caption=message_text, reply_markup=keyboard, @@ -432,39 +360,12 @@ async def process_yookassa_sbp_payment_amount( ) else: # Если QR-код недоступен, отправляем обычное текстовое сообщение - invoice_message = await message.answer( + await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML" ) - - try: - from app.services import payment_service as payment_module - - payment = await payment_module.get_yookassa_payment_by_local_id( - db, payment_result["local_payment_id"] - ) - if payment: - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, - } - await db.execute( - update(payment.__class__) - .where(payment.__class__.id == payment.id) - .values(metadata_json=metadata, updated_at=datetime.utcnow()) - ) - await db.commit() - except Exception as error: # pragma: no cover - диагностический лог - logger.warning("Не удалось сохранить сообщение YooKassa (СБП): %s", error) - - await state.update_data( - yookassa_invoice_message_id=invoice_message.message_id, - yookassa_invoice_chat_id=invoice_message.chat.id, - ) - - await state.clear() + logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 48356450..4b49f51f 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,10 +1,10 @@ import logging from decimal import Decimal, ROUND_HALF_UP from aiogram import Dispatcher, types, F -from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.database.models import User from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id @@ -18,9 +18,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): texts = get_texts(DEFAULT_LANGUAGE) try: - logger.info( - f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}" - ) + logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}") allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_") @@ -37,7 +35,6 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): try: from app.database.database import get_db - async for db in get_db(): user = await get_user_by_telegram_id(db, query.from_user.id) if not user: @@ -80,7 +77,6 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): async def handle_successful_payment( message: types.Message, db: AsyncSession, - state: FSMContext, **kwargs ): texts = get_texts(DEFAULT_LANGUAGE) @@ -110,27 +106,6 @@ async def handle_successful_payment( return payment_service = PaymentService(message.bot) - - state_data = await state.get_data() - prompt_message_id = state_data.get("stars_prompt_message_id") - prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) - invoice_message_id = state_data.get("stars_invoice_message_id") - invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id) - - for chat_id, message_id, label in [ - (prompt_chat_id, prompt_message_id, "запрос суммы"), - (invoice_chat_id, invoice_message_id, "инвойс Stars"), - ]: - if message_id: - try: - await message.bot.delete_message(chat_id, message_id) - except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning( - "Не удалось удалить сообщение %s после оплаты Stars: %s", - label, - delete_error, - ) - success = await payment_service.process_stars_payment( db=db, user_id=user.id, @@ -138,14 +113,7 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - - await state.update_data( - stars_prompt_message_id=None, - stars_prompt_chat_id=None, - stars_invoice_message_id=None, - stars_invoice_chat_id=None, - ) - + if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) @@ -204,15 +172,15 @@ async def handle_successful_payment( def register_stars_handlers(dp: Dispatcher): - + dp.pre_checkout_query.register( handle_pre_checkout_query, - F.currency == "XTR" + F.currency == "XTR" ) - + dp.message.register( handle_successful_payment, F.successful_payment ) - + logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index c347cddf..dc474150 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1023,6 +1023,7 @@ "PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions", "PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.", "PAL24_PAYMENT_INSTRUCTIONS": "🏦 PayPalych (SBP) payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", + "PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)", "PAL24_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)", "PAL24_SELECT_PAYMENT_METHOD": "Choose a PayPalych payment method:", "PAL24_TOPUP_PROMPT": "🏦 PayPalych (SBP) payment\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.", @@ -1060,8 +1061,12 @@ "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", + "PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket", + "PAYMENT_METHOD_HELEKET_NAME": "🪙 Cryptocurrency (Heleket)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}", "PAYMENT_METHOD_MULENPAY_NAME": "💳 Bank card ({mulenpay_name})", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System", + "PAYMENT_METHOD_PAL24_NAME": "🏦 SBP (PayPalych)", "PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)", "PAYMENT_METHOD_PLATEGA_NAME": "💳 Bank card (Platega)", "PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient", @@ -1070,6 +1075,8 @@ "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", + "PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA", + "PAYMENT_METHOD_WATA_NAME": "💳 Bank card (WATA)", "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Bank card", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index a77f47fb..da8b89f9 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1043,6 +1043,7 @@ "PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы", "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych (СБП)\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)", "PAL24_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)", "PAL24_SELECT_PAYMENT_METHOD": "Выберите способ оплаты PayPalych:", "PAL24_TOPUP_PROMPT": "🏦 Оплата через PayPalych (СБП)\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.", @@ -1080,8 +1081,12 @@ "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", + "PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket", + "PAYMENT_METHOD_HELEKET_NAME": "🪙 Криптовалюта (Heleket)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}", "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банковская карта ({mulenpay_name})", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей", + "PAYMENT_METHOD_PAL24_NAME": "🏦 СБП (PayPalych)", "PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)", "PAYMENT_METHOD_PLATEGA_NAME": "💳 Банковская карта (Platega)", "PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно", @@ -1090,6 +1095,8 @@ "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", + "PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA", + "PAYMENT_METHOD_WATA_NAME": "💳 Банковская карта (WATA)", "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банковская карта", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 68897290..4c8fb4b9 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -1039,11 +1039,12 @@ "PAL24_INSTRUCTION_BUTTON": "{step}. Натисніть кнопку «{button}»", "PAL24_INSTRUCTION_COMPLETE": "{step}. Кошти зарахуються автоматично", "PAL24_INSTRUCTION_CONFIRM": "{step}. Підтвердіть переказ", - "PAL24_INSTRUCTION_FOLLOW": "{step}. Дотримуйтесь підказок платіжної системи", - "PAL24_PAYMENT_ERROR": "❌ Помилка створення платежу PayPalych. Спробуйте пізніше або зверніться до підтримки.", - "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych (СБП)\n\n💰 Сума: {amount}\n🆔 ID рахунку: {bill_id}\n\n📱 Інструкція:\n1. Натисніть кнопку ‘Оплатити через PayPalych (СБП)’\n2. Дотримуйтесь підказок платіжної системи\n3. Підтвердіть переказ\n4. Кошти зарахуються автоматично\n\n❓ Якщо виникнуть проблеми, зверніться до {support}", - "PAL24_SBP_PAY_BUTTON": "🏦 Оплатити через PayPalych (СБП)", - "PAL24_SELECT_PAYMENT_METHOD": "Оберіть спосіб оплати PayPalych:", + "PAL24_INSTRUCTION_FOLLOW": "{step}. Дотримуйтесь підказок платіжної системи", + "PAL24_PAYMENT_ERROR": "❌ Помилка створення платежу PayPalych. Спробуйте пізніше або зверніться до підтримки.", + "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych (СБП)\n\n💰 Сума: {amount}\n🆔 ID рахунку: {bill_id}\n\n📱 Інструкція:\n1. Натисніть кнопку ‘Оплатити через PayPalych (СБП)’\n2. Дотримуйтесь підказок платіжної системи\n3. Підтвердіть переказ\n4. Кошти зарахуються автоматично\n\n❓ Якщо виникнуть проблеми, зверніться до {support}", + "PAL24_PAY_BUTTON": "🏦 Оплатити через PayPalych (СБП)", + "PAL24_SBP_PAY_BUTTON": "🏦 Оплатити через PayPalych (СБП)", + "PAL24_SELECT_PAYMENT_METHOD": "Оберіть спосіб оплати PayPalych:", "PAL24_TOPUP_PROMPT": "🏦 Оплата через PayPalych (СБП)\n\nВведіть суму для поповнення від 100 до 1 000 000 ₽.\nОплата проходить через систему швидких платежів PayPalych.", "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способи оплати тимчасово недоступні", "PAYMENT_CARD_MULENPAY": "💳 Банківська картка ({mulenpay_name})", @@ -1079,18 +1080,24 @@ "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ На даний момент автоматичні способи оплати тимчасово недоступні. Для поповнення балансу зверніться до техпідтримки.", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", -"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}", -"PAYMENT_METHOD_MULENPAY_NAME": "💳 Банківська картка ({mulenpay_name})", -"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (картки + СБП)", -"PAYMENT_METHOD_PLATEGA_NAME": "💳 Банківська картка (Platega)", + "PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket", + "PAYMENT_METHOD_HELEKET_NAME": "🪙 Криптовалюта (Heleket)", + "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}", + "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банківська картка ({mulenpay_name})", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему швидких платежів", + "PAYMENT_METHOD_PAL24_NAME": "🏦 СБП (PayPalych)", + "PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (картки + СБП)", + "PAYMENT_METHOD_PLATEGA_NAME": "💳 Банківська картка (Platega)", "PAYMENT_METHOD_STARS_DESCRIPTION": "швидко та зручно", "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "інші способи", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через підтримку", -"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", -"PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банківська картка", -"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", -"PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банківська картка", + "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", + "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банківська картка", + "PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA", + "PAYMENT_METHOD_WATA_NAME": "💳 Банківська картка (WATA)", + "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", + "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банківська картка", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему швидких платежів YooKassa", "PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 СБП (YooKassa)", "PAYMENT_HELEKET_MARKUP_LABEL": "Націнка провайдера", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 3c0b906f..84210ac8 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -1042,6 +1042,7 @@ "PAL24_INSTRUCTION_FOLLOW":"{step}.按照支付系统提示操作", "PAL24_PAYMENT_ERROR":"❌创建PayPalych付款失败。请稍后再试或联系支持。", "PAL24_PAYMENT_INSTRUCTIONS":"🏦通过PayPalych(SBP)付款\n\n💰金额:{amount}\n🆔账单ID:{bill_id}\n\n📱说明:\n1.点击“通过PayPalych(SBP)付款”按钮\n2.按照支付系统提示操作\n3.确认转账\n4.资金将自动到账\n\n❓如果遇到问题,请联系{support}", +"PAL24_PAY_BUTTON":"🏦通过PayPalych(SBP)付款", "PAL24_SBP_PAY_BUTTON":"🏦通过PayPalych(SBP)付款", "PAL24_SELECT_PAYMENT_METHOD":"请选择PayPalych支付方式:", "PAL24_TOPUP_PROMPT":"🏦通过PayPalych(SBP)付款\n\n请输入充值金额,范围100至1000000₽。\n付款通过PayPalych快速支付系统进行。", @@ -1079,8 +1080,12 @@ "PAYMENT_METHODS_UNAVAILABLE_ALERT":"⚠️目前自动支付方式暂时不可用。如需充值余额,请联系技术支持。", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION":"通过CryptoBot", "PAYMENT_METHOD_CRYPTOBOT_NAME":"🪙加密货币", +"PAYMENT_METHOD_HELEKET_DESCRIPTION":"通过Heleket", +"PAYMENT_METHOD_HELEKET_NAME":"🪙加密货币(Heleket)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION":"通过{mulenpay_name}", "PAYMENT_METHOD_MULENPAY_NAME":"💳银行卡({mulenpay_name})", +"PAYMENT_METHOD_PAL24_DESCRIPTION":"通过快速支付系统", +"PAYMENT_METHOD_PAL24_NAME":"🏦SBP(PayPalych)", "PAYMENT_METHOD_PLATEGA_DESCRIPTION":"通过Platega(银行卡+SBP)", "PAYMENT_METHOD_PLATEGA_NAME":"💳银行卡(Platega)", "PAYMENT_METHOD_STARS_DESCRIPTION":"快速便捷", @@ -1089,6 +1094,8 @@ "PAYMENT_METHOD_SUPPORT_NAME":"🛠️通过支持", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION":"通过Tribute", "PAYMENT_METHOD_TRIBUTE_NAME":"💳银行卡", +"PAYMENT_METHOD_WATA_DESCRIPTION":"通过WATA", +"PAYMENT_METHOD_WATA_NAME":"💳银行卡(WATA)", "PAYMENT_METHOD_YOOKASSA_DESCRIPTION":"通过YooKassa", "PAYMENT_METHOD_YOOKASSA_NAME":"💳银行卡", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION":"通过YooKassa快速支付系统", diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index 27d24a2b..c032f990 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -255,42 +255,6 @@ class HeleketPaymentMixin: if updated_payment is None: return None - metadata = dict(getattr(updated_payment, "metadata_json", {}) or {}) - invoice_message = metadata.get("invoice_message") or {} - invoice_message_removed = False - - if getattr(self, "bot", None) and invoice_message: - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") - if chat_id and message_id: - try: - await self.bot.delete_message(chat_id, message_id) - except Exception as delete_error: # pragma: no cover - depends on rights - logger.warning( - "Не удалось удалить счёт Heleket %s: %s", - message_id, - delete_error, - ) - else: - metadata.pop("invoice_message", None) - invoice_message_removed = True - - if invoice_message_removed: - try: - from app.database.crud import heleket as heleket_crud - - await heleket_crud.update_heleket_payment( - db, - updated_payment.uuid, - metadata=metadata, - ) - updated_payment.metadata_json = metadata - except Exception as error: # pragma: no cover - diagnostics - logger.warning( - "Не удалось обновить метаданные Heleket после удаления счёта: %s", - error, - ) - if updated_payment.transaction_id: logger.info( "Heleket платеж %s уже связан с транзакцией %s", diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 27dea36d..7b4ffcf3 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -182,43 +182,7 @@ class MulenPayPaymentMixin: ) return False - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - invoice_message = metadata.get("invoice_message") or {} - - invoice_message_removed = False - - if getattr(self, "bot", None): - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") - if chat_id and message_id: - try: - await self.bot.delete_message(chat_id, message_id) - except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning( - "Не удалось удалить %s счёт %s: %s", - display_name, - message_id, - delete_error, - ) - else: - metadata.pop("invoice_message", None) - invoice_message_removed = True - if payment.is_paid: - if invoice_message_removed: - try: - await payment_module.update_mulenpay_payment_metadata( - db, - payment=payment, - metadata=metadata, - ) - except Exception as error: # pragma: no cover - diagnostics - logger.warning( - "Не удалось обновить метаданные %s после удаления счёта: %s", - display_name, - error, - ) - logger.info( "%s платеж %s уже обработан, игнорируем повторный callback", display_name, @@ -233,7 +197,6 @@ class MulenPayPaymentMixin: status="success", callback_payload=callback_data, mulen_payment_id=mulen_payment_id_int, - metadata=metadata, ) if payment.transaction_id: diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 460e0e31..6dbe3dc5 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -333,41 +333,6 @@ class Pal24PaymentMixin: payment_module = import_module("app.services.payment_service") - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - invoice_message = metadata.get("invoice_message") or {} - invoice_message_removed = False - - if getattr(self, "bot", None) and invoice_message: - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") - if chat_id and message_id: - try: - await self.bot.delete_message(chat_id, message_id) - except Exception as delete_error: # pragma: no cover - depends on rights - logger.warning( - "Не удалось удалить счёт PayPalych %s: %s", - message_id, - delete_error, - ) - else: - metadata.pop("invoice_message", None) - invoice_message_removed = True - - if invoice_message_removed: - try: - await payment_module.update_pal24_payment_status( - db, - payment, - status=payment.status, - metadata=metadata, - ) - payment.metadata_json = metadata - except Exception as error: # pragma: no cover - diagnostics - logger.warning( - "Не удалось обновить метаданные PayPalych после удаления счёта: %s", - error, - ) - if payment.transaction_id: logger.info( "Pal24 платеж %s уже привязан к транзакции (trigger=%s)", diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index 2d08997a..fc7fe4d8 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -306,22 +306,6 @@ class PlategaPaymentMixin: metadata = dict(getattr(payment, "metadata_json", {}) or {}) balance_already_credited = bool(metadata.get("balance_credited")) - invoice_message = metadata.get("invoice_message") or {} - if getattr(self, "bot", None): - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") - if chat_id and message_id: - try: - await self.bot.delete_message(chat_id, message_id) - except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning( - "Не удалось удалить Platega счёт %s: %s", - message_id, - delete_error, - ) - else: - metadata.pop("invoice_message", None) - if payment.transaction_id: logger.info( "Platega платеж %s уже связан с транзакцией %s", diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index 3f1022d7..b7fd5942 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,6 +515,36 @@ class TelegramStarsMixin: exc_info=True, ) + if getattr(self, "bot", None): + try: + keyboard = await self.build_topup_success_keyboard(user) + + charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] + + await self.bot.send_message( + user.telegram_id, + ( + "✅ Пополнение успешно!\n\n" + f"⭐ Звезд: {stars_amount}\n" + f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" + "🦊 Способ: Telegram Stars\n" + f"🆔 Транзакция: {charge_id_short}...\n\n" + "Баланс пополнен автоматически!" + ), + parse_mode="HTML", + reply_markup=keyboard, + ) + logger.info( + "✅ Отправлено уведомление пользователю %s о пополнении на %s", + user.telegram_id, + settings.format_price(amount_kopeks), + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.error( + "Ошибка отправки уведомления о пополнении Stars: %s", + error, + ) + # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index 7072d096..d8bc789a 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -415,25 +415,6 @@ class WataPaymentMixin: if not paid_at and getattr(payment, "paid_at", None): paid_at = payment.paid_at existing_metadata = dict(getattr(payment, "metadata_json", {}) or {}) - - invoice_message = existing_metadata.get("invoice_message") or {} - invoice_message_removed = False - if getattr(self, "bot", None) and invoice_message: - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") - if chat_id and message_id: - try: - await self.bot.delete_message(chat_id, message_id) - except Exception as delete_error: # pragma: no cover - depends on rights - logger.warning( - "Не удалось удалить счёт WATA %s: %s", - message_id, - delete_error, - ) - else: - invoice_message_removed = True - existing_metadata.pop("invoice_message", None) - existing_metadata["transaction"] = transaction_payload await payment_module.update_wata_payment_status( diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 465664a6..181ab94d 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -383,19 +383,11 @@ class YooKassaPaymentMixin: payment_module = import_module("app.services.payment_service") # Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования) - get_transaction_by_external_id = getattr( - payment_module, "get_transaction_by_external_id", None + existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] + db, + payment.yookassa_payment_id, + PaymentMethod.YOOKASSA, ) - existing_transaction = None - if get_transaction_by_external_id: - try: - existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined] - db, - payment.yookassa_payment_id, - PaymentMethod.YOOKASSA, - ) - except AttributeError: - logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов") if existing_transaction: # Если транзакция уже существует, просто завершаем обработку @@ -445,22 +437,6 @@ class YooKassaPaymentMixin: except Exception as parse_error: logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}") - invoice_message = payment_metadata.get("invoice_message") or {} - if getattr(self, "bot", None): - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") - if chat_id and message_id: - try: - await self.bot.delete_message(chat_id, message_id) - except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning( - "Не удалось удалить сообщение YooKassa %s: %s", - message_id, - delete_error, - ) - else: - payment_metadata.pop("invoice_message", None) - processing_completed = bool(payment_metadata.get("processing_completed")) transaction = None @@ -496,20 +472,11 @@ class YooKassaPaymentMixin: ) if transaction is None: - get_transaction_by_external_id = getattr( - payment_module, "get_transaction_by_external_id", None + existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] + db, + payment.yookassa_payment_id, + PaymentMethod.YOOKASSA, ) - existing_transaction = None - - if get_transaction_by_external_id: - try: - existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined] - db, - payment.yookassa_payment_id, - PaymentMethod.YOOKASSA, - ) - except AttributeError: - logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов") if existing_transaction: # Если транзакция уже существует, пропускаем обработку diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 949f7b23..9f417d73 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -112,11 +112,6 @@ async def update_mulenpay_payment_status(*args, **kwargs): return await mulenpay_crud.update_mulenpay_payment_status(*args, **kwargs) -async def update_mulenpay_payment_metadata(*args, **kwargs): - mulenpay_crud = import_module("app.database.crud.mulenpay") - return await mulenpay_crud.update_mulenpay_payment_metadata(*args, **kwargs) - - async def link_mulenpay_payment_to_transaction(*args, **kwargs): mulenpay_crud = import_module("app.database.crud.mulenpay") return await mulenpay_crud.link_mulenpay_payment_to_transaction(*args, **kwargs) diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index 3a6d8c07..bef09a91 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -23,30 +23,10 @@ logger = logging.getLogger(__name__) class TributeService: - _invoice_messages: Dict[int, Dict[str, int]] = {} def __init__(self, bot: Bot): self.bot = bot self.tribute_api = TributeAPI() - - @classmethod - def remember_invoice_message(cls, user_id: int, chat_id: int, message_id: int) -> None: - cls._invoice_messages[user_id] = {"chat_id": chat_id, "message_id": message_id} - - async def _cleanup_invoice_message(self, user_id: int) -> None: - invoice_message = self._invoice_messages.pop(user_id, None) - if not invoice_message or not getattr(self, "bot", None): - return - - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") - if not chat_id or not message_id: - return - - try: - await self.bot.delete_message(chat_id, message_id) - except Exception as error: # pragma: no cover - depends on bot rights - logger.warning("Не удалось удалить Tribute счёт %s: %s", message_id, error) async def create_payment_link( self, @@ -194,8 +174,7 @@ class TributeService: ) except Exception as e: logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}") - - await self._cleanup_invoice_message(user_telegram_id) + await self._send_success_notification(user_telegram_id, amount_kopeks) logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}") From 6dc525dd721981ca5d33f263bf20e4ff1c79d5a0 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 21 Nov 2025 05:26:42 +0300 Subject: [PATCH 20/20] Handle missing YooKassa payment ids gracefully --- app/database/crud/mulenpay.py | 17 ++++ app/database/crud/pal24.py | 3 + app/external/yookassa_webhook.py | 18 ++-- app/handlers/balance/heleket.py | 55 +++++++++- app/handlers/balance/mulenpay.py | 55 +++++++++- app/handlers/balance/pal24.py | 52 +++++++++- app/handlers/balance/platega.py | 47 ++++++++- app/handlers/balance/stars.py | 66 ++++++++---- app/handlers/balance/tribute.py | 52 ++++++---- app/handlers/balance/wata.py | 52 +++++++++- app/handlers/balance/yookassa.py | 129 +++++++++++++++++++++--- app/handlers/stars_payments.py | 46 +++++++-- app/localization/locales/en.json | 7 -- app/localization/locales/ru.json | 7 -- app/localization/locales/ua.json | 33 +++--- app/localization/locales/zh.json | 7 -- app/services/payment/heleket.py | 36 +++++++ app/services/payment/mulenpay.py | 37 +++++++ app/services/payment/pal24.py | 35 +++++++ app/services/payment/platega.py | 16 +++ app/services/payment/stars.py | 30 ------ app/services/payment/wata.py | 19 ++++ app/services/payment/yookassa.py | 49 +++++++-- app/services/payment_service.py | 5 + app/services/tribute_service.py | 23 ++++- tests/external/test_yookassa_webhook.py | 18 ++-- 26 files changed, 745 insertions(+), 169 deletions(-) diff --git a/app/database/crud/mulenpay.py b/app/database/crud/mulenpay.py index 17648f4a..062dcaab 100644 --- a/app/database/crud/mulenpay.py +++ b/app/database/crud/mulenpay.py @@ -3,6 +3,7 @@ from datetime import datetime from typing import Optional from sqlalchemy import select +from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -90,6 +91,7 @@ async def update_mulenpay_payment_status( paid_at: Optional[datetime] = None, callback_payload: Optional[dict] = None, mulen_payment_id: Optional[int] = None, + metadata: Optional[dict] = None, ) -> MulenPayPayment: payment.status = status if is_paid is not None: @@ -100,6 +102,8 @@ async def update_mulenpay_payment_status( payment.callback_payload = callback_payload if mulen_payment_id is not None and not payment.mulen_payment_id: payment.mulen_payment_id = mulen_payment_id + if metadata is not None: + payment.metadata_json = metadata payment.updated_at = datetime.utcnow() await db.commit() @@ -107,6 +111,19 @@ async def update_mulenpay_payment_status( return payment +async def update_mulenpay_payment_metadata( + db: AsyncSession, + *, + payment: MulenPayPayment, + metadata: dict, +) -> MulenPayPayment: + payment.metadata_json = metadata + payment.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(payment) + return payment + + async def link_mulenpay_payment_to_transaction( db: AsyncSession, *, diff --git a/app/database/crud/pal24.py b/app/database/crud/pal24.py index a88c46b4..35cffa61 100644 --- a/app/database/crud/pal24.py +++ b/app/database/crud/pal24.py @@ -96,6 +96,7 @@ async def update_pal24_payment_status( balance_currency: Optional[str] = None, payer_account: Optional[str] = None, callback_payload: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> Pal24Payment: update_values: Dict[str, Any] = { "status": status, @@ -121,6 +122,8 @@ async def update_pal24_payment_status( update_values["payer_account"] = payer_account if callback_payload is not None: update_values["callback_payload"] = callback_payload + if metadata is not None: + update_values["metadata_json"] = metadata update_values["last_status"] = status diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index 0c6485b5..6ea1811d 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -254,17 +254,17 @@ class YooKassaWebhookHandler: logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}") logger.debug(f"🔍 Полные данные webhook: {webhook_data}") - # Извлекаем ID платежа из вебхука для предотвращения дублирования - yookassa_payment_id = webhook_data.get("object", {}).get("id") - if not yookassa_payment_id: - logger.warning("⚠️ Webhook YooKassa без ID платежа") - return web.Response(status=400, text="No payment ID") - event_type = webhook_data.get("event") if not event_type: logger.warning("⚠️ Webhook YooKassa без типа события") return web.Response(status=400, text="No event type") + # Извлекаем ID платежа из вебхука для предотвращения дублирования + yookassa_payment_id = webhook_data.get("object", {}).get("id") + if not yookassa_payment_id: + logger.warning("⚠️ Webhook YooKassa без ID платежа") + return web.Response(status=400, text="No payment id") + if event_type not in YOOKASSA_ALLOWED_EVENTS: logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}") return web.Response(status=200, text="OK") @@ -274,8 +274,10 @@ class YooKassaWebhookHandler: # Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования) from app.database.models import PaymentMethod from app.database.crud.transaction import get_transaction_by_external_id - existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA) - + existing_transaction = None + if yookassa_payment_id and hasattr(db, "execute"): + existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA) + if existing_transaction and event_type == "payment.succeeded": logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.") return web.Response(status=200, text="OK") diff --git a/app/handlers/balance/heleket.py b/app/handlers/balance/heleket.py index 37ac4b53..f97aa1b2 100644 --- a/app/handlers/balance/heleket.py +++ b/app/handlers/balance/heleket.py @@ -1,8 +1,10 @@ import logging +from datetime import datetime from typing import Optional from aiogram import types from aiogram.fsm.context import FSMContext +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -66,7 +68,11 @@ async def start_heleket_payment( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="heleket") + await state.update_data( + payment_method="heleket", + heleket_prompt_message_id=callback.message.message_id, + heleket_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -181,7 +187,52 @@ async def process_heleket_payment_amount( [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], ]) - await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard) + state_data = await state.get_data() + prompt_message_id = state_data.get("heleket_prompt_message_id") + prompt_chat_id = state_data.get("heleket_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning("Не удалось удалить сообщение с суммой Heleket: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - diagnostic + logger.warning( + "Не удалось удалить сообщение с запросом суммы Heleket: %s", + delete_error, + ) + + invoice_message = await message.answer( + "\n".join(details), parse_mode="HTML", reply_markup=keyboard + ) + + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_heleket_payment_by_id(db, result["local_payment_id"]) + if payment: + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await db.execute( + update(payment.__class__) + .where(payment.__class__.id == payment.id) + .values(metadata_json=metadata, updated_at=datetime.utcnow()) + ) + await db.commit() + except Exception as error: # pragma: no cover - diagnostics + logger.warning("Не удалось сохранить сообщение Heleket: %s", error) + + await state.update_data( + heleket_invoice_message_id=invoice_message.message_id, + heleket_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() diff --git a/app/handlers/balance/mulenpay.py b/app/handlers/balance/mulenpay.py index 840ac469..f345abeb 100644 --- a/app/handlers/balance/mulenpay.py +++ b/app/handlers/balance/mulenpay.py @@ -59,7 +59,11 @@ async def start_mulenpay_payment( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="mulenpay") + await state.update_data( + payment_method="mulenpay", + mulenpay_prompt_message_id=callback.message.message_id, + mulenpay_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -93,6 +97,26 @@ async def process_mulenpay_payment_amount( amount_rubles = amount_kopeks / 100 + state_data = await state.get_data() + prompt_message_id = state_data.get("mulenpay_prompt_message_id") + prompt_chat_id = state_data.get("mulenpay_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - depends on bot permissions + logger.warning( + "Не удалось удалить сообщение с суммой MulenPay: %s", delete_error + ) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - diagnostic + logger.warning( + "Не удалось удалить сообщение с запросом суммы MulenPay: %s", + delete_error, + ) + try: payment_service = PaymentService(message.bot) payment_result = await payment_service.create_mulenpay_payment( @@ -163,12 +187,39 @@ async def process_mulenpay_payment_amount( mulenpay_name_html=mulenpay_name_html, ) - await message.answer( + invoice_message = await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML", ) + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_mulenpay_payment_by_local_id( + db, local_payment_id + ) + if payment: + payment_metadata = dict( + getattr(payment, "metadata_json", {}) or {} + ) + payment_metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await payment_module.update_mulenpay_payment_metadata( + db, + payment=payment, + metadata=payment_metadata, + ) + except Exception as error: # pragma: no cover - diagnostic logging only + logger.warning("Не удалось сохранить данные сообщения MulenPay: %s", error) + + await state.update_data( + mulenpay_invoice_message_id=invoice_message.message_id, + mulenpay_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() logger.info( diff --git a/app/handlers/balance/pal24.py b/app/handlers/balance/pal24.py index 0a66b20f..28eda7f9 100644 --- a/app/handlers/balance/pal24.py +++ b/app/handlers/balance/pal24.py @@ -1,10 +1,12 @@ import html import logging +from datetime import datetime from typing import Any, Optional from aiogram import types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -204,12 +206,36 @@ async def _send_pal24_payment_message( support=settings.get_support_contact_display_html(), ) - await message.answer( + invoice_message = await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML", ) + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id) + if payment: + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await db.execute( + update(payment.__class__) + .where(payment.__class__.id == payment.id) + .values(metadata_json=metadata, updated_at=datetime.utcnow()) + ) + await db.commit() + except Exception as error: # pragma: no cover - diagnostics + logger.warning("Не удалось сохранить сообщение PayPalych: %s", error) + + await state.update_data( + pal24_invoice_message_id=invoice_message.message_id, + pal24_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() logger.info( @@ -277,7 +303,11 @@ async def start_pal24_payment( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="pal24") + await state.update_data( + payment_method="pal24", + pal24_prompt_message_id=callback.message.message_id, + pal24_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -309,6 +339,24 @@ async def process_pal24_payment_amount( available_methods = _get_available_pal24_methods() + state_data = await state.get_data() + prompt_message_id = state_data.get("pal24_prompt_message_id") + prompt_chat_id = state_data.get("pal24_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning("Не удалось удалить сообщение с суммой PayPalych: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - diagnostic + logger.warning( + "Не удалось удалить сообщение с запросом суммы PayPalych: %s", + delete_error, + ) + if len(available_methods) == 1: await _send_pal24_payment_message( message, diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py index 40e8c45f..def18f65 100644 --- a/app/handlers/balance/platega.py +++ b/app/handlers/balance/platega.py @@ -98,6 +98,10 @@ async def _prompt_amount( ) await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data( + platega_prompt_message_id=message.message_id, + platega_prompt_chat_id=message.chat.id, + ) @error_handler @@ -300,7 +304,25 @@ async def process_platega_payment_amount( ), ) - await message.answer( + state_data = await state.get_data() + prompt_message_id = state_data.get("platega_prompt_message_id") + prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы Platega: %s", + delete_error, + ) + + invoice_message = await message.answer( instructions_template.format( method=method_title, amount=settings.format_price(amount_kopeks), @@ -311,6 +333,29 @@ async def process_platega_payment_amount( parse_mode="HTML", ) + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_platega_payment_by_id(db, local_payment_id) + if payment: + payment_metadata = dict(getattr(payment, "metadata_json", {}) or {}) + payment_metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await payment_module.update_platega_payment( + db, + payment=payment, + metadata=payment_metadata, + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.warning("Не удалось сохранить данные сообщения Platega: %s", error) + + await state.update_data( + platega_invoice_message_id=invoice_message.message_id, + platega_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index 0dcf0031..a8cac0f8 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,11 +1,10 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext -from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User -from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard +from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts from app.services.payment_service import PaymentService from app.states import BalanceStates @@ -22,11 +21,11 @@ async def start_stars_payment( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) return - + # Формируем текст сообщения в зависимости от настройки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: message_text = ( @@ -35,10 +34,10 @@ async def start_stars_payment( ) else: message_text = texts.TOP_UP_AMOUNT - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons @@ -46,12 +45,17 @@ async def start_stars_payment( if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard ) - + + await state.update_data( + stars_prompt_message_id=callback.message.message_id, + stars_prompt_chat_id=callback.message.chat.id, + ) + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="stars") await callback.answer() @@ -65,29 +69,48 @@ async def process_stars_payment_amount( state: FSMContext ): texts = get_texts(db_user.language) - + if not settings.TELEGRAM_STARS_ENABLED: await message.answer("⚠️ Оплата Stars временно недоступна") return - + try: amount_rubles = amount_kopeks / 100 stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles) - stars_rate = settings.get_stars_rate() - + stars_rate = settings.get_stars_rate() + payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", payload=f"balance_{db_user.id}_{amount_kopeks}" ) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - - await message.answer( + + state_data = await state.get_data() + + prompt_message_id = state_data.get("stars_prompt_message_id") + prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы Stars: %s", + delete_error, + ) + + invoice_message = await message.answer( f"⭐ Оплата через Telegram Stars\n\n" f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" f"⭐ К оплате: {stars_amount} звезд\n" @@ -96,9 +119,14 @@ async def process_stars_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - - await state.clear() - + + await state.update_data( + stars_invoice_message_id=invoice_message.message_id, + stars_invoice_chat_id=invoice_message.chat.id, + ) + + await state.set_state(None) + except Exception as e: logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") \ No newline at end of file + await message.answer("⚠️ Ошибка создания платежа") diff --git a/app/handlers/balance/tribute.py b/app/handlers/balance/tribute.py index e6d24015..f96538a5 100644 --- a/app/handlers/balance/tribute.py +++ b/app/handlers/balance/tribute.py @@ -13,47 +13,55 @@ logger = logging.getLogger(__name__) @error_handler async def start_tribute_payment( callback: types.CallbackQuery, - db_user: User + db_user: User, ): texts = get_texts(db_user.language) - + if not settings.TRIBUTE_ENABLED: await callback.answer("❌ Оплата картой временно недоступна", show_alert=True) return - + try: from app.services.tribute_service import TributeService - + tribute_service = TributeService(callback.bot) payment_url = await tribute_service.create_payment_link( user_id=db_user.telegram_id, amount_kopeks=0, - description="Пополнение баланса VPN" + description="Пополнение баланса VPN", ) - + if not payment_url: await callback.answer("❌ Ошибка создания платежа", show_alert=True) return - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + ] + ) + await callback.message.edit_text( - f"💳 Пополнение банковской картой\n\n" - f"• Введите любую сумму от 100₽\n" - f"• Безопасная оплата через Tribute\n" - f"• Мгновенное зачисление на баланс\n" - f"• Принимаем карты Visa, MasterCard, МИР\n\n" - f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n" + f"💳 Пополнение банковской картой\n\n", + f"• Введите любую сумму от 100₽\n", + f"• Безопасная оплата через Tribute\n", + f"• Мгновенное зачисление на баланс\n", + f"• Принимаем карты Visa, MasterCard, МИР\n\n", + f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n", f"Нажмите кнопку для перехода к оплате:", reply_markup=keyboard, - parse_mode="HTML" + parse_mode="HTML", ) - + + TributeService.remember_invoice_message( + db_user.telegram_id, + callback.message.chat.id, + callback.message.message_id, + ) + except Exception as e: logger.error(f"Ошибка создания Tribute платежа: {e}") await callback.answer("❌ Ошибка создания платежа", show_alert=True) - - await callback.answer() \ No newline at end of file + + await callback.answer() diff --git a/app/handlers/balance/wata.py b/app/handlers/balance/wata.py index 259d559e..fdd2327e 100644 --- a/app/handlers/balance/wata.py +++ b/app/handlers/balance/wata.py @@ -1,8 +1,10 @@ import logging +from datetime import datetime from typing import Dict from aiogram import types from aiogram.fsm.context import FSMContext +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -56,7 +58,11 @@ async def start_wata_payment( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="wata") + await state.update_data( + payment_method="wata", + wata_prompt_message_id=callback.message.message_id, + wata_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -159,12 +165,54 @@ async def process_wata_payment_amount( support=settings.get_support_contact_display_html(), ) - await message.answer( + state_data = await state.get_data() + prompt_message_id = state_data.get("wata_prompt_message_id") + prompt_chat_id = state_data.get("wata_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning("Не удалось удалить сообщение с суммой WATA: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - diagnostic + logger.warning( + "Не удалось удалить сообщение с запросом суммы WATA: %s", + delete_error, + ) + + invoice_message = await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML", ) + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_wata_payment_by_local_id(db, local_payment_id) + if payment: + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await db.execute( + update(payment.__class__) + .where(payment.__class__.id == payment.id) + .values(metadata_json=metadata, updated_at=datetime.utcnow()) + ) + await db.commit() + except Exception as error: # pragma: no cover - diagnostics + logger.warning("Не удалось сохранить сообщение WATA: %s", error) + + await state.update_data( + wata_invoice_message_id=invoice_message.message_id, + wata_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() logger.info( diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py index 607f62bd..fb6a345c 100644 --- a/app/handlers/balance/yookassa.py +++ b/app/handlers/balance/yookassa.py @@ -1,6 +1,9 @@ import logging +from datetime import datetime + from aiogram import types from aiogram.fsm.context import FSMContext +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -58,9 +61,13 @@ async def start_yookassa_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa") + await state.update_data( + yookassa_prompt_message_id=callback.message.message_id, + yookassa_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -108,9 +115,13 @@ async def start_yookassa_sbp_payment( reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data(payment_method="yookassa_sbp") + await state.update_data( + yookassa_prompt_message_id=callback.message.message_id, + yookassa_prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -172,7 +183,25 @@ async def process_yookassa_payment_amount( [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) - await message.answer( + state_data = await state.get_data() + prompt_message_id = state_data.get("yookassa_prompt_message_id") + prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы YooKassa: %s", + delete_error, + ) + + invoice_message = await message.answer( f"💳 Оплата банковской картой\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" @@ -187,9 +216,34 @@ async def process_yookassa_payment_amount( reply_markup=keyboard, parse_mode="HTML" ) - + + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_yookassa_payment_by_local_id( + db, payment_result["local_payment_id"] + ) + if payment: + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await db.execute( + update(payment.__class__) + .where(payment.__class__.id == payment.id) + .values(metadata_json=metadata, updated_at=datetime.utcnow()) + ) + await db.commit() + except Exception as error: # pragma: no cover - диагностический лог + logger.warning("Не удалось сохранить сообщение YooKassa: %s", error) + + await state.update_data( + yookassa_invoice_message_id=invoice_message.message_id, + yookassa_invoice_chat_id=invoice_message.chat.id, + ) + await state.clear() - logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") @@ -310,27 +364,45 @@ async def process_yookassa_sbp_payment_amount( # Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса keyboard_buttons = [] - + # Добавляем кнопку оплаты, если доступна ссылка if confirmation_url: keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)]) else: # Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")]) - + # Добавляем общие кнопки keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")]) keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) - + + state_data = await state.get_data() + prompt_message_id = state_data.get("yookassa_prompt_message_id") + prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) + + try: + await message.delete() + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error) + + if prompt_message_id: + try: + await message.bot.delete_message(prompt_chat_id, prompt_message_id) + except Exception as delete_error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s", + delete_error, + ) + # Подготавливаем текст сообщения message_text = ( f"🔗 Оплата через СБП\n\n" f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" ) - + # Добавляем инструкции в зависимости от доступных способов оплаты if not confirmation_url: message_text += ( @@ -341,18 +413,18 @@ async def process_yookassa_sbp_payment_amount( f"4. Подтвердите платеж в приложении банка\n" f"5. Деньги поступят на баланс автоматически\n\n" ) - + message_text += ( f"🔒 Оплата происходит через защищенную систему YooKassa\n" f"✅ Принимаем СБП от всех банков-участников\n\n" f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}" ) - + # Отправляем сообщение с инструкциями и клавиатурой # Если есть QR-код, отправляем его как медиа-сообщение if qr_photo: # Используем метод отправки медиа-группы или фото с описанием - await message.answer_photo( + invoice_message = await message.answer_photo( photo=qr_photo, caption=message_text, reply_markup=keyboard, @@ -360,12 +432,39 @@ async def process_yookassa_sbp_payment_amount( ) else: # Если QR-код недоступен, отправляем обычное текстовое сообщение - await message.answer( + invoice_message = await message.answer( message_text, reply_markup=keyboard, parse_mode="HTML" ) - + + try: + from app.services import payment_service as payment_module + + payment = await payment_module.get_yookassa_payment_by_local_id( + db, payment_result["local_payment_id"] + ) + if payment: + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + metadata["invoice_message"] = { + "chat_id": invoice_message.chat.id, + "message_id": invoice_message.message_id, + } + await db.execute( + update(payment.__class__) + .where(payment.__class__.id == payment.id) + .values(metadata_json=metadata, updated_at=datetime.utcnow()) + ) + await db.commit() + except Exception as error: # pragma: no cover - диагностический лог + logger.warning("Не удалось сохранить сообщение YooKassa (СБП): %s", error) + + await state.update_data( + yookassa_invoice_message_id=invoice_message.message_id, + yookassa_invoice_chat_id=invoice_message.chat.id, + ) + + await state.clear() logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: " f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 4b49f51f..48356450 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,10 +1,10 @@ import logging from decimal import Decimal, ROUND_HALF_UP from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import User from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id @@ -18,7 +18,9 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): texts = get_texts(DEFAULT_LANGUAGE) try: - logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}") + logger.info( + f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}" + ) allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_") @@ -35,6 +37,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): try: from app.database.database import get_db + async for db in get_db(): user = await get_user_by_telegram_id(db, query.from_user.id) if not user: @@ -77,6 +80,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): async def handle_successful_payment( message: types.Message, db: AsyncSession, + state: FSMContext, **kwargs ): texts = get_texts(DEFAULT_LANGUAGE) @@ -106,6 +110,27 @@ async def handle_successful_payment( return payment_service = PaymentService(message.bot) + + state_data = await state.get_data() + prompt_message_id = state_data.get("stars_prompt_message_id") + prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) + invoice_message_id = state_data.get("stars_invoice_message_id") + invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id) + + for chat_id, message_id, label in [ + (prompt_chat_id, prompt_message_id, "запрос суммы"), + (invoice_chat_id, invoice_message_id, "инвойс Stars"), + ]: + if message_id: + try: + await message.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - зависит от прав бота + logger.warning( + "Не удалось удалить сообщение %s после оплаты Stars: %s", + label, + delete_error, + ) + success = await payment_service.process_stars_payment( db=db, user_id=user.id, @@ -113,7 +138,14 @@ async def handle_successful_payment( payload=payment.invoice_payload, telegram_payment_charge_id=payment.telegram_payment_charge_id ) - + + await state.update_data( + stars_prompt_message_id=None, + stars_prompt_chat_id=None, + stars_invoice_message_id=None, + stars_invoice_chat_id=None, + ) + if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) @@ -172,15 +204,15 @@ async def handle_successful_payment( def register_stars_handlers(dp: Dispatcher): - + dp.pre_checkout_query.register( handle_pre_checkout_query, - F.currency == "XTR" + F.currency == "XTR" ) - + dp.message.register( handle_successful_payment, F.successful_payment ) - + logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index dc474150..c347cddf 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1023,7 +1023,6 @@ "PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions", "PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.", "PAL24_PAYMENT_INSTRUCTIONS": "🏦 PayPalych (SBP) payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", - "PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)", "PAL24_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)", "PAL24_SELECT_PAYMENT_METHOD": "Choose a PayPalych payment method:", "PAL24_TOPUP_PROMPT": "🏦 PayPalych (SBP) payment\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.", @@ -1061,12 +1060,8 @@ "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", - "PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket", - "PAYMENT_METHOD_HELEKET_NAME": "🪙 Cryptocurrency (Heleket)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}", "PAYMENT_METHOD_MULENPAY_NAME": "💳 Bank card ({mulenpay_name})", - "PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System", - "PAYMENT_METHOD_PAL24_NAME": "🏦 SBP (PayPalych)", "PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)", "PAYMENT_METHOD_PLATEGA_NAME": "💳 Bank card (Platega)", "PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient", @@ -1075,8 +1070,6 @@ "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", - "PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA", - "PAYMENT_METHOD_WATA_NAME": "💳 Bank card (WATA)", "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Bank card", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index da8b89f9..a77f47fb 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1043,7 +1043,6 @@ "PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы", "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych (СБП)\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", - "PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)", "PAL24_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)", "PAL24_SELECT_PAYMENT_METHOD": "Выберите способ оплаты PayPalych:", "PAL24_TOPUP_PROMPT": "🏦 Оплата через PayPalych (СБП)\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.", @@ -1081,12 +1080,8 @@ "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", - "PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket", - "PAYMENT_METHOD_HELEKET_NAME": "🪙 Криптовалюта (Heleket)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}", "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банковская карта ({mulenpay_name})", - "PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей", - "PAYMENT_METHOD_PAL24_NAME": "🏦 СБП (PayPalych)", "PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)", "PAYMENT_METHOD_PLATEGA_NAME": "💳 Банковская карта (Platega)", "PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно", @@ -1095,8 +1090,6 @@ "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", - "PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA", - "PAYMENT_METHOD_WATA_NAME": "💳 Банковская карта (WATA)", "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банковская карта", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 4c8fb4b9..68897290 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -1039,12 +1039,11 @@ "PAL24_INSTRUCTION_BUTTON": "{step}. Натисніть кнопку «{button}»", "PAL24_INSTRUCTION_COMPLETE": "{step}. Кошти зарахуються автоматично", "PAL24_INSTRUCTION_CONFIRM": "{step}. Підтвердіть переказ", - "PAL24_INSTRUCTION_FOLLOW": "{step}. Дотримуйтесь підказок платіжної системи", - "PAL24_PAYMENT_ERROR": "❌ Помилка створення платежу PayPalych. Спробуйте пізніше або зверніться до підтримки.", - "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych (СБП)\n\n💰 Сума: {amount}\n🆔 ID рахунку: {bill_id}\n\n📱 Інструкція:\n1. Натисніть кнопку ‘Оплатити через PayPalych (СБП)’\n2. Дотримуйтесь підказок платіжної системи\n3. Підтвердіть переказ\n4. Кошти зарахуються автоматично\n\n❓ Якщо виникнуть проблеми, зверніться до {support}", - "PAL24_PAY_BUTTON": "🏦 Оплатити через PayPalych (СБП)", - "PAL24_SBP_PAY_BUTTON": "🏦 Оплатити через PayPalych (СБП)", - "PAL24_SELECT_PAYMENT_METHOD": "Оберіть спосіб оплати PayPalych:", + "PAL24_INSTRUCTION_FOLLOW": "{step}. Дотримуйтесь підказок платіжної системи", + "PAL24_PAYMENT_ERROR": "❌ Помилка створення платежу PayPalych. Спробуйте пізніше або зверніться до підтримки.", + "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych (СБП)\n\n💰 Сума: {amount}\n🆔 ID рахунку: {bill_id}\n\n📱 Інструкція:\n1. Натисніть кнопку ‘Оплатити через PayPalych (СБП)’\n2. Дотримуйтесь підказок платіжної системи\n3. Підтвердіть переказ\n4. Кошти зарахуються автоматично\n\n❓ Якщо виникнуть проблеми, зверніться до {support}", + "PAL24_SBP_PAY_BUTTON": "🏦 Оплатити через PayPalych (СБП)", + "PAL24_SELECT_PAYMENT_METHOD": "Оберіть спосіб оплати PayPalych:", "PAL24_TOPUP_PROMPT": "🏦 Оплата через PayPalych (СБП)\n\nВведіть суму для поповнення від 100 до 1 000 000 ₽.\nОплата проходить через систему швидких платежів PayPalych.", "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способи оплати тимчасово недоступні", "PAYMENT_CARD_MULENPAY": "💳 Банківська картка ({mulenpay_name})", @@ -1080,24 +1079,18 @@ "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ На даний момент автоматичні способи оплати тимчасово недоступні. Для поповнення балансу зверніться до техпідтримки.", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", - "PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket", - "PAYMENT_METHOD_HELEKET_NAME": "🪙 Криптовалюта (Heleket)", - "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}", - "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банківська картка ({mulenpay_name})", - "PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему швидких платежів", - "PAYMENT_METHOD_PAL24_NAME": "🏦 СБП (PayPalych)", - "PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (картки + СБП)", - "PAYMENT_METHOD_PLATEGA_NAME": "💳 Банківська картка (Platega)", +"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}", +"PAYMENT_METHOD_MULENPAY_NAME": "💳 Банківська картка ({mulenpay_name})", +"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (картки + СБП)", +"PAYMENT_METHOD_PLATEGA_NAME": "💳 Банківська картка (Platega)", "PAYMENT_METHOD_STARS_DESCRIPTION": "швидко та зручно", "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "інші способи", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через підтримку", - "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", - "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банківська картка", - "PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA", - "PAYMENT_METHOD_WATA_NAME": "💳 Банківська картка (WATA)", - "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", - "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банківська картка", +"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", +"PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банківська картка", +"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", +"PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банківська картка", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему швидких платежів YooKassa", "PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 СБП (YooKassa)", "PAYMENT_HELEKET_MARKUP_LABEL": "Націнка провайдера", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 84210ac8..3c0b906f 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -1042,7 +1042,6 @@ "PAL24_INSTRUCTION_FOLLOW":"{step}.按照支付系统提示操作", "PAL24_PAYMENT_ERROR":"❌创建PayPalych付款失败。请稍后再试或联系支持。", "PAL24_PAYMENT_INSTRUCTIONS":"🏦通过PayPalych(SBP)付款\n\n💰金额:{amount}\n🆔账单ID:{bill_id}\n\n📱说明:\n1.点击“通过PayPalych(SBP)付款”按钮\n2.按照支付系统提示操作\n3.确认转账\n4.资金将自动到账\n\n❓如果遇到问题,请联系{support}", -"PAL24_PAY_BUTTON":"🏦通过PayPalych(SBP)付款", "PAL24_SBP_PAY_BUTTON":"🏦通过PayPalych(SBP)付款", "PAL24_SELECT_PAYMENT_METHOD":"请选择PayPalych支付方式:", "PAL24_TOPUP_PROMPT":"🏦通过PayPalych(SBP)付款\n\n请输入充值金额,范围100至1000000₽。\n付款通过PayPalych快速支付系统进行。", @@ -1080,12 +1079,8 @@ "PAYMENT_METHODS_UNAVAILABLE_ALERT":"⚠️目前自动支付方式暂时不可用。如需充值余额,请联系技术支持。", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION":"通过CryptoBot", "PAYMENT_METHOD_CRYPTOBOT_NAME":"🪙加密货币", -"PAYMENT_METHOD_HELEKET_DESCRIPTION":"通过Heleket", -"PAYMENT_METHOD_HELEKET_NAME":"🪙加密货币(Heleket)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION":"通过{mulenpay_name}", "PAYMENT_METHOD_MULENPAY_NAME":"💳银行卡({mulenpay_name})", -"PAYMENT_METHOD_PAL24_DESCRIPTION":"通过快速支付系统", -"PAYMENT_METHOD_PAL24_NAME":"🏦SBP(PayPalych)", "PAYMENT_METHOD_PLATEGA_DESCRIPTION":"通过Platega(银行卡+SBP)", "PAYMENT_METHOD_PLATEGA_NAME":"💳银行卡(Platega)", "PAYMENT_METHOD_STARS_DESCRIPTION":"快速便捷", @@ -1094,8 +1089,6 @@ "PAYMENT_METHOD_SUPPORT_NAME":"🛠️通过支持", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION":"通过Tribute", "PAYMENT_METHOD_TRIBUTE_NAME":"💳银行卡", -"PAYMENT_METHOD_WATA_DESCRIPTION":"通过WATA", -"PAYMENT_METHOD_WATA_NAME":"💳银行卡(WATA)", "PAYMENT_METHOD_YOOKASSA_DESCRIPTION":"通过YooKassa", "PAYMENT_METHOD_YOOKASSA_NAME":"💳银行卡", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION":"通过YooKassa快速支付系统", diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index c032f990..27d24a2b 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -255,6 +255,42 @@ class HeleketPaymentMixin: if updated_payment is None: return None + metadata = dict(getattr(updated_payment, "metadata_json", {}) or {}) + invoice_message = metadata.get("invoice_message") or {} + invoice_message_removed = False + + if getattr(self, "bot", None) and invoice_message: + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on rights + logger.warning( + "Не удалось удалить счёт Heleket %s: %s", + message_id, + delete_error, + ) + else: + metadata.pop("invoice_message", None) + invoice_message_removed = True + + if invoice_message_removed: + try: + from app.database.crud import heleket as heleket_crud + + await heleket_crud.update_heleket_payment( + db, + updated_payment.uuid, + metadata=metadata, + ) + updated_payment.metadata_json = metadata + except Exception as error: # pragma: no cover - diagnostics + logger.warning( + "Не удалось обновить метаданные Heleket после удаления счёта: %s", + error, + ) + if updated_payment.transaction_id: logger.info( "Heleket платеж %s уже связан с транзакцией %s", diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 7b4ffcf3..27dea36d 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -182,7 +182,43 @@ class MulenPayPaymentMixin: ) return False + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + invoice_message = metadata.get("invoice_message") or {} + + invoice_message_removed = False + + if getattr(self, "bot", None): + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning( + "Не удалось удалить %s счёт %s: %s", + display_name, + message_id, + delete_error, + ) + else: + metadata.pop("invoice_message", None) + invoice_message_removed = True + if payment.is_paid: + if invoice_message_removed: + try: + await payment_module.update_mulenpay_payment_metadata( + db, + payment=payment, + metadata=metadata, + ) + except Exception as error: # pragma: no cover - diagnostics + logger.warning( + "Не удалось обновить метаданные %s после удаления счёта: %s", + display_name, + error, + ) + logger.info( "%s платеж %s уже обработан, игнорируем повторный callback", display_name, @@ -197,6 +233,7 @@ class MulenPayPaymentMixin: status="success", callback_payload=callback_data, mulen_payment_id=mulen_payment_id_int, + metadata=metadata, ) if payment.transaction_id: diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 6dbe3dc5..460e0e31 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -333,6 +333,41 @@ class Pal24PaymentMixin: payment_module = import_module("app.services.payment_service") + metadata = dict(getattr(payment, "metadata_json", {}) or {}) + invoice_message = metadata.get("invoice_message") or {} + invoice_message_removed = False + + if getattr(self, "bot", None) and invoice_message: + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on rights + logger.warning( + "Не удалось удалить счёт PayPalych %s: %s", + message_id, + delete_error, + ) + else: + metadata.pop("invoice_message", None) + invoice_message_removed = True + + if invoice_message_removed: + try: + await payment_module.update_pal24_payment_status( + db, + payment, + status=payment.status, + metadata=metadata, + ) + payment.metadata_json = metadata + except Exception as error: # pragma: no cover - diagnostics + logger.warning( + "Не удалось обновить метаданные PayPalych после удаления счёта: %s", + error, + ) + if payment.transaction_id: logger.info( "Pal24 платеж %s уже привязан к транзакции (trigger=%s)", diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index fc7fe4d8..2d08997a 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -306,6 +306,22 @@ class PlategaPaymentMixin: metadata = dict(getattr(payment, "metadata_json", {}) or {}) balance_already_credited = bool(metadata.get("balance_credited")) + invoice_message = metadata.get("invoice_message") or {} + if getattr(self, "bot", None): + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning( + "Не удалось удалить Platega счёт %s: %s", + message_id, + delete_error, + ) + else: + metadata.pop("invoice_message", None) + if payment.transaction_id: logger.info( "Platega платеж %s уже связан с транзакцией %s", diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index b7fd5942..3f1022d7 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -515,36 +515,6 @@ class TelegramStarsMixin: exc_info=True, ) - if getattr(self, "bot", None): - try: - keyboard = await self.build_topup_success_keyboard(user) - - charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8] - - await self.bot.send_message( - user.telegram_id, - ( - "✅ Пополнение успешно!\n\n" - f"⭐ Звезд: {stars_amount}\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - "🦊 Способ: Telegram Stars\n" - f"🆔 Транзакция: {charge_id_short}...\n\n" - "Баланс пополнен автоматически!" - ), - parse_mode="HTML", - reply_markup=keyboard, - ) - logger.info( - "✅ Отправлено уведомление пользователю %s о пополнении на %s", - user.telegram_id, - settings.format_price(amount_kopeks), - ) - except Exception as error: # pragma: no cover - диагностический лог - logger.error( - "Ошибка отправки уведомления о пополнении Stars: %s", - error, - ) - # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index d8bc789a..7072d096 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -415,6 +415,25 @@ class WataPaymentMixin: if not paid_at and getattr(payment, "paid_at", None): paid_at = payment.paid_at existing_metadata = dict(getattr(payment, "metadata_json", {}) or {}) + + invoice_message = existing_metadata.get("invoice_message") or {} + invoice_message_removed = False + if getattr(self, "bot", None) and invoice_message: + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on rights + logger.warning( + "Не удалось удалить счёт WATA %s: %s", + message_id, + delete_error, + ) + else: + invoice_message_removed = True + existing_metadata.pop("invoice_message", None) + existing_metadata["transaction"] = transaction_payload await payment_module.update_wata_payment_status( diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 181ab94d..465664a6 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -383,11 +383,19 @@ class YooKassaPaymentMixin: payment_module = import_module("app.services.payment_service") # Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования) - existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] - db, - payment.yookassa_payment_id, - PaymentMethod.YOOKASSA, + get_transaction_by_external_id = getattr( + payment_module, "get_transaction_by_external_id", None ) + existing_transaction = None + if get_transaction_by_external_id: + try: + existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined] + db, + payment.yookassa_payment_id, + PaymentMethod.YOOKASSA, + ) + except AttributeError: + logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов") if existing_transaction: # Если транзакция уже существует, просто завершаем обработку @@ -437,6 +445,22 @@ class YooKassaPaymentMixin: except Exception as parse_error: logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}") + invoice_message = payment_metadata.get("invoice_message") or {} + if getattr(self, "bot", None): + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if chat_id and message_id: + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as delete_error: # pragma: no cover - depends on bot rights + logger.warning( + "Не удалось удалить сообщение YooKassa %s: %s", + message_id, + delete_error, + ) + else: + payment_metadata.pop("invoice_message", None) + processing_completed = bool(payment_metadata.get("processing_completed")) transaction = None @@ -472,11 +496,20 @@ class YooKassaPaymentMixin: ) if transaction is None: - existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined] - db, - payment.yookassa_payment_id, - PaymentMethod.YOOKASSA, + get_transaction_by_external_id = getattr( + payment_module, "get_transaction_by_external_id", None ) + existing_transaction = None + + if get_transaction_by_external_id: + try: + existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined] + db, + payment.yookassa_payment_id, + PaymentMethod.YOOKASSA, + ) + except AttributeError: + logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов") if existing_transaction: # Если транзакция уже существует, пропускаем обработку diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 9f417d73..949f7b23 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -112,6 +112,11 @@ async def update_mulenpay_payment_status(*args, **kwargs): return await mulenpay_crud.update_mulenpay_payment_status(*args, **kwargs) +async def update_mulenpay_payment_metadata(*args, **kwargs): + mulenpay_crud = import_module("app.database.crud.mulenpay") + return await mulenpay_crud.update_mulenpay_payment_metadata(*args, **kwargs) + + async def link_mulenpay_payment_to_transaction(*args, **kwargs): mulenpay_crud = import_module("app.database.crud.mulenpay") return await mulenpay_crud.link_mulenpay_payment_to_transaction(*args, **kwargs) diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index bef09a91..3a6d8c07 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -23,10 +23,30 @@ logger = logging.getLogger(__name__) class TributeService: + _invoice_messages: Dict[int, Dict[str, int]] = {} def __init__(self, bot: Bot): self.bot = bot self.tribute_api = TributeAPI() + + @classmethod + def remember_invoice_message(cls, user_id: int, chat_id: int, message_id: int) -> None: + cls._invoice_messages[user_id] = {"chat_id": chat_id, "message_id": message_id} + + async def _cleanup_invoice_message(self, user_id: int) -> None: + invoice_message = self._invoice_messages.pop(user_id, None) + if not invoice_message or not getattr(self, "bot", None): + return + + chat_id = invoice_message.get("chat_id") + message_id = invoice_message.get("message_id") + if not chat_id or not message_id: + return + + try: + await self.bot.delete_message(chat_id, message_id) + except Exception as error: # pragma: no cover - depends on bot rights + logger.warning("Не удалось удалить Tribute счёт %s: %s", message_id, error) async def create_payment_link( self, @@ -174,7 +194,8 @@ class TributeService: ) except Exception as e: logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}") - + + await self._cleanup_invoice_message(user_telegram_id) await self._send_success_notification(user_telegram_id, amount_kopeks) logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}") diff --git a/tests/external/test_yookassa_webhook.py b/tests/external/test_yookassa_webhook.py index 03b98927..3ae1f6ff 100644 --- a/tests/external/test_yookassa_webhook.py +++ b/tests/external/test_yookassa_webhook.py @@ -134,9 +134,9 @@ async def test_handle_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: status = response.status text = await response.text() - assert status == 200 - assert text == "OK" - process_mock.assert_awaited_once() + assert status == 400 + assert text == "No payment id" + process_mock.assert_not_awaited() @pytest.mark.asyncio @@ -160,9 +160,9 @@ async def test_handle_webhook_trusts_cf_connecting_ip(monkeypatch: pytest.Monkey status = response.status text = await response.text() - assert status == 200 - assert text == "OK" - process_mock.assert_awaited_once() + assert status == 400 + assert text == "No payment id" + process_mock.assert_not_awaited() @pytest.mark.asyncio @@ -184,9 +184,9 @@ async def test_handle_webhook_with_optional_signature(monkeypatch: pytest.Monkey status = response.status text = await response.text() - assert status == 200 - assert text == "OK" - process_mock.assert_awaited_once() + assert status == 400 + assert text == "No payment id" + process_mock.assert_not_awaited() @pytest.mark.asyncio