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