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