Handle missing YooKassa payment ids gracefully

This commit is contained in:
Egor
2025-11-21 05:26:42 +03:00
parent b9586ad72a
commit 6dc525dd72
26 changed files with 745 additions and 169 deletions

View File

@@ -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,
*,

View File

@@ -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

View File

@@ -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")

View File

@@ -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()

View File

@@ -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(

View File

@@ -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,

View File

@@ -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()

View File

@@ -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"⭐ <b>Оплата через Telegram Stars</b>\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("⚠️ Ошибка создания платежа")
await message.answer("⚠️ Ошибка создания платежа")

View File

@@ -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"💳 <b>Пополнение банковской картой</b>\n\n"
f"• Введите любую сумму от 100₽\n"
f"• Безопасная оплата через Tribute\n"
f"• Мгновенное зачисление на баланс\n"
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
f"💳 <b>Пополнение банковской картой</b>\n\n",
f"• Введите любую сумму от 100₽\n",
f"• Безопасная оплата через Tribute\n",
f"• Мгновенное зачисление на баланс\n",
f"• Принимаем карты Visa, MasterCard, МИР\n\n",
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n",
f"Нажмите кнопку для перехода к оплате:",
reply_markup=keyboard,
parse_mode="HTML"
parse_mode="HTML",
)
TributeService.remember_invoice_message(
db_user.telegram_id,
callback.message.chat.id,
callback.message.message_id,
)
except Exception as e:
logger.error(f"Ошибка создания Tribute платежа: {e}")
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
await callback.answer()
await callback.answer()

View File

@@ -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(

View File

@@ -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"💳 <b>Оплата банковской картой</b>\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"🔗 <b>Оплата через СБП</b>\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']}")

View File

@@ -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 платежей")

View File

@@ -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": "🏦 <b>PayPalych (SBP) payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\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": "🏦 <b>PayPalych (SBP) payment</b>\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": "🪙 <b>Cryptocurrency</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Cryptocurrency (Heleket)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
@@ -1075,8 +1070,6 @@
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",

View File

@@ -1043,7 +1043,6 @@
"PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\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": "🏦 <b>Оплата через PayPalych (СБП)</b>\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": "🪙 <b>Криптовалюта</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
@@ -1095,8 +1090,6 @@
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",

View File

@@ -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": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сума: {amount}\n🆔 ID рахунку: {bill_id}\n\n📱 <b>Інструкція:</b>\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": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сума: {amount}\n🆔 ID рахунку: {bill_id}\n\n📱 <b>Інструкція:</b>\n1. Натисніть кнопку ‘Оплатити через PayPalych (СБП)\n2. Дотримуйтесь підказок платіжної системи\n3. Підтвердіть переказ\n4. Кошти зарахуються автоматично\n\n❓ Якщо виникнуть проблеми, зверніться до {support}",
"PAL24_SBP_PAY_BUTTON": "🏦 Оплатити через PayPalych (СБП)",
"PAL24_SELECT_PAYMENT_METHOD": "Оберіть спосіб оплати PayPalych:",
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\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": "🪙 <b>Криптовалюта</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банківська картка ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему швидких платежів",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (картки + СБП)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банківська картка (Platega)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банківська картка ({mulenpay_name})</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (картки + СБП)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банківська картка (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "швидко та зручно",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "інші способи",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через підтримку</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банківська картка</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банківська картка (WATA)</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банківська картка</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банківська картка</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банківська картка</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему швидких платежів YooKassa",
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
"PAYMENT_HELEKET_MARKUP_LABEL": "Націнка провайдера",

View File

@@ -1042,7 +1042,6 @@
"PAL24_INSTRUCTION_FOLLOW":"{step}.按照支付系统提示操作",
"PAL24_PAYMENT_ERROR":"❌创建PayPalych付款失败。请稍后再试或联系支持。",
"PAL24_PAYMENT_INSTRUCTIONS":"🏦<b>通过PayPalych(SBP)付款</b>\n\n💰金额{amount}\n🆔账单ID{bill_id}\n\n📱<b>说明:</b>\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":"🏦<b>通过PayPalych(SBP)付款</b>\n\n请输入充值金额范围100至1000000₽。\n付款通过PayPalych快速支付系统进行。",
@@ -1080,12 +1079,8 @@
"PAYMENT_METHODS_UNAVAILABLE_ALERT":"⚠️目前自动支付方式暂时不可用。如需充值余额,请联系技术支持。",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION":"通过CryptoBot",
"PAYMENT_METHOD_CRYPTOBOT_NAME":"🪙<b>加密货币</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION":"通过Heleket",
"PAYMENT_METHOD_HELEKET_NAME":"🪙<b>加密货币(Heleket)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION":"通过{mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME":"💳<b>银行卡({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION":"通过快速支付系统",
"PAYMENT_METHOD_PAL24_NAME":"🏦<b>SBP(PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION":"通过Platega(银行卡+SBP)",
"PAYMENT_METHOD_PLATEGA_NAME":"💳<b>银行卡(Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION":"快速便捷",
@@ -1094,8 +1089,6 @@
"PAYMENT_METHOD_SUPPORT_NAME":"🛠️<b>通过支持</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION":"通过Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME":"💳<b>银行卡</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION":"通过WATA",
"PAYMENT_METHOD_WATA_NAME":"💳<b>银行卡(WATA)</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION":"通过YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME":"💳<b>银行卡</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION":"通过YooKassa快速支付系统",

View File

@@ -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",

View File

@@ -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:

View File

@@ -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)",

View File

@@ -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",

View File

@@ -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,
(
"✅ <b>Пополнение успешно!</b>\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

View File

@@ -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(

View File

@@ -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:
# Если транзакция уже существует, пропускаем обработку

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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